//! The calendar write commands: add, edit, move, delete, notes, bulk and saved filters. mod common; use common::{on_disk, seed, temp_vault}; use notescraft_engine::{Registry, Session, SessionConfig, register_core}; use serde_json::{Value, json}; const SERIES: &str = "---\ntitle: Standup\nstart: 2026-10-19T09:30\nend: 2026-10-19T09:45\nrepeat: 'FREQ=WEEKLY;BYDAY=MO'\ncalendar: Work\nmystery: keep me\n---\nBody text.\n"; const ONCE: &str = "---\ntitle: Lunch\nstart: 2026-10-20T12:00\nend: 2026-10-20T13:00\n---\n"; struct World { registry: Registry, session: Session, vault: common::TempVault, } impl World { fn new(name: &str) -> Self { let vault = temp_vault(name); seed(vault.path(), &[("Calendar/standup.md", SERIES), ("Calendar/lunch.md", ONCE)]); let mut registry = Registry::new(); register_core(&mut registry).expect("register"); let session = Session::open(vault.path()).expect("open"); Self { registry, session, vault } } fn run(&mut self, id: &str, params: &Value) -> Value { self.registry.run(id, &mut self.session, params).unwrap_or_else(|error| panic!("{id}: {error:?}")) } fn fails(&mut self, id: &str, params: &Value) -> String { match self.registry.run(id, &mut self.session, params) { Err(error) => format!("{error:?}"), Ok(value) => panic!("{id} should fail, got {value}"), } } fn file(&self, path: &str) -> String { on_disk(self.vault.path(), path) } /// The `start` of every agenda item of the window, oldest first. fn starts(&mut self, from: &str, to: &str) -> Vec { let out = self.run("calendar.agenda", &json!({ "from": from, "to": to })); out["groups"] .as_array() .unwrap() .iter() .flat_map(|g| g["items"].as_array().unwrap().iter().map(|i| format!("{} {}", i["start"].as_str().unwrap(), i["title"].as_str().unwrap()))) .collect() } } #[test] fn add_from_text_and_from_fields() { let mut world = World::new("cal-add"); let out = world.run("calendar.add", &json!({ "text": "Dentist 2026-11-03 at 15:00 for 1h #health" })); let path = out["path"].as_str().unwrap().to_owned(); assert_eq!(path, "Calendar/2026-11-03-dentist.md"); assert!(world.file(&path).contains("start: 2026-11-03T15:00") && world.file(&path).contains("end: 2026-11-03T16:00")); let again = world.run("calendar.add", &json!({ "text": "Dentist 2026-11-03 at 15:00" })); assert_eq!(again["path"], "Calendar/2026-11-03-dentist-2.md", "a taken name is numbered"); let fields = world.run("calendar.add", &json!({ "event": { "title": "Trip", "start": "2026-12-01", "end": "2026-12-03", "tags": ["fun"] } })); assert!(world.file(fields["path"].as_str().unwrap()).contains("end: 2026-12-03")); for bad in [ json!({}), json!({ "text": "x", "event": { "title": "y", "start": "2026-01-01" } }), json!({ "text": "Lunch at 25:00" }), json!({ "event": { "title": "x" } }), json!({ "event": { "title": "x", "start": "2026-01-01", "colour": "red" } }), json!({ "event": { "title": "x", "start": "2026-01-01", "repeat": "sometimes" } }), ] { world.fails("calendar.add", &bad); } assert_eq!(std::fs::read_dir(world.vault.path().join("Calendar")).unwrap().count(), 5, "failed adds write nothing"); } #[test] fn editing_a_plain_event_keeps_unknown_keys_and_the_body() { let mut world = World::new("cal-edit-plain"); world.run("calendar.edit", &json!({ "id": "Calendar/lunch.md", "fields": { "title": "Late lunch", "priority": "high", "end": null } })); let text = world.file("Calendar/lunch.md"); assert!(text.contains("title: Late lunch") && text.contains("priority: high") && !text.contains("end:"), "{text}"); world.run("calendar.move", &json!({ "id": "Calendar/lunch.md", "start": "2026-10-22T14:00" })); assert!(world.file("Calendar/lunch.md").contains("start: 2026-10-22T14:00")); world.fails("calendar.edit", &json!({ "id": "Calendar/lunch.md", "fields": { "colour": "red" } })); world.fails("calendar.edit", &json!({ "id": "Calendar/lunch.md", "fields": { "start": "nope" } })); world.fails("calendar.edit", &json!({ "id": "Notes/outside.md", "fields": {} })); world.fails("calendar.edit", &json!({ "id": "Calendar/missing.md", "fields": {} })); } #[test] fn moving_keeps_the_length() { let mut world = World::new("cal-move"); world.run("calendar.move", &json!({ "id": "Calendar/lunch.md", "start": "2026-10-21T10:00" })); assert!(world.file("Calendar/lunch.md").contains("end: 2026-10-21T11:00")); } #[test] fn a_repeating_event_needs_a_scope() { let mut world = World::new("cal-scope"); for (command, params) in [ ("calendar.edit", json!({ "id": "Calendar/standup.md", "fields": { "title": "x" } })), ("calendar.move", json!({ "id": "Calendar/standup.md", "start": "2026-10-20" })), ("calendar.delete", json!({ "id": "Calendar/standup.md" })), ] { let error = world.fails(command, ¶ms); assert!(error.contains("this, following or all"), "{command}: {error}"); } let error = world.fails("calendar.edit", &json!({ "id": "Calendar/standup.md", "fields": {}, "scope": "sometimes" })); assert!(error.contains("this, following, all"), "{error}"); world.fails("calendar.edit", &json!({ "id": "Calendar/standup.md", "fields": {}, "scope": "this" })); world.fails("calendar.edit", &json!({ "id": "Calendar/standup.md", "fields": {}, "scope": "this", "occurrence": "2026-10-20" })); assert!(world.file("Calendar/standup.md").contains("mystery: keep me"), "refused calls change nothing"); } #[test] fn editing_this_following_or_all() { let mut world = World::new("cal-series"); let window = ("2026-10-19", "2026-11-09"); assert_eq!(world.starts(window.0, window.1).len(), 5, "lunch + four Mondays"); let this = world.run("calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "this", "occurrence": "2026-10-26", "fields": { "title": "Standup (long)", "start": "2026-10-26T10:00" } })); assert_eq!(this["created"].as_array().unwrap().len(), 1); assert_eq!( world.starts(window.0, window.1), ["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch", "2026-10-26T10:00 Standup (long)", "2026-11-02T09:30 Standup", "2026-11-09T09:30 Standup"] ); world.run("calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "following", "occurrence": "2026-11-02", "fields": { "title": "Sync" } })); assert_eq!( world.starts(window.0, window.1), ["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch", "2026-10-26T10:00 Standup (long)", "2026-11-02T09:30 Sync", "2026-11-09T09:30 Sync"] ); let head = world.file("Calendar/standup.md"); assert!(head.contains("UNTIL=2026-11-01") && head.contains("mystery: keep me") && head.ends_with("Body text.\n"), "{head}"); world.run("calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "all", "fields": { "calendar": "Team" } })); assert!(world.file("Calendar/standup.md").contains("calendar: Team")); } #[test] fn following_at_the_first_occurrence_is_all() { let mut world = World::new("cal-first"); let out = world.run("calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "following", "occurrence": "2026-10-19", "fields": { "title": "Renamed" } })); assert_eq!(out["created"], json!([])); assert!(world.file("Calendar/standup.md").contains("title: Renamed")); } #[test] fn deleting_this_following_or_all() { let mut world = World::new("cal-delete"); world.run("calendar.delete", &json!({ "id": "Calendar/standup.md", "scope": "this", "occurrence": "2026-10-26" })); assert_eq!( world.starts("2026-10-19", "2026-11-09"), ["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch", "2026-11-02T09:30 Standup", "2026-11-09T09:30 Standup"] ); world.run("calendar.delete", &json!({ "id": "Calendar/standup.md", "scope": "following", "occurrence": "2026-11-02" })); assert_eq!(world.starts("2026-10-19", "2026-11-30"), ["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch"]); world.run("calendar.delete", &json!({ "id": "Calendar/standup.md", "scope": "following", "occurrence": "2026-10-19" })); assert!(!world.vault.path().join("Calendar/standup.md").exists(), "following from the first one deletes the series"); world.run("calendar.delete", &json!({ "id": "Calendar/lunch.md" })); assert!(!world.vault.path().join("Calendar/lunch.md").exists()); } #[test] fn a_meeting_note_is_linked_both_ways_and_made_once() { let mut world = World::new("cal-note"); let out = world.run("calendar.note", &json!({ "id": "Calendar/lunch.md" })); let path = out["path"].as_str().unwrap().to_owned(); assert_eq!((out["created"].clone(), path.as_str()), (json!(true), "Meetings/2026-10-20 Lunch.md")); assert!(world.file(&path).contains("[[Calendar/lunch]]")); assert!(world.file("Calendar/lunch.md").contains("note: Meetings/2026-10-20 Lunch.md")); let backlinks = world.run("links.backlinks", &json!({ "path": "Calendar/lunch.md" })); assert!(backlinks.to_string().contains("Meetings/2026-10-20 Lunch.md"), "{backlinks}"); let again = world.run("calendar.note", &json!({ "id": "Calendar/lunch.md" })); assert_eq!((again["created"].clone(), again["path"].as_str()), (json!(false), Some(path.as_str()))); } #[test] fn bulk_counts_first_and_applies_on_confirm() { let mut world = World::new("cal-bulk"); let ids = json!(["Calendar/lunch.md", "Calendar/standup.md", "Calendar/lunch.md"]); let preview = world.run("calendar.bulk", &json!({ "ids": ids, "action": "move", "days": 2 })); assert_eq!((preview["applied"].clone(), preview["affected"].clone()), (json!(false), json!(2))); assert!(world.file("Calendar/lunch.md").contains("start: 2026-10-20T12:00"), "no write without confirm"); world.run("calendar.bulk", &json!({ "ids": ids, "action": "move", "days": 2, "confirm": true })); assert!(world.file("Calendar/lunch.md").contains("start: 2026-10-22T12:00") && world.file("Calendar/standup.md").contains("start: 2026-10-21T09:30")); world.run("calendar.bulk", &json!({ "ids": ids, "action": "tag", "tag": "x", "confirm": true })); assert!(world.file("Calendar/lunch.md").contains("tags: [x]")); world.run("calendar.bulk", &json!({ "ids": ids, "action": "untag", "tag": "x", "confirm": true })); assert!(!world.file("Calendar/lunch.md").contains("tags:")); world.run("calendar.bulk", &json!({ "ids": ids, "action": "calendar", "calendar": "Home", "confirm": true })); assert!(world.file("Calendar/lunch.md").contains("calendar: Home")); for bad in [ json!({ "ids": [], "action": "tag", "tag": "x" }), json!({ "ids": ids, "action": "explode" }), json!({ "ids": ids, "action": "move" }), json!({ "ids": ["Calendar/lunch.md", "Calendar/nope.md"], "action": "tag", "tag": "x", "confirm": true }), ] { world.fails("calendar.bulk", &bad); } assert!(!world.file("Calendar/lunch.md").contains("tags:"), "a bad id in the list changes nothing"); let gone = world.run("calendar.bulk_delete", &json!({ "ids": ids })); assert_eq!(gone["applied"], false); assert!(world.vault.path().join("Calendar/lunch.md").exists()); world.run("calendar.bulk_delete", &json!({ "ids": ids, "confirm": true })); assert!(!world.vault.path().join("Calendar/lunch.md").exists() && !world.vault.path().join("Calendar/standup.md").exists()); } #[test] fn saved_filters_survive_a_restart() { let mut world = World::new("cal-filters"); world.run("calendar.filter_save", &json!({ "name": "Work", "query": { "filter": { "calendars": ["Work"] }, "sort": "priority", "group": "tag" } })); world.run("calendar.filter_save", &json!({ "name": "Work", "query": { "sort": "title" } })); world.run("calendar.filter_save", &json!({ "name": "Home", "query": {} })); let World { registry, session, vault } = world; drop(session); let mut session = Session::open(vault.path()).expect("reopen"); let listed = registry.run("calendar.filters", &mut session, &json!({})).expect("filters"); let names: Vec<&str> = listed["filters"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Work", "Home"]); assert_eq!(listed["filters"][0]["query"]["sort"], "title", "the same name replaces"); } #[test] fn bad_filters_are_refused_and_deleting_works() { let mut world = World::new("cal-filters-bad"); world.run("calendar.filter_save", &json!({ "name": "Home", "query": {} })); world.fails("calendar.filter_save", &json!({ "name": " ", "query": {} })); world.fails("calendar.filter_save", &json!({ "name": "x", "query": { "sort": "colour" } })); world.run("calendar.filter_delete", &json!({ "name": "Home" })); world.fails("calendar.filter_delete", &json!({ "name": "Home" })); assert_eq!(world.run("calendar.filters", &json!({}))["filters"].as_array().unwrap().len(), 0); } #[test] fn a_read_only_session_refuses_every_write() { let vault = temp_vault("cal-readonly"); seed(vault.path(), &[("Calendar/lunch.md", ONCE)]); let mut registry = Registry::new(); register_core(&mut registry).expect("register"); let mut session = Session::open_read_only(vault.path()).expect("open"); for (id, params) in [ ("calendar.add", json!({ "text": "x" })), ("calendar.edit", json!({ "id": "Calendar/lunch.md", "fields": {} })), ("calendar.move", json!({ "id": "Calendar/lunch.md", "start": "2026-10-21" })), ("calendar.delete", json!({ "id": "Calendar/lunch.md" })), ("calendar.note", json!({ "id": "Calendar/lunch.md" })), ("calendar.bulk", json!({ "ids": ["Calendar/lunch.md"], "action": "tag", "tag": "x", "confirm": true })), ("calendar.bulk_delete", json!({ "ids": ["Calendar/lunch.md"], "confirm": true })), ("calendar.filter_save", json!({ "name": "x", "query": {} })), ("calendar.filter_delete", json!({ "name": "x" })), ] { assert!(registry.run(id, &mut session, ¶ms).is_err(), "{id}"); } assert_eq!(on_disk(vault.path(), "Calendar/lunch.md"), ONCE); let _ = SessionConfig::default(); } #[test] fn the_assistant_gate_queues_calendar_writes_and_honours_the_toggle() { use notescraft_engine::{AssistantGate, AssistantSettings, Decision, GateSource}; let mut world = World::new("cal-gate"); let allow = |write: bool, delete: bool, calendar: Option| { let mut settings = AssistantSettings { allow_write: write, allow_delete: delete, review_writes: true, ..AssistantSettings::default() }; if let Some(on) = calendar { settings.plugin_tools.insert("calendar".to_owned(), on); } settings }; let check = |world: &World, id: &str, params: &Value| AssistantGate::check(GateSource::Assistant, &world.session, id, params); let add = json!({ "text": "Lunch tomorrow" }); assert!(matches!(check(&world, "calendar.agenda", &json!({})), Decision::Allow), "reads are never gated"); assert!(matches!(check(&world, "calendar.add", &add), Decision::Deny(_)), "writes are off by default"); world.session.set_assistant_settings(allow(true, false, None)); assert!(matches!(check(&world, "calendar.add", &add), Decision::Queue(_)), "a permitted write waits for review"); assert!(matches!(check(&world, "calendar.delete", &json!({ "id": "Calendar/lunch.md" })), Decision::Deny(_)), "deletes need their own flag"); world.session.set_assistant_settings(allow(true, true, None)); assert!(matches!(check(&world, "calendar.bulk_delete", &json!({ "ids": [] })), Decision::Queue(_))); world.session.set_assistant_settings(allow(true, true, Some(false))); assert!(matches!(check(&world, "calendar.agenda", &json!({})), Decision::Deny(_)), "the toggle hides reads too"); assert!(matches!(check(&world, "calendar.add", &add), Decision::Deny(_))); assert!(matches!(AssistantGate::check(GateSource::Cli, &world.session, "calendar.add", &add), Decision::Allow), "the user is never gated"); assert!(world.vault.path().join("Calendar/lunch.md").exists()); } #[test] fn switching_the_calendar_plugin_off_is_saved_and_closes_the_assistant_gate() { use notescraft_engine::{AssistantGate, AssistantSettings, Decision, GateSource}; let mut world = World::new("cal-core-off"); world.session.use_settings_dir(world.vault.path().join(".notescraft/test-config")); assert!(world.session.core_plugin_enabled("calendar")); world.session.set_assistant_settings(AssistantSettings { allow_write: true, ..AssistantSettings::default() }); assert!(matches!(AssistantGate::check(GateSource::Assistant, &world.session, "calendar.agenda", &json!({})), Decision::Allow)); world.run("settings.set", &json!({ "key": "plugins.core_disabled", "value": ["calendar"] })); assert!(!world.session.core_plugin_enabled("calendar"), "the setting reaches the session"); assert!( matches!(AssistantGate::check(GateSource::Assistant, &world.session, "calendar.agenda", &json!({})), Decision::Deny(_)), "an off plugin has no assistant tools" ); let saved = world.run("settings.get", &json!({})); assert_eq!(saved["settings"]["plugins"]["core_disabled"], json!(["calendar"]), "and it is stored"); world.run("settings.set", &json!({ "key": "plugins.core_disabled", "value": [] })); assert!(world.session.core_plugin_enabled("calendar")); } #[test] fn calendar_switches_hide_tasks_and_done_tasks() { let vault = temp_vault("cal-switches"); seed(vault.path(), &[("Notes/plan.md", "- [ ] Open \u{1F4C5} 2026-10-21\n- [x] Done \u{1F4C5} 2026-10-22\n")]); let mut registry = Registry::new(); register_core(&mut registry).expect("register"); let mut session = Session::open(vault.path()).expect("open"); session.use_settings_dir(vault.path().join(".notescraft/test-config")); let count = |registry: &Registry, session: &mut Session| { registry.run("calendar.agenda", session, &json!({ "from": "2026-10-19", "to": "2026-10-27" })).unwrap()["count"].as_u64().unwrap() }; assert_eq!(count(®istry, &mut session), 2); registry.run("settings.set", &mut session, &json!({ "key": "session.calendar.show_completed", "value": false })).unwrap(); assert_eq!(count(®istry, &mut session), 1, "done tasks are hidden"); registry.run("settings.set", &mut session, &json!({ "key": "session.calendar.tasks", "value": false })).unwrap(); assert_eq!(count(®istry, &mut session), 0, "no tasks at all"); registry.run("settings.set", &mut session, &json!({ "key": "session.week_start", "value": "monday" })).unwrap(); assert_eq!(session.config().week_start, notescraft_templates::Weekday::Monday); }