//! `note.capture` over a `Session`: the daily note, the inbox, a folder, an attachment, an open //! buffer and hostile input. use notescraft_engine::{Registry, Session}; use serde_json::{Value, json}; mod common; use common::{on_disk, temp_config, temp_vault}; /// A session over a fresh vault plus the registry that holds the core commands. fn setup(name: &str) -> (common::TempVault, common::TempVault, Session, Registry) { let vault = temp_vault(name); let config = temp_config(&format!("{name}-cfg")); let mut session = Session::open(vault.path()).expect("open the session"); session.use_settings_dir(config.path()); let mut registry = Registry::new(); notescraft_engine::register_core(&mut registry).expect("register the core commands"); (vault, config, session, registry) } #[expect(clippy::needless_pass_by_value, reason = "the call sites pass json! literals")] fn capture(registry: &Registry, session: &mut Session, params: Value) -> Result { registry.run("note.capture", session, ¶ms).map_err(|error| error.to_string()) } fn path_of(answer: &Value) -> String { answer.get("path").and_then(Value::as_str).unwrap_or_default().to_owned() } /// A tiny valid PNG (1x1) as base64. const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; #[test] fn a_one_line_capture_is_appended_to_the_daily_note_and_the_note_is_created() { let (vault, _config, mut session, registry) = setup("capture-daily"); let first = capture(®istry, &mut session, json!({ "text": "buy milk" })).expect("capture"); let path = path_of(&first); assert!(std::path::Path::new(&path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("md")), "{first}"); assert_eq!(first.get("appended"), Some(&json!(true))); let second = capture(®istry, &mut session, json!({ "text": "call Ana", "source": "https://e.x" })).expect("capture"); assert_eq!(path_of(&second), path, "the same daily note"); let content = on_disk(vault.path(), &path); let milk = content.find("buy milk").expect("first item"); let ana = content.find("call Ana").expect("second item"); assert!(milk < ana, "the second capture sits below the first: {content}"); assert!(content.contains("source: https://e.x"), "{content}"); } #[test] fn several_lines_become_a_heading_block() { let (vault, _config, mut session, registry) = setup("capture-block"); let answer = capture(®istry, &mut session, json!({ "text": "one\ntwo", "title": "Idea" })).expect("capture"); let content = on_disk(vault.path(), &path_of(&answer)); assert!(content.contains("### ") && content.contains(" Idea\none\ntwo\n"), "{content}"); } #[test] fn the_inbox_gets_a_new_note_with_frontmatter_and_collisions_are_numbered() { let (vault, _config, mut session, registry) = setup("capture-inbox"); let params = json!({ "text": "an idea", "title": "My Idea!", "target": "inbox", "source": "app" }); let a = path_of(&capture(®istry, &mut session, params.clone()).expect("first")); let b = path_of(&capture(®istry, &mut session, params).expect("second")); assert!(a.starts_with("Inbox/") && a.ends_with("my-idea.md"), "{a}"); assert_ne!(a, b, "a second capture in the same second does not overwrite"); let content = on_disk(vault.path(), &a); assert!(content.starts_with("---\ncreated: ") && content.contains("source: \"app\"") && content.ends_with("an idea\n"), "{content}"); } #[test] fn a_folder_target_works_and_hostile_targets_are_refused() { let (vault, _config, mut session, registry) = setup("capture-folder"); let answer = capture(®istry, &mut session, json!({ "text": "x", "target": "Projects/Atlas" })).expect("capture"); assert!(path_of(&answer).starts_with("Projects/Atlas/"), "{answer}"); assert!(vault.path().join(path_of(&answer)).is_file()); for bad in ["..", "../out", "/abs", "a/../b", ".obsidian", ".obsidian/x", ".notescraft", "a//b"] { assert!(capture(®istry, &mut session, json!({ "text": "x", "target": bad })).is_err(), "target {bad:?} must be refused"); } assert!(!vault.path().join("out").exists()); } #[test] fn an_attachment_is_saved_and_embedded_and_bad_names_are_refused() { let (vault, _config, mut session, registry) = setup("capture-attachment"); let answer = capture(®istry, &mut session, json!({ "text": "look", "target": "inbox", "attachment": { "name": "shot.png", "base64": PNG } })).expect("capture"); let embed = answer.get("embed").and_then(Value::as_str).unwrap_or_default(); assert!(embed.starts_with("![[") && embed.ends_with("]]"), "{answer}"); assert!(on_disk(vault.path(), &path_of(&answer)).contains(embed), "the note embeds the file"); for bad in ["../x.png", "a/b.png", "noext", ".png"] { let refused = capture(®istry, &mut session, json!({ "attachment": { "name": bad, "base64": PNG } })); assert!(refused.is_err(), "{bad:?}: {refused:?}"); } assert!(capture(®istry, &mut session, json!({ "attachment": { "name": "a.png", "base64": "not base64!!" } })).is_err()); } #[test] fn size_limits_and_empty_captures_are_refused() { let (_vault, _config, mut session, registry) = setup("capture-limits"); assert!(capture(®istry, &mut session, json!({})).is_err(), "nothing to capture"); assert!(capture(®istry, &mut session, json!({ "text": " \n" })).is_err(), "blank text"); let huge = capture(®istry, &mut session, json!({ "text": "x".repeat(1024 * 1024 + 1) })).unwrap_err(); assert!(huge.contains("too_large") || huge.contains("1 MiB") || huge.contains("longer"), "{huge}"); assert!(capture(®istry, &mut session, json!({ "text": "x", "title": "t".repeat(201) })).is_err()); assert!(capture(®istry, &mut session, json!({ "text": "x", "source": "s".repeat(2001) })).is_err()); assert!(capture(®istry, &mut session, json!({ "text": 5 })).is_err(), "wrong type"); } #[test] fn a_read_only_session_refuses() { let vault = temp_vault("capture-readonly"); let config = temp_config("capture-readonly-cfg"); let mut session = Session::open_read_only(vault.path()).expect("open read only"); session.use_settings_dir(config.path()); let mut registry = Registry::new(); notescraft_engine::register_core(&mut registry).expect("register"); assert!(capture(®istry, &mut session, json!({ "text": "x" })).is_err()); } #[test] fn a_dirty_open_buffer_receives_the_append_and_undo_works() { let (_vault, _config, mut session, registry) = setup("capture-buffer"); let first = path_of(&capture(®istry, &mut session, json!({ "text": "first" })).expect("capture")); session.open_buffer(&first).expect("open the buffer"); let before = session.buffer(&first).map(|b| b.text().to_owned()).unwrap_or_default(); capture(®istry, &mut session, json!({ "text": "second" })).expect("capture into the open note"); let after = session.buffer(&first).map(|b| b.text().to_owned()).unwrap_or_default(); assert!(after.contains("second") && after.starts_with(&before), "{after}"); assert!(session.buffer(&first).is_some_and(notescraft_engine::Buffer::is_dirty), "the append stays an unsaved edit"); session.buffer_mut(&first).expect("buffer").undo().expect("undo"); assert_eq!(session.buffer(&first).map(|b| b.text().to_owned()).unwrap_or_default(), before, "undo takes the capture back"); } #[test] fn hostile_parameters_never_panic() { let (_vault, _config, mut session, registry) = setup("capture-hostile"); let nul = "\u{0}".repeat(100_000); let hostile = [ json!({ "text": nul }), json!({ "text": "x", "title": nul }), json!({ "text": "x", "target": nul }), json!({ "text": "x", "attachment": 5 }), json!({ "text": "x", "attachment": { "name": nul, "base64": "AAAA" } }), json!({ "text": "x", "utc_offset_minutes": 99_999_999_999_i64 }), json!({ "text": ["a"], "target": { "x": 1 } }), json!([1, 2, 3]), json!(null), ]; for params in hostile { let _ = capture(®istry, &mut session, params); } }