//! Search in a session: the mirror stays in step with every write, and the //! `search` command checks its params (SPEC 6 item 6). mod common; use common::{on_disk, seed, temp_vault}; use notescraft_engine::{Error, Registry, Session, register_core}; use notescraft_vault::VaultEvent; use serde_json::{Value, json}; /// A registry plus a session on a two-note vault. fn fixture(name: &str) -> (Registry, Session, common::TempVault) { let vault = temp_vault(name); seed(vault.path(), &[("alpha.md", "# Alpha\n\nalpha has words\n"), ("notes/beta.md", "# Beta\n\nbeta is quiet\n")]); let mut registry = Registry::new(); register_core(&mut registry).expect("register"); let session = Session::open(vault.path()).expect("open"); (registry, session, vault) } /// The paths a query returns, via the session method. fn hits(session: &Session, query: &str) -> Vec { session.search(query, 20).expect("search").into_iter().map(|hit| hit.path).collect() } /// A note created by `note.create` is searchable, and nothing else changed. #[test] fn create_makes_a_note_findable() { let (_reg, mut session, _vault) = fixture("create"); assert!(hits(&session, "fresh").is_empty(), "nothing matches before the create"); session.create_note("fresh.md", "a fresh start\n").expect("create"); assert_eq!(hits(&session, "fresh"), vec!["fresh.md".to_owned()]); } /// After a rename the hit carries the new path and the old one is gone. #[test] fn rename_moves_the_hit() { let (_reg, mut session, _vault) = fixture("rename"); assert_eq!(hits(&session, "alpha"), vec!["alpha.md".to_owned()]); session.rename_note("alpha.md", "folder/Renamed.md", true).expect("rename"); assert_eq!(hits(&session, "alpha"), vec!["folder/Renamed.md".to_owned()], "the content survives the move"); } /// A deleted note leaves no hit behind. #[test] fn delete_removes_the_hit() { let (_reg, mut session, _vault) = fixture("delete"); assert_eq!(hits(&session, "quiet"), vec!["notes/beta.md".to_owned()]); session.delete_note("notes/beta.md").expect("delete"); assert_eq!(hits(&session, "quiet"), [] as [String; 0]); assert_eq!(hits(&session, "alpha"), vec!["alpha.md".to_owned()], "the other note is untouched"); } /// Saving a buffer re-indexes it, so the new words are findable and the old /// ones gone; unsaved edits are not searched. #[test] fn save_updates_the_mirror() { let (reg, mut session, vault) = fixture("save"); session.create_note("words.md", "# Words\n\nalpha has words\n").expect("create words.md"); reg.run("buffer.open", &mut session, &json!({ "path": "words.md" })).expect("open buffer"); let edited = reg.run("buffer.edit", &mut session, &json!({ "path": "words.md", "start": 0, "end": 7, "text": "rebrand" })).expect("edit"); assert_eq!(edited.get("dirty"), Some(&json!(true))); let body_edit = reg.run("buffer.edit", &mut session, &json!({ "path": "words.md", "start": 9, "end": 14, "text": "branded" })).expect("edit body"); assert_eq!(body_edit.get("dirty"), Some(&json!(true))); assert!(hits(&session, "rebrand").is_empty(), "no hit before the save"); assert!(hits(&session, "words").contains(&"words.md".to_owned()), "the disk text is still indexed"); reg.run("buffer.save", &mut session, &json!({ "path": "words.md" })).expect("save"); assert_eq!(hits(&session, "rebrand"), vec!["words.md".to_owned()]); assert!(hits(&session, "branded").contains(&"words.md".to_owned()), "the saved body is in the mirror"); assert_eq!(on_disk(vault.path(), "words.md"), "rebrand\n\nbranded has words\n"); } /// A change made outside the app is searchable right after `apply_event`. #[test] fn an_external_event_is_indexed() { let (_reg, mut session, vault) = fixture("external"); std::fs::write(vault.path().join("added.md"), "the outside word\n").expect("write outside the app"); let applied = session.apply_event(VaultEvent::Created(String::from("added.md"))).expect("apply"); assert_eq!(applied, notescraft_engine::Applied::Indexed); assert_eq!(hits(&session, "outside"), vec!["added.md".to_owned()]); session.apply_event(VaultEvent::Removed(String::from("added.md"))).expect("remove"); assert_eq!(hits(&session, "outside"), [] as [String; 0]); } /// The machine code of a refused search param (`invalid_params` or the /// domain-level `invalid_query`). fn code(error: &Error) -> &'static str { match error { Error::InvalidParams { .. } => "invalid_params", Error::Query(_) => "invalid_query", Error::Command { source, .. } => match source.code.as_str() { "invalid_params" => "invalid_params", "invalid_query" => "invalid_query", other => panic!("unexpected code `{other}`"), }, other => panic!("unexpected error {other:?}"), } } /// The search command reports hits with snippets, and refuses a broken limit. #[test] fn the_search_command_checks_its_params() { let (reg, mut session, _vault) = fixture("command"); let found = reg.run("search", &mut session, &json!({ "query": "alpha" })).expect("search"); let hits = found.get("hits").and_then(Value::as_array).expect("hits array"); assert_eq!(hits.len(), 1); let hit = hits.first().expect("one hit"); assert_eq!(hit.get("path"), Some(&json!("alpha.md"))); assert_eq!(hit.get("title_match"), Some(&json!(true))); assert!(hit.get("snippets").and_then(Value::as_array).is_some_and(|snippets| !snippets.is_empty()), "a snippet is shown"); assert!(reg.run("search", &mut session, &json!({ "query": "alpha", "limit": 200 })).is_ok(), "200 hits are allowed"); for bad in [json!({ "query": "alpha", "limit": 201 }), json!({ "query": "alpha", "limit": -1 }), json!({ "query": "alpha", "limit": "many" })] { let error = reg.run("search", &mut session, &bad).expect_err("must be refused"); assert_eq!(code(&error), "invalid_params", "`{bad}`: {error}"); } for empty in ["", " ", "-only"] { let error = reg.run("search", &mut session, &json!({ "query": empty })).expect_err("must be refused"); assert_eq!(code(&error), "invalid_query", "empty query `{empty}`"); } let error = reg.run("search", &mut session, &json!({ "query": "alpha", "typo": 1 })).expect_err("must be refused"); assert_eq!(code(&error), "invalid_params", "an unknown param is refused"); } /// The read-only session searches like the writable one. #[test] fn a_read_only_session_still_searches() { let vault = temp_vault("read-only-search"); seed(vault.path(), &[("alpha.md", "alpha words\n")]); let session = Session::open_read_only(vault.path()).expect("open read-only"); assert_eq!(hits(&session, "alpha"), vec!["alpha.md".to_owned()]); assert!(session.is_read_only()); }