use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use indexmap::IndexMap;
use mps::{elements::*, parser, ref_resolver::RefResolver, store::Store};
fn fixtures_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures")
}
fn fixture_path(name: &str) -> PathBuf {
fixtures_dir().join(name)
}
fn parse_fixture(name: &str) -> IndexMap<String, Element> {
parser::parse_file(&fixture_path(name)).expect("fixture parse failed")
}
fn non_root(elements: &IndexMap<String, Element>) -> Vec<&Element> {
elements.values()
.filter(|e| !e.is_mps_group() && !e.is_unknown())
.collect()
}
fn fixture_store() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().unwrap();
for entry in std::fs::read_dir(fixtures_dir()).unwrap() {
let entry = entry.unwrap();
let dst = dir.path().join(entry.file_name());
std::fs::copy(entry.path(), dst).unwrap();
}
let store = Store::new(dir.path());
(dir, store)
}
const SPRINT_FILE: &str = "20260201.1000000010.mps";
const RUST_FILE: &str = "20260215.1000000020.mps";
const RELEASE_FILE: &str = "20260220.1000000030.mps";
#[test]
fn test_bare_file_has_task_note_reminder_log() {
let els = parse_fixture("20260101.1000000001.mps");
let leafs = non_root(&els);
assert!(leafs.iter().any(|e| e.kind() == ElementKind::Task));
assert!(leafs.iter().any(|e| e.kind() == ElementKind::Note));
assert!(leafs.iter().any(|e| e.kind() == ElementKind::Reminder));
assert!(leafs.iter().any(|e| e.kind() == ElementKind::Log));
}
#[test]
fn test_bare_file_task_is_open() {
let els = parse_fixture("20260101.1000000001.mps");
let task = els.values().find(|e| e.kind() == ElementKind::Task).unwrap();
if let Element::Task { data, .. } = task {
assert!(data.is_open());
} else {
panic!("not a task");
}
}
#[test]
fn test_tagged_file_task_has_tags() {
let els = parse_fixture("20260102.1000000002.mps");
let has_tags = els.values()
.filter(|e| e.kind() == ElementKind::Task)
.any(|e| e.tags().contains(&"x".to_string()) && e.tags().contains(&"y".to_string()));
assert!(has_tags, "expected a task with tags x and y");
}
#[test]
fn test_tagged_file_has_nested_task() {
let els = parse_fixture("20260102.1000000002.mps");
let task_count = els.values().filter(|e| e.kind() == ElementKind::Task).count();
assert!(task_count >= 2, "should have at least a top-level and nested task");
}
#[test]
fn test_tagged_file_reminder_at_5pm() {
let els = parse_fixture("20260102.1000000002.mps");
let reminder = els.values().find(|e| e.kind() == ElementKind::Reminder).unwrap();
if let Element::Reminder { data, .. } = reminder {
assert_eq!(data.at.as_deref(), Some("5pm"));
} else {
panic!("not a reminder");
}
}
#[test]
fn test_tagged_file_mps_group_has_task_and_note() {
let els = parse_fixture("20260102.1000000002.mps");
let nested_task = els.values()
.any(|e| e.kind() == ElementKind::Task && e.body_str().contains("nested task"));
let nested_note = els.values()
.any(|e| e.kind() == ElementKind::Note && e.body_str().contains("Nested note"));
assert!(nested_task, "nested task inside @mps expected");
assert!(nested_note, "nested note inside @mps expected");
}
#[test]
fn test_sprint_tasks_count() {
let els = parse_fixture(SPRINT_FILE);
let tasks: Vec<&Element> = els.values().filter(|e| e.kind() == ElementKind::Task).collect();
assert_eq!(tasks.len(), 4);
}
#[test]
fn test_sprint_open_tasks() {
let els = parse_fixture(SPRINT_FILE);
let open: Vec<&Element> = els.values()
.filter(|e| matches!(e, Element::Task { data, .. } if data.is_open()))
.collect();
assert_eq!(open.len(), 3);
}
#[test]
fn test_sprint_done_task_body() {
let els = parse_fixture(SPRINT_FILE);
let done: Vec<&Element> = els.values()
.filter(|e| matches!(e, Element::Task { data, .. } if data.is_done()))
.collect();
assert_eq!(done.len(), 1);
assert!(done[0].body_str().contains("Set up the PostgreSQL schema"));
}
#[test]
fn test_sprint_log_duration_210_minutes() {
let els = parse_fixture(SPRINT_FILE);
let log = els.values().find(|e| e.kind() == ElementKind::Log).unwrap();
if let Element::Log { data, .. } = log {
assert_eq!(data.duration_minutes(), Some(210)); assert_eq!(data.duration_str(), Some("3h30m".into()));
} else {
panic!("not a log");
}
}
#[test]
fn test_sprint_reminder_at_3pm() {
let els = parse_fixture(SPRINT_FILE);
let rem = els.values().find(|e| e.kind() == ElementKind::Reminder).unwrap();
if let Element::Reminder { data, .. } = rem {
assert_eq!(data.at.as_deref(), Some("3pm"));
} else {
panic!("not a reminder");
}
}
#[test]
fn test_sprint_nested_tagged_tasks() {
let els = parse_fixture(SPRINT_FILE);
let backend_or_frontend = els.values()
.filter(|e| e.kind() == ElementKind::Task)
.filter(|e| e.tags().iter().any(|t| t == "backend" || t == "frontend"))
.count();
assert!(backend_or_frontend >= 3, "expected ≥ 3 backend/frontend tasks");
}
#[test]
fn test_sprint_ref_resolver_top_level() {
let els = parse_fixture(SPRINT_FILE);
let r = RefResolver::new(&els);
let mps_refs: Vec<_> = els.keys()
.filter(|k| {
let e = &els[*k];
e.is_mps_group() && k.split('.').count() == 2
})
.collect();
for epoch_ref in mps_refs {
let human = r.to_human(epoch_ref).expect("mps group should be mapped");
assert!(human.starts_with("mps-"));
}
}
#[test]
fn test_sprint_ref_resolver_nested_refs() {
let els = parse_fixture(SPRINT_FILE);
let r = RefResolver::new(&els);
for (epoch_ref, el) in &els {
if el.kind() == ElementKind::Task && epoch_ref.split('.').count() == 3 {
let human = r.to_human(epoch_ref);
assert!(human.is_some(), "nested task {} should have a human ref", epoch_ref);
let h = human.unwrap();
assert!(h.contains('.'), "nested human ref should contain a dot: {}", h);
}
}
}
#[test]
fn test_rust_done_reading_tasks() {
let els = parse_fixture(RUST_FILE);
let done_reading: Vec<_> = els.values()
.filter(|e| matches!(e, Element::Task { data, .. } if data.is_done())
&& e.tags().contains(&"reading".to_string()))
.collect();
assert_eq!(done_reading.len(), 2);
}
#[test]
fn test_rust_open_reading_task_body_contains_rustlings() {
let els = parse_fixture(RUST_FILE);
let open_reading = els.values().find(|e| {
matches!(e, Element::Task { data, .. } if data.is_open())
&& e.tags().contains(&"reading".to_string())
}).unwrap();
assert!(open_reading.body_str().contains("Rustlings"));
}
#[test]
fn test_rust_habit_tasks() {
let els = parse_fixture(RUST_FILE);
let habit_tasks: Vec<_> = els.values()
.filter(|e| e.kind() == ElementKind::Task && e.tags().contains(&"habits".to_string()))
.collect();
assert_eq!(habit_tasks.len(), 3);
let done_habits = habit_tasks.iter()
.filter(|e| matches!(e, Element::Task { data, .. } if data.is_done()))
.count();
assert_eq!(done_habits, 2);
}
#[test]
fn test_rust_log_duration_90_minutes() {
let els = parse_fixture(RUST_FILE);
let log = els.values().find(|e| e.kind() == ElementKind::Log).unwrap();
if let Element::Log { data, .. } = log {
assert_eq!(data.duration_minutes(), Some(90)); assert_eq!(data.duration_str(), Some("1h30m".into()));
}
}
#[test]
fn test_rust_all_tasks_have_human_refs() {
let els = parse_fixture(RUST_FILE);
let r = RefResolver::new(&els);
for (epoch_ref, el) in &els {
if el.kind() == ElementKind::Task {
let human = r.to_human(epoch_ref);
assert!(human.is_some(), "task {} should have a human ref", epoch_ref);
}
}
}
#[test]
fn test_release_done_tasks() {
let els = parse_fixture(RELEASE_FILE);
let done: Vec<_> = els.values()
.filter(|e| matches!(e, Element::Task { data, .. } if data.is_done()))
.collect();
assert_eq!(done.len(), 2);
}
#[test]
fn test_release_total_log_time_360_minutes() {
let els = parse_fixture(RELEASE_FILE);
let total: i64 = els.values()
.filter_map(|e| if let Element::Log { data, .. } = e { data.duration_minutes() } else { None })
.sum();
assert_eq!(total, 360); }
#[test]
fn test_release_nested_notes_in_retrospective() {
let els = parse_fixture(RELEASE_FILE);
let notes_count = els.values().filter(|e| e.kind() == ElementKind::Note).count();
assert!(notes_count >= 3, "expected ≥ 3 notes in release day");
}
#[test]
fn test_release_has_open_v11_task() {
let els = parse_fixture(RELEASE_FILE);
let v11_task = els.values().find(|e| {
matches!(e, Element::Task { data, .. } if data.is_open())
&& e.body_str().contains("v1.1")
});
assert!(v11_task.is_some(), "expected an open task mentioning v1.1");
}
#[test]
fn test_release_tags_include_work_and_release() {
let els = parse_fixture(RELEASE_FILE);
let all_tags: Vec<String> = els.values()
.filter(|e| !e.is_mps_group() && !e.is_unknown())
.flat_map(|e| e.tags().to_vec())
.collect();
assert!(all_tags.contains(&"work".to_string()));
assert!(all_tags.contains(&"release".to_string()));
}
#[test]
fn test_ref_resolver_roundtrip_on_all_fixtures() {
for fixture in &[SPRINT_FILE, RUST_FILE, RELEASE_FILE] {
let els = parse_fixture(fixture);
let r = RefResolver::new(&els);
for epoch_ref in els.keys() {
if let Some(human) = r.to_human(epoch_ref) {
let back = r.to_epoch(human).unwrap_or("none");
assert_eq!(back, epoch_ref.as_str(), "roundtrip failed for {}", epoch_ref);
}
}
}
}
#[test]
fn test_ref_resolver_skips_unknown_elements() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("20260101.1000000001.mps");
std::fs::write(&path, "@custom_type{ body }\n@task{ real task }").unwrap();
let els = parser::parse_file(&path).unwrap();
let r = RefResolver::new(&els);
assert!(r.to_human("20260101.1").is_none());
assert_eq!(r.to_human("20260101.2"), Some("task-1"));
}
#[test]
fn test_store_all_files_sorted() {
let (_dir, store) = fixture_store();
let files = store.all_files().unwrap();
let names: Vec<String> = files.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "files should be sorted chronologically");
}
#[test]
fn test_store_tag_counts_across_fixtures() {
let (_dir, store) = fixture_store();
let mut all_tags: HashMap<String, usize> = HashMap::new();
for file in store.all_files().unwrap() {
let date_str = file.file_name().unwrap().to_str().unwrap()[..8].to_string();
let d = NaiveDate::parse_from_str(&date_str, "%Y%m%d").unwrap();
let els = store.parse_date(d).unwrap();
for el in els.values().filter(|e| !e.is_mps_group() && !e.is_unknown()) {
for tag in el.tags() {
*all_tags.entry(tag.clone()).or_insert(0) += 1;
}
}
}
assert!(all_tags.get("work").copied().unwrap_or(0) >= 3, "work tag appears across multiple files");
assert!(all_tags.get("reading").copied().unwrap_or(0) >= 3, "reading tag from rust day");
assert!(all_tags.get("release").copied().unwrap_or(0) >= 2, "release tag from release day");
}
#[test]
fn test_store_search_authentication_across_fixtures() {
let (_dir, store) = fixture_store();
let results = store.search("authentication", None, None, None).unwrap();
assert!(!results.is_empty(), "should find 'authentication' in some fixture");
assert!(results.iter().all(|r| !r.element.is_unknown()));
}
#[test]
fn test_store_search_type_filter_log() {
let (_dir, store) = fixture_store();
let results = store.search("", None, Some("log"), None).unwrap();
assert!(results.iter().all(|r| r.element.kind() == ElementKind::Log));
}
#[test]
fn test_store_search_excludes_mps_and_unknown() {
let (_dir, store) = fixture_store();
let results = store.search("", None, None, None).unwrap();
for r in &results {
assert!(!r.element.is_mps_group(), "search should not return @mps groups");
assert!(!r.element.is_unknown(), "search should not return Unknown elements");
}
}
#[test]
fn test_store_all_file_dates_unique_and_sorted() {
let (_dir, store) = fixture_store();
let dates = store.all_file_dates().unwrap();
let mut sorted = dates.clone();
sorted.sort();
sorted.dedup();
assert_eq!(dates, sorted, "dates should be unique and sorted");
}
#[test]
fn test_store_files_since_sprint_date() {
let (_dir, store) = fixture_store();
let since = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
let files = store.files_since(since).unwrap();
for f in &files {
let date_str = &f.file_name().unwrap().to_str().unwrap()[..8];
assert!(date_str >= "20260201", "file {} is before since date", date_str);
}
let jan_files: Vec<_> = files.iter().filter(|f| {
f.file_name().unwrap().to_str().unwrap().starts_with("202601")
}).collect();
assert!(jan_files.is_empty(), "January fixtures should be excluded");
}
#[test]
fn test_rewrite_element_marks_task_done() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("20260101.1000000001.mps");
std::fs::write(&path, "@task[work]{\n Ship it\n}\n").unwrap();
let store = Store::new(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut attrs = HashMap::new();
attrs.insert("status".to_string(), "done".to_string());
let ok = store.rewrite_element("task-1", &attrs, date).unwrap();
assert!(ok, "rewrite should succeed");
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("status: done"), "file should now contain status: done");
assert!(content.contains("work"), "tag 'work' should be preserved");
}
#[test]
fn test_rewrite_element_by_epoch_ref() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("20260101.1000000001.mps");
std::fs::write(&path, "@task[work]{\n Ship it\n}\n").unwrap();
let store = Store::new(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let els = store.parse_date(date).unwrap();
let epoch_ref = els.keys().find(|k| {
let e = &els[*k];
e.kind() == ElementKind::Task
}).unwrap().clone();
let mut attrs = HashMap::new();
attrs.insert("status".to_string(), "done".to_string());
let ok = store.rewrite_element(&epoch_ref, &attrs, date).unwrap();
assert!(ok);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("status: done"));
}
#[test]
fn test_rewrite_element_nonexistent_ref_returns_false() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("20260101.1000000001.mps");
std::fs::write(&path, "@task{\n Only task\n}\n").unwrap();
let store = Store::new(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut attrs = HashMap::new();
attrs.insert("status".to_string(), "done".to_string());
let ok = store.rewrite_element("task-99", &attrs, date).unwrap();
assert!(!ok, "should return false for missing ref");
}
#[test]
fn test_rewrite_element_preserves_file_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("20260101.1000000001.mps");
let original = "@task[work]{\n Task one\n}\n\n@note{\n A note\n}\n";
std::fs::write(&path, original).unwrap();
let store = Store::new(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut attrs = HashMap::new();
attrs.insert("status".to_string(), "done".to_string());
store.rewrite_element("task-1", &attrs, date).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("A note"), "note body should be preserved");
assert!(content.contains("status: done"));
}
#[test]
fn test_rewrite_element_log_start_end() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("20260101.1000000001.mps");
std::fs::write(&path, "@log[work]{\n Deep work session\n}\n").unwrap();
let store = Store::new(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut attrs = HashMap::new();
attrs.insert("start".to_string(), "09:00".to_string());
attrs.insert("end".to_string(), "12:00".to_string());
let ok = store.rewrite_element("log-1", &attrs, date).unwrap();
assert!(ok);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("start: 09:00"));
assert!(content.contains("end: 12:00"));
assert!(content.contains("work"), "tag preserved");
}
#[test]
fn test_store_epoch_less_filename_included_in_all_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("20260101.1000000001.mps"), "@task{ A }").unwrap();
std::fs::write(dir.path().join("20260102.mps"), "@note{ B }").unwrap();
let store = Store::new(dir.path());
let files = store.all_files().unwrap();
let names: Vec<String> = files.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert!(names.contains(&"20260101.1000000001.mps".to_string()));
assert!(names.contains(&"20260102.mps".to_string()));
}
#[test]
fn test_store_epoch_less_filename_found_by_find_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("20260101.mps"), "@task{ A }").unwrap();
let store = Store::new(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
assert!(store.find_file(date).is_some());
}
#[test]
fn test_store_non_date_filenames_ignored() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("20260101.1000000001.mps"), "@task{ A }").unwrap();
std::fs::write(dir.path().join("weird_name.mps"), "@task{ B }").unwrap();
let store = Store::new(dir.path());
let files = store.all_files().unwrap();
let names: Vec<String> = files.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert!(names.contains(&"20260101.1000000001.mps".to_string()));
assert!(!names.contains(&"weird_name.mps".to_string()));
}
#[test]
fn test_search_empty_query_returns_all_non_mps() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("20260101.1000000001.mps"),
"@task{ do something }\n@note{ remember this }\n",
).unwrap();
let store = Store::new(dir.path());
let results = store.search("", None, None, None).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_parser_unknown_element_in_search() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("20260101.1000000001.mps"),
"@custom_widget{ some body }\n@task{ real task }",
).unwrap();
let store = Store::new(dir.path());
let results = store.search("", None, None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].element.kind(), ElementKind::Task);
}
#[test]
fn test_date_parse_all_formats() {
use mps::date_parse::parse_date;
use chrono::Local;
let today = Local::now().date_naive();
assert_eq!(parse_date("today").unwrap(), today);
assert_eq!(parse_date("yesterday").unwrap(), today - chrono::Duration::days(1));
assert_eq!(parse_date("last week").unwrap(), today - chrono::Duration::days(7));
assert_eq!(parse_date("3 days ago").unwrap(), today - chrono::Duration::days(3));
assert_eq!(parse_date("20260101").unwrap(), NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert_eq!(parse_date("2026-01-01").unwrap(), NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
assert!(parse_date("not a date").is_err());
}
#[test]
fn test_ref_resolver_three_levels_deep() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("20260101.1000000001.mps");
std::fs::write(&path, "@mps{\n @mps{\n @task{ Deep }\n }\n}").unwrap();
let els = parser::parse_file(&path).unwrap();
let r = RefResolver::new(&els);
assert_eq!(r.to_human("20260101.1"), Some("mps-1"));
assert_eq!(r.to_human("20260101.1.1"), Some("mps-1.1"));
assert_eq!(r.to_human("20260101.1.1.1"), Some("mps-1.1.1"));
}
#[test]
fn test_config_loads_default_command_and_aliases() {
use mps::config::Config;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mps_config.yaml");
std::fs::write(&path, r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
git_remote: origin
git_branch: master
default_command: list
aliases:
t: task
n: note
"#).unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.default_command, "list");
assert_eq!(cfg.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
assert_eq!(cfg.type_aliases.get("n").map(|s| s.as_str()), Some("note"));
}
#[test]
fn test_config_defaults_when_fields_absent() {
use mps::config::Config;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mps_config.yaml");
std::fs::write(&path, r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
"#).unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.default_command, "open");
assert!(cfg.type_aliases.is_empty());
assert_eq!(cfg.git_remote, "origin");
assert_eq!(cfg.git_branch, "master");
}
#[test]
fn test_config_loads_ruby_symbol_keys() {
use mps::config::Config;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mps_config.yaml");
std::fs::write(&path, r#":mps_dir: /tmp/mps-test
:storage_dir: /tmp/mps-test/mps
:log_file: /tmp/mps-test/mps.log
:git_remote: origin
:git_branch: master
:default_command: list
"#).unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.default_command, "list");
assert_eq!(cfg.git_remote, "origin");
}
#[test]
fn test_config_loads_type_aliases_new_key() {
use mps::config::Config;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mps_config.yaml");
std::fs::write(&path, r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
type_aliases:
t: task
c: character
"#).unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
assert_eq!(cfg.type_aliases.get("c").map(|s| s.as_str()), Some("character"));
}
#[test]
fn test_config_loads_command_aliases() {
use mps::config::Config;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mps_config.yaml");
std::fs::write(&path, r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
command_aliases:
a: append
"+": append
l: list
"#).unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.command_aliases.get("a").map(|s| s.as_str()), Some("append"));
assert_eq!(cfg.command_aliases.get("+").map(|s| s.as_str()), Some("append"));
assert_eq!(cfg.command_aliases.get("l").map(|s| s.as_str()), Some("list"));
}
#[test]
fn test_config_aliases_backward_compat() {
use mps::config::Config;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".mps_config.yaml");
std::fs::write(&path, r#"
mps_dir: /tmp/mps-test
storage_dir: /tmp/mps-test/mps
log_file: /tmp/mps-test/mps.log
aliases:
t: task
n: note
r: reminder
"#).unwrap();
let cfg = Config::load(&path).unwrap();
assert_eq!(cfg.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
assert_eq!(cfg.type_aliases.get("n").map(|s| s.as_str()), Some("note"));
assert_eq!(cfg.type_aliases.get("r").map(|s| s.as_str()), Some("reminder"));
assert!(cfg.command_aliases.is_empty());
}
#[test]
fn test_character_append_and_parse() {
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append(
"character",
"He has done me a great favor",
&["mahfuz-vai".to_string(), "favor".to_string()],
&[("name", "Mahfuz Vai")],
date,
).unwrap();
let els = store.parse_date(date).unwrap();
let el = els.values().find(|e| e.kind() == ElementKind::Character)
.expect("character element should exist");
if let Element::Character { data, .. } = el {
assert_eq!(data.name.as_deref(), Some("Mahfuz Vai"));
assert!(data.tags.contains(&"favor".to_string()));
}
assert!(el.body_str().contains("great favor"));
}
#[test]
fn test_character_append_no_name() {
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("character", "Anonymous observation", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let el = els.values().find(|e| e.kind() == ElementKind::Character)
.expect("character element should exist");
if let Element::Character { data, .. } = el {
assert!(data.name.is_none());
}
}
#[test]
fn test_character_type_filter_excludes_task() {
use mps::commands::{visible, DisplayOpts};
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("task", "do the thing", &["work".to_string()], &[], date).unwrap();
store.append("character", "person entry", &[], &[("name", "Someone")], date).unwrap();
let els = store.parse_date(date).unwrap();
let opts = DisplayOpts {
type_filter: Some("character".to_string()),
tag_filter: None,
status_filter: None,
name_filter: None,
};
let visible_kinds: Vec<_> = els.values()
.filter(|e| !e.is_mps_group() && !e.is_unknown() && visible(e, &opts))
.map(|e| e.kind())
.collect();
assert!(visible_kinds.iter().all(|k| *k == ElementKind::Character),
"only character elements should be visible with type=character");
assert!(!visible_kinds.is_empty());
}
#[test]
fn test_character_tag_filter() {
use mps::commands::{visible, DisplayOpts};
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("character", "entry one", &["mahfuz-vai".to_string()], &[("name", "Mahfuz Vai")], date).unwrap();
store.append("character", "entry two", &["other-person".to_string()], &[("name", "Other")], date).unwrap();
let els = store.parse_date(date).unwrap();
let opts = DisplayOpts {
type_filter: None,
tag_filter: Some("mahfuz-vai".to_string()),
status_filter: None,
name_filter: None,
};
let matched: Vec<_> = els.values()
.filter(|e| !e.is_mps_group() && !e.is_unknown() && visible(e, &opts))
.collect();
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].kind(), ElementKind::Character);
}
#[test]
fn test_character_name_filter() {
use mps::commands::{visible, DisplayOpts};
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("character", "about mahfuz", &["mahfuz-vai".to_string()], &[("name", "Mahfuz Vai")], date).unwrap();
store.append("character", "about alice", &["alice".to_string()], &[("name", "Alice")], date).unwrap();
let els = store.parse_date(date).unwrap();
let opts = DisplayOpts {
type_filter: None,
tag_filter: None,
status_filter: None,
name_filter: Some("Mahfuz Vai".to_string()),
};
let matched: Vec<_> = els.values()
.filter(|e| !e.is_mps_group() && !e.is_unknown() && visible(e, &opts))
.collect();
assert_eq!(matched.len(), 1);
if let Element::Character { data, .. } = matched[0] {
assert_eq!(data.name.as_deref(), Some("Mahfuz Vai"));
}
}
#[test]
fn test_character_search_by_body() {
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("character", "He offered me a fancy breakfast", &[], &[("name", "Mahfuz Vai")], date).unwrap();
let results = store.search("fancy breakfast", None, None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].element.kind(), ElementKind::Character);
}
#[test]
fn test_character_search_type_filter() {
let dir = tempfile::tempdir().unwrap();
let store = Store::new(dir.path());
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("note", "favor note", &[], &[], date).unwrap();
store.append("character", "favor character", &[], &[("name", "Mahfuz Vai")], date).unwrap();
let results = store.search("favor", Some("character"), None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].element.kind(), ElementKind::Character);
}
#[test]
fn test_character_counted_in_store_parse() {
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("character", "first", &[], &[("name", "A")], date).unwrap();
store.append("character", "second", &[], &[("name", "B")], date).unwrap();
store.append("task", "a task", &[], &[], date).unwrap();
let els = store.parse_date(date).unwrap();
let char_count = els.values().filter(|e| e.kind() == ElementKind::Character).count();
assert_eq!(char_count, 2);
let task_count = els.values().filter(|e| e.kind() == ElementKind::Task).count();
assert_eq!(task_count, 1);
}
#[test]
fn test_character_not_excluded_from_search() {
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("character", "unique phrase xyz", &[], &[("name", "Test")], date).unwrap();
let results = store.search("unique phrase xyz", None, None, None).unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn test_character_export_name_in_typed_attrs() {
let (_dir, store) = fixture_store();
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
store.append("character", "body text", &[], &[("name", "Export Test")], date).unwrap();
let els = store.parse_date(date).unwrap();
let el = els.values().find(|e| e.kind() == ElementKind::Character).unwrap();
let attrs = el.typed_attrs();
let name_attr = attrs.iter().find(|(k, _)| k == "name");
assert!(name_attr.is_some());
assert_eq!(name_attr.unwrap().1, "Export Test");
}
#[test]
fn test_meta_shared_path_is_in_storage_dir() {
use mps::meta::MetaShared;
let dir = tempfile::tempdir().unwrap();
let path = MetaShared::path(dir.path());
assert_eq!(path, dir.path().join(".mps.meta"));
}
#[test]
fn test_meta_local_path_is_in_storage_dir() {
use mps::meta::MetaLocal;
let dir = tempfile::tempdir().unwrap();
let path = MetaLocal::path(dir.path());
assert_eq!(path, dir.path().join(".mps.local"));
}
#[test]
fn test_meta_shared_roundtrip_with_all_fields() {
use mps::meta::MetaShared;
use std::collections::HashMap;
let dir = tempfile::tempdir().unwrap();
let mut m = MetaShared::default();
m.version = 1;
m.config.type_aliases = HashMap::from([("t".into(), "task".into())]);
m.config.command_aliases = HashMap::from([("a".into(), "append".into())]);
m.config.default_command = Some("list".into());
m.config.custom_tags = vec!["work".into(), "urgent".into()];
m.config.notify.window_minutes = 10;
m.config.notify.task_notify_at = Some("8am".into());
m.save(dir.path()).unwrap();
let m2 = MetaShared::load(dir.path());
assert_eq!(m2.version, 1);
assert_eq!(m2.config.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
assert_eq!(m2.config.command_aliases.get("a").map(|s| s.as_str()), Some("append"));
assert_eq!(m2.config.default_command.as_deref(), Some("list"));
assert_eq!(m2.config.custom_tags, vec!["work", "urgent"]);
assert_eq!(m2.config.notify.window_minutes, 10);
assert_eq!(m2.config.notify.task_notify_at.as_deref(), Some("8am"));
}
#[test]
fn test_config_merge_meta_unions_type_aliases() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
use std::collections::HashMap;
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: dir.path().to_path_buf(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::from([("t".into(), "task".into())]),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(),
};
let mut meta = MetaConfig::default();
meta.type_aliases.insert("c".into(), "character".into());
meta.type_aliases.insert("t".into(), "note".into()); meta.command_aliases.insert("a".into(), "append".into());
meta.default_command = Some("list".into());
meta.custom_tags = vec!["work".into()];
cfg.merge_meta(&meta);
assert_eq!(cfg.type_aliases.get("t").map(|s| s.as_str()), Some("task")); assert_eq!(cfg.type_aliases.get("c").map(|s| s.as_str()), Some("character")); assert_eq!(cfg.command_aliases.get("a").map(|s| s.as_str()), Some("append"));
assert_eq!(cfg.default_command, "list"); assert!(cfg.custom_tags.contains(&"work".to_string()));
}
#[test]
fn test_config_merge_meta_does_not_clobber_paths() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
use std::collections::HashMap;
let dir = tempfile::tempdir().unwrap();
let storage = dir.path().join("mps");
std::fs::create_dir_all(&storage).unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: storage.clone(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(),
};
let meta = MetaConfig::default();
cfg.merge_meta(&meta);
assert_eq!(cfg.storage_dir, storage);
assert_eq!(cfg.git_remote, "origin");
assert_eq!(cfg.git_branch, "master");
}
#[test]
fn test_config_merge_meta_custom_tags_deduped() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
use std::collections::HashMap;
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: dir.path().to_path_buf(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec!["work".into()],
notify: NotifyConfig::default(),
};
let mut meta = MetaConfig::default();
meta.custom_tags = vec!["work".into(), "urgent".into()];
cfg.merge_meta(&meta);
let work_count = cfg.custom_tags.iter().filter(|t| t.as_str() == "work").count();
assert_eq!(work_count, 1, "duplicate tags must be removed");
assert!(cfg.custom_tags.contains(&"urgent".to_string()));
}
#[test]
fn test_daemon_service_template_has_placeholders() {
use mps::config::{Config, NotifyConfig};
use std::collections::HashMap;
let dir = tempfile::tempdir().unwrap();
let storage = dir.path().join("mps");
std::fs::create_dir_all(&storage).unwrap();
std::fs::write(dir.path().join("mps.log"), "").unwrap();
let cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: storage,
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(),
};
let mut cfg2 = cfg.clone();
cfg2.notify.enabled = false;
let result = mps::commands::daemon::run(&cfg2, "run");
assert!(result.is_ok());
let err = mps::commands::daemon::run(&cfg, "bogus");
assert!(err.is_err());
}
#[test]
fn test_time_parse_used_in_notify_reminder_scan() {
use mps::time_parse::parse_time;
use chrono::Timelike;
let cases = [("5pm", 17, 0), ("9am", 9, 0), ("3:30pm", 15, 30), ("noon", 12, 0)];
for (input, exp_h, exp_m) in cases {
let t = parse_time(input).unwrap_or_else(|_| panic!("parse_time('{}') failed", input));
assert_eq!(t.hour(), exp_h, "hour mismatch for '{}'", input);
assert_eq!(t.minute(), exp_m, "minute mismatch for '{}'", input);
}
}
#[test]
fn test_notify_dry_run_no_events_when_no_reminders() {
use mps::config::{Config, NotifyConfig};
use std::collections::HashMap;
let dir = tempfile::tempdir().unwrap();
let storage = dir.path().join("mps");
std::fs::create_dir_all(&storage).unwrap();
let log = dir.path().join("mps.log");
std::fs::write(&log, "").unwrap();
let cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: storage,
log_file: log,
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(),
};
let result = mps::commands::notify::run(&cfg, true, None, false);
assert!(result.is_ok());
assert!(!mps::meta::MetaLocal::path(&cfg.storage_dir).exists());
}
fn make_notify_cfg(storage: &std::path::Path, log: &std::path::Path) -> mps::config::Config {
use mps::config::NotifyConfig;
mps::config::Config {
mps_dir: storage.parent().unwrap().to_path_buf(),
storage_dir: storage.to_path_buf(),
log_file: log.to_path_buf(),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(),
}
}
#[test]
fn test_notify_cooldown_uses_task_cooldown_minutes() {
let dir = tempfile::tempdir().unwrap();
let storage = dir.path().join("mps");
std::fs::create_dir_all(&storage).unwrap();
let log = dir.path().join("mps.log");
std::fs::write(&log, "").unwrap();
let cfg = make_notify_cfg(&storage, &log);
let epoch_ref = "20260101.1700000000.1";
let mut local = mps::meta::MetaLocal::default();
local.mark_notified(epoch_ref);
local.save(&storage).unwrap();
let local2 = mps::meta::MetaLocal::load(&storage);
assert!(local2.was_notified(epoch_ref, (cfg.notify.task_cooldown_minutes * 60) as i64),
"reminder marked now must be within 60-minute cooldown");
assert!(cfg.notify.task_cooldown_minutes * 60 > cfg.notify.window_minutes * 60,
"cooldown must exceed window so dedup logic is meaningful");
}
#[test]
fn test_merge_meta_notify_open_tasks_false_in_meta_wins() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: dir.path().to_path_buf(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(), };
let mut meta = MetaConfig::default();
meta.notify.notify_open_tasks = false;
cfg.merge_meta(&meta);
assert!(!cfg.notify.notify_open_tasks,
"meta.notify_open_tasks=false must disable open task briefing");
}
#[test]
fn test_merge_meta_open_task_tags_from_meta() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: dir.path().to_path_buf(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(), };
let mut meta = MetaConfig::default();
meta.notify.open_task_tags = vec!["urgent".into(), "work".into()];
cfg.merge_meta(&meta);
assert_eq!(cfg.notify.open_task_tags, vec!["urgent", "work"],
"meta open_task_tags must be applied when non-empty");
}
#[test]
fn test_merge_meta_yaml_open_task_tags_not_clobbered_by_empty_meta() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: dir.path().to_path_buf(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig {
open_task_tags: vec!["personal".into()],
..NotifyConfig::default()
},
};
let meta = MetaConfig::default();
cfg.merge_meta(&meta);
assert_eq!(cfg.notify.open_task_tags, vec!["personal"],
"YAML open_task_tags must survive when meta is empty");
}
#[test]
fn test_merge_meta_notify_yaml_window_minutes_survives() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: dir.path().to_path_buf(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig {
window_minutes: 10, ..NotifyConfig::default()
},
};
let mut meta = MetaConfig::default();
meta.notify.task_notify_at = Some("9am".into());
cfg.merge_meta(&meta);
assert_eq!(cfg.notify.window_minutes, 10,
"YAML window_minutes must survive when meta has default value");
assert_eq!(cfg.notify.task_notify_at.as_deref(), Some("9am"),
"meta task_notify_at must be merged");
}
#[test]
fn test_merge_meta_notify_disabled_in_meta_wins() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: dir.path().to_path_buf(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(), };
let mut meta = MetaConfig::default();
meta.notify.enabled = false;
cfg.merge_meta(&meta);
assert!(!cfg.notify.enabled, "meta.notify.enabled=false must override YAML enabled=true");
}
#[test]
fn test_merge_meta_notify_overdue_days_from_meta() {
use mps::config::{Config, NotifyConfig};
use mps::meta::MetaConfig;
let dir = tempfile::tempdir().unwrap();
let mut cfg = Config {
mps_dir: dir.path().to_path_buf(),
storage_dir: dir.path().to_path_buf(),
log_file: dir.path().join("mps.log"),
git_remote: "origin".into(),
git_branch: "master".into(),
default_command: "open".into(),
type_aliases: HashMap::new(),
command_aliases: HashMap::new(),
custom_tags: vec![],
notify: NotifyConfig::default(), };
let mut meta = MetaConfig::default();
meta.notify.overdue_days = 14;
cfg.merge_meta(&meta);
assert_eq!(cfg.notify.overdue_days, 14,
"non-default meta overdue_days must override YAML default");
}
#[test]
fn test_notify_enabled_false_returns_immediately() {
let dir = tempfile::tempdir().unwrap();
let storage = dir.path().join("mps");
std::fs::create_dir_all(&storage).unwrap();
let log = dir.path().join("mps.log");
std::fs::write(&log, "").unwrap();
let mut cfg = make_notify_cfg(&storage, &log);
cfg.notify.enabled = false;
let store = mps::store::Store::new(&storage);
let today = chrono::Local::now().date_naive();
store.append("task", "Important task", &[], &[], today).unwrap();
let result = mps::commands::notify::run(&cfg, true, None, false);
assert!(result.is_ok());
}
#[test]
fn test_notify_open_tasks_tag_filter_excludes_untagged() {
let dir = tempfile::tempdir().unwrap();
let storage = dir.path().join("mps");
std::fs::create_dir_all(&storage).unwrap();
let log = dir.path().join("mps.log");
std::fs::write(&log, "").unwrap();
let mut cfg = make_notify_cfg(&storage, &log);
cfg.notify.open_task_tags = vec!["urgent".into()];
cfg.notify.task_notify_at = Some("9am".into());
cfg.notify.notify_open_tasks = true;
let store = mps::store::Store::new(&storage);
let today = chrono::Local::now().date_naive();
store.append("task", "Non-urgent task", &[], &[], today).unwrap();
let result = mps::commands::notify::run(&cfg, true, None, false);
assert!(result.is_ok(), "notify with tag filter must not error");
}