//! Integration tests of the settings document: the `craft-settings` store //! round trip, recovery of a damaged file, newer schema files, every repair //! (SPEC 3, 4.1, 6 item 14). The commands are covered in //! [`settings_commands`](super); both run against a throw-away `config.toml` //! folder the session points at, so nothing here touches a real config or a //! real vault. use notescraft_engine::{AppSettings, Error, SCHEMA_VERSION}; use common::{TempVault, temp_config}; mod common; /// A temp vault with its own settings folder to load and save against. struct Fix { config: TempVault, } impl Fix { #[must_use] fn new(name: &str) -> Self { Self { config: temp_config(name) } } } /// The `config.toml` inside the settings folder. fn config_file(fix: &Fix) -> std::path::PathBuf { fix.config.path().join("config.toml") } /// (settings, notes) from the settings folder of `fix`. fn load_from(config: &TempVault) -> (AppSettings, Vec) { notescraft_engine::load_at(config.path()) } /// Defaults survive the TOML round trip with no notes at all. #[test] fn defaults_round_trip() { let fix = Fix::new("round-trip"); let before = AppSettings::default(); notescraft_engine::save_at(fix.config.path(), &before).expect("save the defaults"); assert!(config_file(&fix).exists(), "the store writes the file even for defaults"); let (settings, notes) = load_from(&fix.config); assert_eq!(settings, before); assert_eq!(notes, &[] as &[String], "{notes:?}"); } /// A damaged `config.toml` is moved aside, defaults are in use and the note /// says where the bytes went. #[test] fn a_corrupt_file_is_recovered_with_a_note() { let fix = Fix::new("corrupt"); let broken = "this is = not toml ]["; std::fs::write(config_file(&fix), broken).expect("write the broken file"); let (settings, notes) = load_from(&fix.config); assert_eq!(settings, AppSettings::default()); assert_eq!(notes.len(), 1, "{notes:?}"); assert!(notes[0].starts_with("the damaged config.toml was kept at "), "{}", notes[0]); assert!(notes[0].contains("and the defaults are in use"), "{}", notes[0]); let kept: Vec<_> = std::fs::read_dir(fix.config.path()) .unwrap_or_else(|error| panic!("read the config dir: {error}")) .filter_map(Result::ok) .map(|entry| entry.file_name().to_string_lossy().into_owned()) .collect(); assert!(kept.iter().any(|name| name.starts_with("config.broken-")), "kept = {kept:?}, the corrupt bytes are kept"); } /// A new `schema_version` file from a newer build is read (its known sections /// are in use) but never overwritten. #[test] fn a_newer_schema_file_is_read_but_not_saved() { let fix = Fix::new("newer"); std::fs::write(config_file(&fix), b"schema_version = 2\n\n[session]\ndaily_folder = \"Notes\"\n\n[hotkeys]\n\"note.read\" = \"Mod-r\"\n") .expect("write the newer file"); let (settings, notes) = load_from(&fix.config); assert_eq!(settings.session.daily_folder, "Notes", "known keys are still usable"); assert_eq!(settings.hotkeys.get("note.read").map(String::as_str), Some("Mod-r")); let joined = notes.join("\n"); assert!(joined.contains("config.toml is read-only: config.toml has schema version 2"), "{joined}"); let refused = notescraft_engine::save_at(fix.config.path(), &AppSettings::default()); assert!(matches!(refused, Err(Error::Settings(ref message)) if message.contains("read-only")), "{refused:?}"); let text = std::fs::read_to_string(config_file(&fix)).unwrap_or_default(); assert!(text.starts_with("schema_version = 2"), "the newer file is untouched: {text}"); } /// A file one version older runs the (empty) migration hook per step and gets /// the migration note. #[test] fn an_older_schema_file_migrates() { let fix = Fix::new("migrate"); std::fs::write(config_file(&fix), b"schema_version = 0\n\n[session]\ntheme = \"latte\"\n").expect("write the old file"); let (settings, notes) = load_from(&fix.config); assert_eq!(settings.session.theme, "latte"); assert!(notes.iter().any(|note| note.contains("config.toml was migrated from schema version 0")), "{notes:?}"); // The migrated document is written back at the current version, too. notescraft_engine::save_at(fix.config.path(), &settings).expect("the migrated file can be saved over"); let text = std::fs::read_to_string(config_file(&fix)).unwrap_or_default(); assert!(text.contains(&format!("schema_version = {SCHEMA_VERSION}")), "{text}"); } /// A section that parses as TOML but does not fit the document is replaced by /// its defaults and reported, while the other sections stay. #[test] fn a_broken_section_is_reset_with_a_note() { let fix = Fix::new("broken-section"); std::fs::write(config_file(&fix), b"schema_version = 1\n\nhotkeys = \"oops\"\n\n[session]\ntheme = \"latte\"\n").expect("write"); let (settings, notes) = load_from(&fix.config); assert_eq!(settings.hotkeys, std::collections::BTreeMap::new(), "the broken section is at its defaults"); assert_eq!(settings.session.theme, "latte", "the other sections survive"); assert!(notes.iter().any(|note| note.starts_with("the `hotkeys` section was replaced by its defaults")), "{notes:?}"); } /// A hand-written file with bad values is repaired on load, the notes say /// what was repaired, and the next save stores the repaired document. #[test] fn bad_values_in_a_file_are_repaired_with_notes() { let fix = Fix::new("repairs"); let big = "x".repeat(notescraft_engine::MAX_UI_BYTES * 2); let document = format!( "schema_version = 1\n\nui = {{ theme = \"latte\", blob = \"{big}\" }}\n\nrecent_vaults = [\"b\", \"b\", \"\", \"a\"]\n\n[session]\ndaily_folder = \"../up\"\n\n[hotkeys]\n\"bad key\" = \"Mod-r\"\n\"ok\" = \"not ok!!\"\n" ); std::fs::write(config_file(&fix), document).expect("write the file with repairs"); let (settings, notes) = load_from(&fix.config); let joined = notes.join("\n"); assert!(joined.contains("session.daily_folder"), "{joined}"); assert!(joined.contains("ui: the opaque ui state is dropped"), "{joined}"); assert!(joined.contains("recent_vaults: kept"), "{joined}"); assert!(joined.contains("hotkeys: dropped"), "{joined}"); assert_eq!(settings.session.daily_folder, "Daily"); assert_eq!(settings.ui, AppSettings::default().ui); assert_eq!(settings.recent_vaults, ["b", "a"].iter().map(|s| (*s).to_owned()).collect::>()); assert_eq!(settings.hotkeys, std::collections::BTreeMap::new()); notescraft_engine::save_at(fix.config.path(), &settings).expect("save"); let (again, again_notes) = load_from(&fix.config); assert_eq!(again, settings, "the repaired values stay over a save"); // The raw keys craft-settings preserves (hand edits survive a merge) are // still in the file, so they are repaired again, with the same notes. let wanted: Vec<&String> = again_notes.iter().filter(|note| note.starts_with("ui:") || note.starts_with("hotkeys:")).collect(); assert_eq!(wanted.len(), 2, "{again_notes:?}"); } /// The recents list keeps its order through the file round trip. #[test] fn recents_keep_their_order_through_the_file() { let fix = Fix::new("recents"); let settings = AppSettings { recent_vaults: ["zed", "old", "new"].iter().map(|s| (*s).to_owned()).collect(), ..AppSettings::default() }; notescraft_engine::save_at(fix.config.path(), &settings).expect("save"); let (loaded, notes) = load_from(&fix.config); assert_eq!(notes, &[] as &[String], "{notes:?}"); assert_eq!(loaded.recent_vaults, settings.recent_vaults, "newest first, in order"); } /// The attachment cap lives in the nested `session.files` group and is set and /// repaired like every other session setting (SPEC 9). #[test] fn the_attachment_cap_is_a_session_files_setting() { use notescraft_engine::{FilesConfig, Session, SessionConfig}; let vault = temp_config("attach-cap-setting"); let mut session = Session::open(vault.path()).expect("open"); session.use_settings_dir(temp_config("attach-cap-cfg").path()); assert_eq!(session.config().files.max_attachment_mib, 64, "the default"); let mut registry = notescraft_engine::Registry::new(); notescraft_engine::register_core(&mut registry).expect("register"); let set = |session: &mut Session, key: &str, value: i64| { let params = serde_json::json!({ "key": key, "value": value }); registry.run("settings.set", session, ¶ms) }; let ok = set(&mut session, "session.files.max_attachment_mib", 128); assert!(ok.is_ok(), "{ok:?}"); assert_eq!(session.config().files.max_attachment_mib, 128, "applied to the live session"); // 0 is refused at the config boundary... let config = SessionConfig { files: FilesConfig { max_attachment_mib: 0, ..FilesConfig::default() }, ..SessionConfig::default() }; assert!(config.validate().is_err(), "0 is refused"); // ...and repaired to the default with a note through the settings document. let repaired = set(&mut session, "session.files.max_attachment_mib", 0); assert!(repaired.is_ok(), "{repaired:?}"); assert_eq!(session.config().files.max_attachment_mib, 64, "repaired to the default"); let notes = repaired .unwrap_or_default() .get("notes") .and_then(serde_json::Value::as_array) .map(|notes| notes.iter().filter_map(|note| note.as_str()).collect::>().join("; ")) .unwrap_or_default(); assert!(notes.contains("session.files.max_attachment_mib"), "{notes}"); } /// The knowledge settings live in the nested `session.knowledge` group: a value /// round-trips through `settings.set` and an out-of-range one is refused. #[test] fn knowledge_settings_round_trip_through_settings_set() { use notescraft_engine::Session; let vault = temp_config("knowledge-setting"); let mut session = Session::open(vault.path()).expect("open"); session.use_settings_dir(temp_config("knowledge-cfg").path()); assert_eq!(session.config().knowledge.passages, 6, "the default"); let mut registry = notescraft_engine::Registry::new(); notescraft_engine::register_core(&mut registry).expect("register"); let set = |session: &mut Session, key: &str, value: serde_json::Value| { let params = serde_json::json!({ "key": key, "value": value }); registry.run("settings.set", session, ¶ms) }; let ok = set(&mut session, "session.knowledge.passages", serde_json::json!(12)); assert!(ok.is_ok(), "{ok:?}"); assert_eq!(session.config().knowledge.passages, 12, "applied to the live session"); // 0 is never applied: the settings document repairs the section to its defaults and says so. let repaired = set(&mut session, "session.knowledge.passages", serde_json::json!(0)); assert!(repaired.is_ok(), "{repaired:?}"); assert_eq!(session.config().knowledge.passages, 6, "repaired to the default, not clamped to 1"); let notes = repaired.unwrap_or_default().to_string(); assert!(notes.contains("knowledge"), "{notes}"); } /// `session.lint.*` and `editor.blocks.*` are settings paths that apply to the live session. #[test] fn lint_and_block_settings_round_trip_through_settings_set() { use notescraft_engine::Session; let vault = temp_config("lint-setting"); let mut session = Session::open(vault.path()).expect("open"); session.use_settings_dir(temp_config("lint-cfg").path()); assert!(session.config().lint.learn_style, "the default"); let mut registry = notescraft_engine::Registry::new(); notescraft_engine::register_core(&mut registry).expect("register"); let set = |session: &mut Session, key: &str, value: serde_json::Value| { let params = serde_json::json!({ "key": key, "value": value }); registry.run("settings.set", session, ¶ms) }; assert!(set(&mut session, "session.lint.learn_style", serde_json::json!(false)).is_ok()); assert!(!session.config().lint.learn_style); assert!(session.config().lint.learn_list_marker && session.config().lint.learn_date_format, "the aspects default on"); assert!(set(&mut session, "session.lint.learn_list_marker", serde_json::json!(false)).is_ok()); assert!(set(&mut session, "session.lint.learn_date_format", serde_json::json!(false)).is_ok()); assert!(!session.config().lint.learn_list_marker && !session.config().lint.learn_date_format); assert!(set(&mut session, "editor.blocks.grip", serde_json::json!(false)).is_ok()); assert!(set(&mut session, "editor.blocks.nope", serde_json::json!(true)).is_err(), "unknown key refused"); } /// A session with its own config folder and a registry holding the core commands. fn preview_session(name: &str) -> (common::TempVault, notescraft_engine::Registry, notescraft_engine::Session) { let vault = temp_config(name); let config = temp_config(&format!("{name}-cfg")); let mut session = notescraft_engine::Session::open(vault.path()).expect("open the vault"); session.use_settings_dir(config.path()); let mut registry = notescraft_engine::Registry::new(); notescraft_engine::register_core(&mut registry).expect("register the core commands"); (config, registry, session) } /// The two preview switches as the document holds them. fn saved_previews(config: &common::TempVault) -> (bool, bool) { let (settings, notes) = load_from(config); assert_eq!(notes, &[] as &[String], "{notes:?}"); (settings.editor.previews.colors, settings.editor.previews.named_colors) } /// `editor.previews.*` set, save, come back out of `config.toml` and refuse an /// ill-typed value without touching the file. #[test] fn preview_settings_round_trip_through_settings_set() { let defaults = notescraft_engine::PreviewSettings::default(); assert!(defaults.colors, "color swatches are on by default"); assert!(!defaults.named_colors, "named colors are off by default"); let (config, registry, mut session) = preview_session("previews-set"); let set = |session: &mut notescraft_engine::Session, key: &str, value: serde_json::Value| { let params = serde_json::json!({ "key": key, "value": value }); registry.run("settings.set", session, ¶ms) }; assert!(set(&mut session, "editor.previews.colors", serde_json::json!(false)).is_ok()); assert!(set(&mut session, "editor.previews.named_colors", serde_json::json!(true)).is_ok()); assert_eq!(saved_previews(&config), (false, true), "both switches survive the file"); let got = registry.run("settings.get", &mut session, &serde_json::json!({})).expect("get"); let previews = got.get("settings").and_then(|settings| settings.get("editor")).and_then(|editor| editor.get("previews")).cloned(); assert_eq!(previews, Some(serde_json::json!({ "colors": false, "named_colors": true })), "{got}"); let before = std::fs::read(config.path().join("config.toml")).expect("read the config file"); for (key, value) in [("editor.previews.colors", serde_json::json!("yes")), ("editor.previews.named_colors", serde_json::json!(3))] { let refused = set(&mut session, key, value); assert!(refused.is_err(), "`{key}` with the wrong type is refused"); } let after = std::fs::read(config.path().join("config.toml")).expect("read the config file again"); assert_eq!(before, after, "a refused value leaves config.toml byte-identical"); } /// A `config.toml` written before the switches existed loads with the defaults. #[test] fn preview_settings_default_for_older_config_files() { let fix = Fix::new("previews-old"); std::fs::write(config_file(&fix), b"schema_version = 1\n\n[editor]\nvim = false\n\n[editor.blocks]\ngrip = false\n").expect("write the old file"); let (settings, notes) = load_from(&fix.config); assert_eq!(notes, &[] as &[String], "{notes:?}"); assert_eq!(settings.editor.previews, notescraft_engine::PreviewSettings::default(), "a file without the group gets the defaults"); assert_eq!(saved_previews(&fix.config), (true, false)); assert_eq!(AppSettings::default().editor.previews, notescraft_engine::PreviewSettings::default()); } /// `settings.reset editor.previews` puts both switches back. #[test] fn settings_reset_restores_the_preview_defaults() { let (config, registry, mut session) = preview_session("previews-reset"); for (key, value) in [("editor.previews.colors", serde_json::json!(false)), ("editor.previews.named_colors", serde_json::json!(true))] { let params = serde_json::json!({ "key": key, "value": value }); assert!(registry.run("settings.set", &mut session, ¶ms).is_ok(), "{key}"); } assert_eq!(saved_previews(&config), (false, true)); let params = serde_json::json!({ "key": "editor.previews.named_colors" }); assert!(registry.run("settings.reset", &mut session, ¶ms).is_ok()); assert_eq!(saved_previews(&config), (false, false), "one key resets to its own default"); for (key, value) in [("editor.previews.colors", serde_json::json!(false)), ("editor.previews.named_colors", serde_json::json!(true))] { let params = serde_json::json!({ "key": key, "value": value }); assert!(registry.run("settings.set", &mut session, ¶ms).is_ok(), "{key}"); } let params = serde_json::json!({ "key": "editor.previews" }); assert!(registry.run("settings.reset", &mut session, ¶ms).is_ok()); assert_eq!(saved_previews(&config), (true, false), "the whole group resets too"); } /// The `session.files` group as it sits in `config.toml`. fn saved_files(config: &common::TempVault) -> serde_json::Value { let (settings, notes) = load_from(config); assert_eq!(notes, &[] as &[String], "{notes:?}"); serde_json::to_value(&settings).expect("serialize settings").pointer("/session/files").cloned().unwrap_or_default() } /// The new-note folder, the link format, the link path, the deleted-file /// behaviour and the hidden-file switch live in `session.files`, default the /// way the design says, and set, save and reset like every other key. #[test] fn new_note_and_link_settings_round_trip_through_settings_set() { let defaults = serde_json::to_value(notescraft_engine::FilesConfig::default()).expect("the default files section"); assert_eq!(defaults.pointer("/new_note_folder"), Some(&serde_json::json!(""))); assert_eq!(defaults.pointer("/link_format"), Some(&serde_json::json!("wikilink"))); assert_eq!(defaults.pointer("/link_path"), Some(&serde_json::json!("shortest"))); assert_eq!(defaults.pointer("/deleted_files"), Some(&serde_json::json!("trash"))); assert_eq!(defaults.pointer("/show_hidden_files"), Some(&serde_json::json!(false))); let (config, registry, mut session) = preview_session("files-set"); let set = |session: &mut notescraft_engine::Session, key: &str, value: serde_json::Value| { let params = serde_json::json!({ "key": key, "value": value }); registry.run("settings.set", session, ¶ms) }; let changed = [ ("session.files.new_note_folder", serde_json::json!("Notes/Inbox")), ("session.files.link_format", serde_json::json!("markdown")), ("session.files.link_path", serde_json::json!("relative")), ("session.files.deleted_files", serde_json::json!("permanent")), ("session.files.show_hidden_files", serde_json::json!(true)), ]; for (key, value) in &changed { assert!(set(&mut session, key, value.clone()).is_ok(), "{key} must be settable"); let field = key.trim_start_matches("session.files."); assert_eq!(saved_files(&config).pointer(&format!("/{field}")), Some(value), "{key} in config.toml"); } for (key, _) in &changed { let params = serde_json::json!({ "key": key }); assert!(registry.run("settings.reset", &mut session, ¶ms).is_ok(), "{key} must be resettable"); let field = key.trim_start_matches("session.files."); assert_eq!(saved_files(&config).pointer(&format!("/{field}")), defaults.pointer(&format!("/{field}")), "{key} back to its default"); } for (key, value) in &changed { let params = serde_json::json!({ "key": key, "value": value.clone() }); assert!(registry.run("settings.set", &mut session, ¶ms).is_ok(), "{key} must be settable again"); } let params = serde_json::json!({ "key": "session.files" }); assert!(registry.run("settings.reset", &mut session, ¶ms).is_ok()); assert_eq!(saved_files(&config), defaults, "the whole group resets too"); } /// A `config.toml` written before these keys existed loads with the defaults. #[test] fn file_settings_default_for_older_config_files() { let fix = Fix::new("files-old"); std::fs::write(config_file(&fix), b"schema_version = 1\n\n[session]\ndaily_folder = \"Notes\"\n\n[session.files]\nmax_attachment_mib = 8\n") .expect("write the old file"); let (settings, notes) = load_from(&fix.config); assert_eq!(notes, &[] as &[String], "{notes:?}"); assert_eq!(settings.session.files.max_attachment_mib, 8, "the keys the file has are kept"); assert_eq!(settings.session.files, notescraft_engine::FilesConfig { max_attachment_mib: 8, ..notescraft_engine::FilesConfig::default() }); } /// An unusable new-note folder is repaired to the vault root with a note, a /// wrong type and an unknown enum name are refused, and a refused set leaves /// `config.toml` byte-identical. #[test] fn file_settings_repair_and_refuse_bad_values() { let fix = Fix::new("files-bad-folder"); std::fs::write(config_file(&fix), b"schema_version = 1\n\n[session]\n\n[session.files]\nnew_note_folder = \"../up\"\n").expect("write the bad folder"); let (settings, notes) = load_from(&fix.config); assert_eq!(settings.session.files.new_note_folder, "", "a folder that escapes the vault is cleared"); let joined = notes.join("\n"); assert!(joined.contains("session.files.new_note_folder"), "{joined}"); let (config, registry, mut session) = preview_session("files-refusals"); let set = |session: &mut notescraft_engine::Session, key: &str, value: serde_json::Value| { let params = serde_json::json!({ "key": key, "value": value }); registry.run("settings.set", session, ¶ms) }; assert!(set(&mut session, "session.files.link_path", serde_json::json!("absolute")).is_ok(), "one good value first"); let before = std::fs::read(config.path().join("config.toml")).expect("read the config file"); for (key, value, code) in [ ("session.files.new_note_folder", serde_json::json!(["Notes"]), "invalid_value"), ("session.files.link_format", serde_json::json!("WikiLink"), "invalid_value"), ("session.files.link_path", serde_json::json!("shortest "), "invalid_value"), ("session.files.deleted_files", serde_json::json!(true), "invalid_value"), ("session.files.show_hidden_files", serde_json::json!("no"), "invalid_value"), ("session.files.nope", serde_json::json!(true), "unknown_key"), ] { let result = set(&mut session, key, value.clone()); assert!(matches!(result, Err(Error::Command { ref source, .. }) if source.code == code), "`{key}` = {value} must fail with {code}, got {result:?}"); } let after = std::fs::read(config.path().join("config.toml")).expect("read the config file again"); assert_eq!(before, after, "a refused value leaves config.toml byte-identical"); assert_eq!(saved_files(&config).pointer("/link_path"), Some(&serde_json::json!("absolute")), "the last good value survives"); } /// A new-note folder that escapes the vault is repaired to the vault root /// through `settings.set`, with the note that says so. #[test] fn a_bad_new_note_folder_is_cleared_through_settings_set() { let (config, registry, mut session) = preview_session("files-repair-set"); let params = serde_json::json!({ "key": "session.files.new_note_folder", "value": ".obsidian" }); let result = registry.run("settings.set", &mut session, ¶ms).expect("the set succeeds, repaired"); let notes = result.to_string(); assert!(notes.contains("session.files.new_note_folder"), "{notes}"); assert_eq!(saved_files(&config).pointer("/new_note_folder"), Some(&serde_json::json!(""))); }