use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::{Value, json};
use tempfile::TempDir;
use time::{Date, Month};
use tix::app::App;
use tix::config::Config;
use tix::mcp::protocol::Id;
use tix::mcp::tools;
use tix::model::Issue;
use tix::recurrence::detect::detect;
use tix::recurrence::rule::RuleContext;
use tix::recurrence::series::SeriesIndex;
use tix::storage::Store;
fn repo() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
fs::write(
tmp.path().join("tix.toml"),
format!(
"issues_dir = {issues:?}\n\
[kanban.default]\n\
columns = [\"todo\", \"in progress\", \"done\"]\n\
done_column = \"done\"\n\
[layout]\n\
move_on_change = false\n",
issues = dir.to_string_lossy(),
),
)
.unwrap();
(tmp, dir)
}
fn write_series(dir: &Path, name: &str, number: u32, id: &str, anchor: &str, state: &str) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"{name}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
series:\n id: {id}\n name: {name}\n state: {state}\n rule:\n kind: daily\n \
anchor: {anchor}\n---\n\nSeries body.\n"
);
fs::write(dir.join(format!("2026-01-01-{id}.md")), contents).unwrap();
}
fn load(tmp: &TempDir) -> (Config, Store) {
let config = Config::load(tmp.path()).expect("the fixture config loads");
let store = Store::open(&config).expect("the fixture store opens");
(config, store)
}
fn cli(root: &Path, args: &[&str]) -> String {
let out = Command::new(env!("CARGO_BIN_EXE_tix"))
.current_dir(root)
.args(args)
.output()
.expect("the binary runs");
assert!(
out.status.success(),
"tix {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).expect("stdout is UTF-8")
}
fn cli_refused(root: &Path, args: &[&str]) -> String {
let out = Command::new(env!("CARGO_BIN_EXE_tix"))
.current_dir(root)
.args(args)
.output()
.expect("the binary runs");
assert!(!out.status.success(), "tix {args:?} unexpectedly succeeded");
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
fn mcp(config: &Config, store: &Store, name: &str, args: &Value) -> Value {
tools::call(config, store, Id::Number(1), name, args)
.unwrap_or_else(|error| panic!("tix {name} was a protocol error: {error:?}"))
}
fn tool_text(result: &Value) -> &str {
result["content"][0]["text"]
.as_str()
.expect("a text content block")
}
fn tool_ok(result: &Value) -> bool {
result.get("isError") != Some(&Value::Bool(true))
}
fn tool_structured(result: &Value) -> &Value {
&result["structuredContent"]
}
fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(root, &path, out);
} else {
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
out.insert(relative, fs::read(&path).unwrap_or_default());
}
}
}
let mut out = BTreeMap::new();
walk(dir, dir, &mut out);
out
}
fn file_texts(dir: &Path) -> Vec<(PathBuf, String)> {
fn walk(dir: &Path, out: &mut Vec<(PathBuf, String)>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, out);
} else {
out.push((path.clone(), fs::read_to_string(&path).unwrap_or_default()));
}
}
}
let mut out = Vec::new();
walk(dir, &mut out);
out.sort();
out
}
fn file_containing(dir: &Path, needle: &str) -> (PathBuf, String) {
let found: Vec<(PathBuf, String)> = file_texts(dir)
.into_iter()
.filter(|(_, text)| text.contains(needle))
.collect();
assert_eq!(
found.len(),
1,
"exactly one file holds {needle:?}: {found:?}"
);
found.into_iter().next().expect("one match")
}
fn digest(path: &Path) -> Vec<String> {
fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(|line| {
match line
.splitn(3, " \u{2014} ")
.collect::<Vec<&str>>()
.as_slice()
{
[_timestamp, actor, rest] => format!("- {actor} \u{2014} {rest}"),
_ => line.to_string(),
}
})
.filter(|line| !line.starts_with("updated:"))
.collect()
}
fn history_bullets(text: &str) -> Vec<String> {
text.split_once("## History")
.map(|(_, after)| {
after
.lines()
.filter(|line| line.starts_with("- "))
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn cli_warning_keys(output: &str) -> BTreeSet<String> {
output
.lines()
.filter(|line| line.starts_with("series_id\t"))
.skip(1)
.chain(
output
.lines()
.filter(|line| !line.starts_with("series_id\t") && !line.starts_with("note:")),
)
.filter(|line| line.contains('@'))
.filter_map(|line| line.split('\t').nth(1))
.map(str::to_string)
.collect()
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn series_issue(number: u32, id: &str, anchor: &str) -> Issue {
let contents = format!(
"---\nnumber: {number}\ntitle: \"Fixture {number}\"\nstatus: \"todo\"\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
series:\n id: {id}\n name: Fixture {number}\n state: active\n rule:\n \
kind: daily\n anchor: {anchor}\n---\n\nBody.\n"
);
let mut issue = Issue::parse(&contents).expect("the fixture parses");
issue.filename = format!("fixture-{number}.md");
issue
}
#[test]
fn nfr001_detection_of_ten_thousand_series_is_one_pass_without_io() {
let issues: Vec<Issue> = (1..=10_000)
.map(|number| series_issue(number, &format!("s-{number}"), "2000-01-01"))
.collect();
let index = SeriesIndex::build(&issues);
assert_eq!(index.series_count(), 10_000);
assert!(index.is_consistent(), "{:?}", index.diagnostics());
let context =
RuleContext::on(Date::from_calendar_date(2000, Month::January, 2).expect("a real date"));
let first = detect(&index, &context);
assert_eq!(
first.series_examined(),
10_000,
"one pass over every series"
);
assert_eq!(
first.warnings().len(),
10_000,
"every series is due at 2000-01-01"
);
assert_eq!(
first.warnings()[0].occurrence_key.as_str(),
"s-1@2000-01-01"
);
let second = detect(&index, &context);
assert_eq!(first.warnings().len(), second.warnings().len());
assert_eq!(
first.warnings()[0].occurrence_key,
second.warnings()[0].occurrence_key
);
let started = Instant::now();
for _ in 0..10 {
let repeated = detect(&index, &context);
std::hint::black_box(repeated.warnings().len());
}
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"10 detections of 10,000 series took {elapsed:?}, which suggests each detection re-walks"
);
let issues = vec![series_issue(1, "s-future", "2999-01-01")];
let index = SeriesIndex::build(&issues);
let detection = detect(&index, &context);
assert_eq!(detection.series_examined(), 1);
assert!(detection.warnings().is_empty());
assert!(
detection.notes().is_empty(),
"nothing is due and nothing is wrong"
);
let mut paused = series_issue(1, "s-paused", "2000-01-01");
paused
.series
.as_mut()
.expect("the fixture has a series")
.state = Some(tix::recurrence::series::SeriesLifecycle::Paused);
let index = SeriesIndex::build(&[paused]);
let detection = detect(&index, &context);
assert!(
detection.warnings().is_empty(),
"a paused series is never due"
);
assert!(
detection
.notes()
.iter()
.any(|note| note.kind.as_str() == "series_paused"),
"and the detector says why: {detection:?}"
);
}
#[test]
fn nfr002_a_repeated_stable_key_is_one_occurrence_and_one_event() {
let (tmp, dir) = repo();
write_series(&dir, "Nightly close", 1, "nightly", "2000-01-01", "active");
let root = tmp.path().to_path_buf();
let (config, store) = load(&tmp);
let created = cli(
&root,
&[
"recurring",
"generate",
"nightly",
"--date",
"2000-01-01",
"--yes",
],
);
assert!(
created.starts_with("Created occurrence #2 for "),
"{created}"
);
let after_first = snapshot(&dir);
let again = mcp(
&config,
&store,
"generate_recurring_occurrence",
&json!({ "series": "nightly", "date": "2000-01-01", "confirm": true }),
);
assert!(tool_ok(&again), "{}", tool_text(&again));
assert_eq!(tool_structured(&again)["status"], json!("no-op"));
let cli_again = cli(
&root,
&[
"recurring",
"generate",
"nightly",
"--date",
"2000-01-01",
"--yes",
],
);
assert!(cli_again.starts_with("No-op for "), "{cli_again}");
assert_eq!(snapshot(&dir), after_first, "a repeated key writes nothing");
let (_, text) = file_containing(&dir, "key: nightly@2000-01-01");
let events = history_bullets(&text);
assert_eq!(
events.len(),
1,
"one History entry, appended by the first call only: {events:?}"
);
assert!(
events[0].contains("generated occurrence `nightly@2000-01-01`"),
"and it says what was generated: {events:?}"
);
}
#[test]
fn nfr003_the_three_surfaces_report_the_same_due_warning() {
let (tmp, dir) = repo();
write_series(&dir, "Nightly close", 1, "s-due", "2000-01-01", "active");
write_series(&dir, "Much later", 2, "s-later", "2999-01-01", "active");
let root = tmp.path().to_path_buf();
let (config, store) = load(&tmp);
let cli_output = cli(&root, &["recurring", "detect"]);
let cli_keys = cli_warning_keys(&cli_output);
assert_eq!(
cli_keys,
BTreeSet::from(["s-due@2000-01-01".to_string()]),
"{cli_output}"
);
let app = App::new(&config).expect("the app starts");
let overlay = app
.recurring_warning
.as_ref()
.expect("start detection raises the warning");
let tui_keys: BTreeSet<String> = overlay
.warnings
.iter()
.map(|warning| warning.occurrence_key.as_str().to_string())
.collect();
assert_eq!(tui_keys, cli_keys, "the TUI and the CLI agree");
let detected = mcp(
&config,
&store,
"detect_recurring_due",
&json!({ "date": &tix::model::today() }),
);
assert!(tool_ok(&detected), "{}", tool_text(&detected));
let mcp_keys: BTreeSet<String> = tool_structured(&detected)["warnings"]
.as_array()
.expect("warnings")
.iter()
.map(|warning| {
warning["occurrence_key"]
.as_str()
.expect("a key")
.to_string()
})
.collect();
assert_eq!(mcp_keys, cli_keys, "MCP and the CLI agree");
assert_eq!(tool_text(&detected), cli_output, "and word for word");
let structured = &tool_structured(&detected)["warnings"][0];
assert_eq!(structured["series_name"], json!("Nightly close"));
assert_eq!(structured["due_date"], json!("2000-01-01"));
assert!(
structured["summary"]
.as_str()
.expect("a summary")
.contains("Nightly close is due on 2000-01-01"),
"{structured}"
);
}
#[test]
fn nfr003_the_three_surfaces_create_the_same_occurrence() {
let (cli_tmp, cli_dir) = repo();
let (mcp_tmp, mcp_dir) = repo();
let (tui_tmp, tui_dir) = repo();
for dir in [&cli_dir, &mcp_dir, &tui_dir] {
write_series(dir, "Nightly close", 1, "nightly", "2000-01-01", "active");
}
let cli_created = cli(
cli_tmp.path(),
&[
"recurring",
"generate",
"nightly",
"--date",
"2000-01-01",
"--yes",
],
);
let (mcp_config, mcp_store) = load(&mcp_tmp);
let mcp_created = mcp(
&mcp_config,
&mcp_store,
"generate_recurring_occurrence",
&json!({ "series": "nightly", "date": "2000-01-01", "confirm": true }),
);
assert!(tool_ok(&mcp_created), "{}", tool_text(&mcp_created));
let tui_config = Config::load(tui_tmp.path()).expect("config");
let mut app = App::new(&tui_config).expect("the app starts");
assert!(
app.recurring_warning.is_some(),
"the series is due, so the overlay is open"
);
app.handle_key(press(KeyCode::Char('g')))
.expect("the overlay answers `g`");
let tui_status = app.status_msg.clone().unwrap_or_default();
assert_eq!(
tool_structured(&mcp_created)["occurrence_key"],
json!("nightly@2000-01-01")
);
assert!(cli_created.contains("nightly@2000-01-01"), "{cli_created}");
assert!(
tui_status.contains("nightly@2000-01-01"),
"the TUI names the same key: {tui_status}"
);
let cli_show = cli(cli_tmp.path(), &["recurring", "show", "nightly"]);
let mcp_show = cli(mcp_tmp.path(), &["recurring", "show", "nightly"]);
let tui_show = cli(tui_tmp.path(), &["recurring", "show", "nightly"]);
let state_lines = |text: String| -> Vec<String> {
text.lines()
.filter(|line| !line.starts_with("issue:"))
.map(str::to_string)
.collect()
};
assert_eq!(state_lines(mcp_show.clone()), state_lines(cli_show.clone()));
assert_eq!(
state_lines(tui_show),
state_lines(cli_show),
"the same action leaves the same series state on every surface"
);
let series_block = |dir: &Path| -> Vec<String> {
let (_, text) = file_containing(dir, "key: nightly@2000-01-01");
text.lines()
.filter(|line| line.starts_with(" "))
.map(str::to_string)
.collect()
};
assert_eq!(series_block(&mcp_dir), series_block(&cli_dir));
assert_eq!(series_block(&tui_dir), series_block(&cli_dir));
}
#[test]
fn c004_every_read_surface_is_inert_and_every_unconfirmed_mutation_writes_nothing() {
let (tmp, dir) = repo();
write_series(&dir, "Nightly close", 1, "nightly", "2000-01-01", "active");
let root = tmp.path().to_path_buf();
let (config, store) = load(&tmp);
let before = snapshot(&dir);
cli(&root, &["recurring", "list"]);
cli(&root, &["recurring", "show", "nightly"]);
cli(&root, &["recurring", "detect"]);
for (name, args) in [
("list_recurring_series", json!({})),
("get_recurring_series", json!({ "series": "nightly" })),
("detect_recurring_due", json!({})),
] {
let read = mcp(&config, &store, name, &args);
assert!(tool_ok(&read), "{name}: {}", tool_text(&read));
}
let mut app = App::new(&config).expect("the app starts");
app.handle_key(press(KeyCode::Char('r'))).expect("refresh");
app.handle_key(press(KeyCode::F(8)))
.expect("the Schedule page");
app.handle_key(press(KeyCode::Down))
.expect("move the cursor");
app.handle_key(press(KeyCode::Char('?'))).expect("help");
assert_eq!(
snapshot(&dir),
before,
"reading a series, a schedule or a warning must write nothing"
);
let refused = cli_refused(
&root,
&["recurring", "generate", "nightly", "--date", "2000-01-01"],
);
assert!(refused.contains("not_confirmed"), "{refused}");
let refused = cli_refused(&root, &["recurring", "pause", "nightly"]);
assert!(refused.contains("not_confirmed"), "{refused}");
let refused = mcp(
&config,
&store,
"generate_recurring_occurrence",
&json!({ "series": "nightly", "date": "2000-01-01" }),
);
assert!(!tool_ok(&refused), "{}", tool_text(&refused));
assert!(tool_text(&refused).contains("not_confirmed"), "{refused}");
let refused = mcp(
&config,
&store,
"pause_recurring_series",
&json!({ "series": "nightly" }),
);
assert!(!tool_ok(&refused), "{}", tool_text(&refused));
assert_eq!(
tool_structured(&refused)["status"],
json!("rejected"),
"and the refusal stays diagnosable"
);
assert_eq!(
snapshot(&dir),
before,
"an unconfirmed mutation must write nothing"
);
let (mcp_tmp, mcp_dir) = repo();
write_series(
&mcp_dir,
"Nightly close",
1,
"nightly",
"2000-01-01",
"active",
);
let (mcp_config, mcp_store) = load(&mcp_tmp);
cli(
&root,
&[
"recurring",
"pause",
"nightly",
"--yes",
"--reason",
"freeze",
],
);
let paused = mcp(
&mcp_config,
&mcp_store,
"pause_recurring_series",
&json!({ "series": "nightly", "confirm": true, "reason": "freeze" }),
);
assert!(tool_ok(&paused), "{}", tool_text(&paused));
let (mcp_definition, _) = file_containing(&mcp_dir, "id: nightly");
let (definition, _) = file_containing(&dir, "id: nightly");
assert_eq!(
digest(&mcp_definition),
digest(&definition),
"one service, one outcome, whichever surface asked"
);
}