//! State file, per-vault file and store layout of the add-on registry. mod common; use notescraft_engine::addons::{Installed, Kind, Source, State, Store, VaultFile, Version, key}; fn v(major: u16, minor: u16, patch: u16) -> Version { Version { major, minor, patch } } fn row(version: Version, previous: Option) -> Installed { Installed { version, previous, source: Source::Registry, installed_at: "2026-10-21T12:00:00Z".into(), sha256_of_files: "e".repeat(64) } } fn sample() -> State { let mut state = State::default(); state.installed.insert(key(Kind::Grammar, "twig"), row(v(1, 1, 0), Some(v(1, 0, 0)))); state.installed.insert(key(Kind::Plugin, "dice"), row(v(0, 1, 0), None)); state } #[test] fn state_round_trip() { let state = sample(); assert_eq!(State::parse(state.to_json().unwrap().as_bytes()).unwrap(), state); } #[test] fn state_rejections() { assert!(State::parse(b"nope").is_err()); assert!(State::parse(br#"{"version":2,"installed":{}}"#).is_err()); assert!(State::parse(br#"{"installed":{}}"#).is_err()); let good = |k: &str| { format!(r#"{{"version":1,"installed":{{"{k}":{{"version":"1.0.0","source":"seed","installed_at":"t","sha256_of_files":"{}"}}}}}}"#, "e".repeat(64)) }; assert!(State::parse(good("grammar:twig").as_bytes()).is_ok()); for bad in ["twig", "exe:twig", "grammar:../x", "grammar:Twig", "grammar:"] { assert!(State::parse(good(bad).as_bytes()).is_err(), "{bad}"); } assert!(State::parse(good("grammar:a").replace("1.0.0", "1.0").as_bytes()).is_err()); assert!(State::parse(good("grammar:a").replace("seed", "magic").as_bytes()).is_err()); let mut big = State::default(); for i in 0..513 { big.installed.insert(key(Kind::Theme, &format!("t{i}")), row(v(1, 0, 0), None)); } assert!(big.to_json().is_err()); let rows: Vec = (0..513).map(|i| format!(r#""theme:t{i}":{{"version":"1.0.0","source":"seed","installed_at":"t","sha256_of_files":"{}"}}"#, "e".repeat(64))).collect(); assert!(State::parse(format!(r#"{{"version":1,"installed":{{{}}}}}"#, rows.join(",")).as_bytes()).is_err()); } #[test] fn vault_file_round_trip_and_caps() { let file = VaultFile { enabled: vec!["plugin:dice".into()], wanted: vec!["grammar:twig".into(), "theme:x".into()], ..VaultFile::default() }; assert_eq!(VaultFile::parse(file.to_json().unwrap().as_bytes()).unwrap(), file); assert!(VaultFile::parse(br#"{"version":2}"#).is_err()); assert!(VaultFile::parse(br#"{"version":1,"enabled":["grammar:twig"]}"#).is_err()); assert!(VaultFile::parse(br#"{"version":1,"wanted":["../x"]}"#).is_err()); let many: Vec = (0..257).map(|i| format!("grammar:g{i}")).collect(); assert!(VaultFile { wanted: many, ..VaultFile::default() }.to_json().is_err()); assert_eq!(VaultFile::parse(br#"{"version":1}"#).unwrap(), VaultFile::default()); } #[test] fn commit_and_load() { let dir = common::temp_config("addons-commit"); let store = Store::open(dir.path()).unwrap(); assert_eq!(store.load().unwrap(), State::default()); let state = sample(); store.commit(&state).unwrap(); assert_eq!(store.load().unwrap(), state); assert!(store.root().join("state.json").is_file()); } #[test] fn corrupt_state_errs_and_file_is_untouched() { let dir = common::temp_config("addons-corrupt"); let store = Store::open(dir.path()).unwrap(); let path = store.root().join("state.json"); std::fs::write(&path, b"{ not json").unwrap(); assert!(store.load().is_err()); assert!(store.gc(&State::default()).is_ok()); assert_eq!(std::fs::read(&path).unwrap(), b"{ not json"); } #[test] fn version_dir_uses_validated_parts() { let dir = common::temp_config("addons-vdir"); let store = Store::open(dir.path()).unwrap(); let path = store.version_dir(Kind::Grammar, "twig", v(1, 2, 3)).unwrap(); assert!(path.starts_with(store.root())); assert!(path.ends_with("store/grammar/twig/1.2.3")); assert!(store.version_dir(Kind::Grammar, "../x", v(1, 0, 0)).is_err()); assert!(store.version_dir(Kind::Grammar, "a/b", v(1, 0, 0)).is_err()); assert!(store.version_dir(Kind::Grammar, "", v(1, 0, 0)).is_err()); } #[test] fn gc_keeps_version_and_previous_only() { let dir = common::temp_config("addons-gc"); let store = Store::open(dir.path()).unwrap(); let state = sample(); let touch = |kind, id, ver| { let d = store.version_dir(kind, id, ver).unwrap(); std::fs::create_dir_all(&d).unwrap(); std::fs::write(d.join("f"), b"x").unwrap(); d }; let active = touch(Kind::Grammar, "twig", v(1, 1, 0)); let previous = touch(Kind::Grammar, "twig", v(1, 0, 0)); let old = touch(Kind::Grammar, "twig", v(0, 9, 0)); let orphan = touch(Kind::Theme, "gone", v(1, 0, 0)); let plugin = touch(Kind::Plugin, "dice", v(0, 1, 0)); std::fs::create_dir_all(store.staging_dir().join("abc")).unwrap(); std::fs::write(store.staging_dir().join("abc/part"), b"x").unwrap(); std::fs::write(store.staging_dir().join("loose"), b"x").unwrap(); let outside = dir.path().join("outside"); std::fs::create_dir_all(&outside).unwrap(); std::fs::write(outside.join("keep"), b"x").unwrap(); assert_eq!(store.gc(&state).unwrap(), 2); assert!(active.is_dir() && previous.is_dir() && plugin.is_dir()); assert!(!old.exists() && !orphan.exists()); assert!(!orphan.parent().unwrap().exists(), "empty parents are pruned"); assert_eq!(std::fs::read_dir(store.staging_dir()).unwrap().count(), 0); assert!(outside.join("keep").is_file()); } #[cfg(unix)] #[test] fn gc_does_not_follow_links() { let dir = common::temp_config("addons-link"); let store = Store::open(dir.path()).unwrap(); let outside = dir.path().join("outside"); std::fs::create_dir_all(&outside).unwrap(); std::fs::write(outside.join("keep"), b"x").unwrap(); let parent = store.version_dir(Kind::Theme, "t", v(1, 0, 0)).unwrap().parent().unwrap().to_path_buf(); std::fs::create_dir_all(&parent).unwrap(); std::os::unix::fs::symlink(&outside, parent.join("2.0.0")).unwrap(); store.gc(&State::default()).unwrap(); assert!(outside.join("keep").is_file()); assert!(!parent.join("2.0.0").exists()); }