From cb6db8dd19731c90f2c820976dcd3a04e3954a21 Mon Sep 17 00:00:00 2001 From: guantw Date: Sun, 6 Sep 2026 10:53:14 +0800 Subject: [PATCH] fix(config): accept OpenBitFun 1.0 prerelease configurations Apply the product-generation floor to the numeric version core so beta and nightly configurations written by OpenBitFun 1.0 can be loaded and imported. Keep product identity, schema, pre-1.0 rejection, and actual-version writes unchanged. Cover prerelease validation, load-save-restart cycles, explicit imports, and account-settings imports with focused regression tests. --- .../core/src/service/config/manager.rs | 40 ++++++++++--- .../core/src/service/config/service.rs | 56 +++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/crates/assembly/core/src/service/config/manager.rs b/src/crates/assembly/core/src/service/config/manager.rs index 17a389ea95..e2d62e0a15 100644 --- a/src/crates/assembly/core/src/service/config/manager.rs +++ b/src/crates/assembly/core/src/service/config/manager.rs @@ -29,11 +29,11 @@ fn invalid_config_error(context: &str, result: &ConfigValidationResult) -> OpenB const MIN_OPENBITFUN_CONFIG_VERSION: (u64, u64, u64) = (1, 0, 0); -fn parse_semver_floor(version: &str) -> Option<((u64, u64, u64), bool)> { +fn parse_semver_floor(version: &str) -> Option<(u64, u64, u64)> { let without_build = version.split_once('+').map_or(version, |(value, _)| value); - let (core, has_prerelease) = without_build + let core = without_build .split_once('-') - .map_or((without_build, false), |(value, _)| (value, true)); + .map_or(without_build, |(value, _)| value); let mut parts = core.split('.'); let parsed = ( parts.next()?.parse().ok()?, @@ -43,7 +43,7 @@ fn parse_semver_floor(version: &str) -> Option<((u64, u64, u64), bool)> { if parts.next().is_some() { return None; } - Some((parsed, has_prerelease)) + Some(parsed) } pub(crate) fn validate_openbitfun_product_version( @@ -58,14 +58,14 @@ pub(crate) fn validate_openbitfun_product_version( ))); } - let Some((parsed, has_prerelease)) = parse_semver_floor(version) else { + let Some(parsed) = parse_semver_floor(version) else { return Err(OpenBitFunError::validation(format!( "{context} version '{version}' is not a valid OpenBitFun version" ))); }; - if parsed < MIN_OPENBITFUN_CONFIG_VERSION - || (parsed == MIN_OPENBITFUN_CONFIG_VERSION && has_prerelease) - { + // This floor separates product generations, not release-channel precedence. + // OpenBitFun 1.0 prereleases write the same product identity and schema. + if parsed < MIN_OPENBITFUN_CONFIG_VERSION { return Err(OpenBitFunError::validation(format!( "{context} version '{version}' predates OpenBitFun 1.0.0" ))); @@ -934,6 +934,30 @@ mod tests { } } + #[test] + fn current_config_contract_accepts_openbitfun_prerelease_versions() { + for version in [ + "1.0.0-beta.1", + "1.0.0-beta.2+build.7", + "1.0.0-nightly.20260906", + "1.0.0-rc.1", + "1.0.0", + "1.0.1-beta.1", + ] { + let mut current = serde_json::to_value(GlobalConfig::default()).unwrap(); + current["version"] = serde_json::json!(version); + validate_current_config_value(¤t, "test config") + .unwrap_or_else(|error| panic!("{version}: {error}")); + } + + for version in ["0.2.19", "0.9.9-beta.1", "0.9.9+build.7"] { + let mut legacy = serde_json::to_value(GlobalConfig::default()).unwrap(); + legacy["version"] = serde_json::json!(version); + let error = validate_current_config_value(&legacy, "test config").unwrap_err(); + assert!(error.to_string().contains("predates OpenBitFun 1.0.0")); + } + } + #[test] fn persistence_omits_default_memories_config() { let config = GlobalConfig::default(); diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 1dfeb4fbb8..0d48ac5150 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -1043,6 +1043,62 @@ mod tests { } } + #[tokio::test] + async fn prerelease_config_survives_load_save_and_restart() { + for version in ["1.0.0-beta.1", "1.0.0-nightly.20260906"] { + let name = "prerelease-config-restart"; + let (service, dir) = test_service(name).await; + let mut config: GlobalConfig = service.get_config(None).await.unwrap(); + config.version = version.to_string(); + config.app.language = "zh-CN".to_string(); + drop(service); + + let config_file = dir.path().join(name).join("config").join("app.json"); + let original = serde_json::to_string_pretty(&config).unwrap(); + tokio::fs::write(&config_file, &original).await.unwrap(); + let service = restart_test_service(&dir, name).await; + let loaded: GlobalConfig = service.get_config(None).await.unwrap(); + assert_eq!(loaded.version, version); + assert_eq!(loaded.app.language, "zh-CN"); + assert_eq!( + tokio::fs::read_to_string(&config_file).await.unwrap(), + original + ); + + service.set_config("app.language", &"en-US").await.unwrap(); + drop(service); + let restarted = restart_test_service(&dir, name).await; + let saved: GlobalConfig = restarted.get_config(None).await.unwrap(); + assert_eq!(saved.version, env!("CARGO_PKG_VERSION")); + assert_eq!(saved.app.language, "en-US"); + } + } + + #[tokio::test] + async fn prerelease_exports_support_explicit_import_and_account_settings() { + for version in ["1.0.0-beta.1", "1.0.0-nightly.20260906"] { + for account_sync in [false, true] { + let (service, _dir) = test_service("prerelease-config-import").await; + let mut export = current_export(GlobalConfig::default()); + export.version = version.to_string(); + export.config.version = version.to_string(); + export.config.app.language = "zh-CN".to_string(); + let export: ConfigExport = + serde_json::from_str(&serde_json::to_string(&export).unwrap()).unwrap(); + + let imported = if account_sync { + service.import_account_settings(export).await.unwrap() + } else { + service.import_config(export).await.unwrap() + }; + assert!(imported.success, "{:?}", imported.errors); + let config: GlobalConfig = service.get_config(None).await.unwrap(); + assert_eq!(config.app.language, "zh-CN"); + assert_eq!(config.version, env!("CARGO_PKG_VERSION")); + } + } + } + #[tokio::test] async fn pre_1_0_exports_are_rejected_without_changing_current_config() { for account_sync in [false, true] {