Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace
with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/
video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked
tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits,
block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:21:19 -03:00

455 lines
24 KiB
Rust

//! 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<String>) {
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::<Vec<String>>());
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, &params)
};
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::<Vec<&str>>().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, &params)
};
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, &params)
};
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>, 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, &params)
};
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, &params).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, &params).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, &params).is_ok(), "{key}");
}
let params = serde_json::json!({ "key": "editor.previews" });
assert!(registry.run("settings.reset", &mut session, &params).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, &params)
};
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, &params).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, &params).is_ok(), "{key} must be settable again");
}
let params = serde_json::json!({ "key": "session.files" });
assert!(registry.run("settings.reset", &mut session, &params).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, &params)
};
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, &params).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!("")));
}