use std::collections::BTreeMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::{Value, json};
use tempfile::TempDir;
use tix::app::{App, Mode, StatsTab, StatsTimeState};
use tix::config::Config;
use tix::model::{HistoryEventKind, Issue};
use tix::storage::Store;
use tix::time_tracking::aggregate::{TimeSnapshot, TimeTotal, TimeTotalState};
use tix::time_tracking::entry::TimeEntry;
use tix::time_tracking::filters::TimeFilter;
use tix::time_tracking::portable::PortableTimeDocument;
use tix::time_tracking::service::entry_id_in;
fn tix() -> String {
env!("CARGO_BIN_EXE_tix").to_string()
}
fn project() -> (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]\ncolumns = [\"todo\", \"in progress\", \"done\"]\n\
done_column = \"done\"\n[layout]\nmove_on_change = false\n",
issues = dir.to_string_lossy(),
),
)
.unwrap();
(tmp, dir)
}
fn config(tmp: &TempDir) -> Config {
Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).expect("the config loads")
}
fn issue_markdown(number: u32, assignee: &str, entries: &str, history: &str) -> String {
format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\n\
labels: []\npriority: \"medium\"\nassignee: {assignee:?}\n\
estimate: \"3d\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{entries}---\n\n\
{history}Body prose that must survive.\n"
)
}
fn issue_filename(number: u32) -> String {
format!("2026-01-01-issue-{number}.md")
}
fn write_issue_with_history(dir: &Path, number: u32, assignee: &str, entries: &str, history: &str) {
fs::write(
dir.join(issue_filename(number)),
issue_markdown(number, assignee, entries, history),
)
.unwrap();
}
fn write_issue(dir: &Path, number: u32, assignee: &str, entries: &str) {
write_issue_with_history(dir, number, assignee, entries, "");
}
fn entry_line(
id: &str,
minutes: Option<i64>,
date: Option<&str>,
author: &str,
note: &str,
) -> String {
let mut out = format!("- id: {id}\n");
if let Some(minutes) = minutes {
out.push_str(&format!(" duration_minutes: {minutes}\n"));
}
if let Some(date) = date {
out.push_str(&format!(" date: {date:?}\n"));
}
out.push_str(&format!(" author: {author:?}\n"));
if !note.is_empty() {
out.push_str(&format!(" note: {note:?}\n"));
}
out
}
fn entries(items: &[String]) -> String {
format!("time_entries:\n{}", items.concat())
}
const AUDIT_HISTORY: &str = "## History\n\n\
- 2026-03-10T08:00:00Z \u{2014} ada \u{2014} time_entry_updated \u{2014} \
entry e-a1; updated 60m on 2026-03-01 by ada -> 90m on 2026-03-01 by ada; reason: recount\n\
- 2026-03-11T09:30:00Z \u{2014} bob \u{2014} time_entry_deleted \u{2014} \
entry e-old; deleted 45m on 2026-02-01 by ada; reason: duplicate\n\n";
fn fixture() -> (TempDir, PathBuf) {
let (tmp, dir) = project();
write_issue_with_history(
&dir,
1,
"@ada",
&entries(&[
entry_line("e-a1", Some(90), Some("2026-03-01"), "ada", "shipped"),
entry_line("e-a2", Some(30), None, "ada", ""),
entry_line("e-a0", Some(0), Some("2026-03-02"), "ada", ""),
]),
AUDIT_HISTORY,
);
write_issue(
&dir,
2,
"@bob",
&entries(&[entry_line("e-b1", None, None, "bob", "")]),
);
write_issue(&dir, 3, "@ada", "");
write_issue(
&dir,
4,
"@ada",
&entries(&[entry_line("e-d1", Some(0), Some("2026-03-03"), "ada", "")]),
);
(tmp, dir)
}
fn snapshot(tmp: &TempDir) -> (Config, TimeSnapshot) {
let config = config(tmp);
let store = Store::open(&config).expect("the store opens");
let issues = store.list().expect("the issues are read");
(config, TimeSnapshot::of(&issues))
}
fn run(dir: &Path, args: &[&str]) -> (i32, String, String) {
let out = Command::new(tix())
.current_dir(dir)
.args(args)
.output()
.expect("the binary runs");
(
out.status.code().unwrap_or(-1),
String::from_utf8(out.stdout).unwrap(),
String::from_utf8(out.stderr).unwrap(),
)
}
fn ok(dir: &Path, args: &[&str]) -> String {
let (code, stdout, stderr) = run(dir, args);
assert_eq!(
code, 0,
"tix {args:?} exited {code}\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
stdout
}
fn refused(dir: &Path, args: &[&str]) -> (i32, String, String) {
let (code, stdout, stderr) = run(dir, args);
assert_ne!(code, 0, "tix {args:?} unexpectedly succeeded:\n{stdout}");
(code, stdout, stderr)
}
fn tree(dir: &Path) -> BTreeMap<String, Vec<u8>> {
let mut out = BTreeMap::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let Ok(read) = fs::read_dir(¤t) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else {
let name = path
.strip_prefix(dir)
.unwrap()
.to_string_lossy()
.to_string();
out.insert(name, fs::read(&path).unwrap());
}
}
}
out
}
fn block(stdout: &str) -> BTreeMap<String, String> {
stdout
.lines()
.filter_map(|line| line.split_once(": "))
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
fn opt_i128(raw: &str) -> Option<i128> {
if raw.trim() == "-" {
None
} else {
Some(raw.trim().parse().expect("a numeric total"))
}
}
type Fields = (String, Option<i128>, usize, usize, usize);
fn shared_fields(total: &TimeTotal) -> Fields {
(
total.state().as_str().to_string(),
total.total_minutes,
total.entry_count,
total.malformed_count,
total.undated_count,
)
}
fn cli_fields(stdout: &str) -> Fields {
let fields = block(stdout);
(
fields["state"].clone(),
opt_i128(&fields["total_minutes"]),
fields["entry_count"].parse().unwrap(),
fields["malformed_count"].parse().unwrap(),
fields["undated_count"].parse().unwrap(),
)
}
fn portable_fields(document: &PortableTimeDocument) -> Fields {
(
document.total.state.clone(),
document.total.total_minutes,
document.total.entry_count,
document.total.malformed_count,
document.total.undated_count,
)
}
fn value_result(result: &Value) -> &Value {
result.get("result").unwrap_or(result)
}
fn structured(result: &Value) -> &Value {
&value_result(result)["structuredContent"]
}
fn text(result: &Value) -> &str {
value_result(result)["content"][0]["text"]
.as_str()
.expect("the tool answered with a text channel")
}
struct Mcp {
child: Child,
stdin: Option<ChildStdin>,
rx: mpsc::Receiver<String>,
reader: Option<JoinHandle<()>>,
id: u32,
}
impl Mcp {
fn start(repo: &Path) -> Self {
let mut child = Command::new(tix())
.arg("mcp")
.current_dir(repo)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn tix mcp");
let stdin = child.stdin.take().expect("stdin piped");
let stdout = child.stdout.take().expect("stdout piped");
let (tx, rx) = mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
match line {
Ok(line) => {
if tx.send(line).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let mut session = Self {
child,
stdin: Some(stdin),
rx,
reader: Some(reader),
id: 1,
};
session.handshake();
session
}
fn send(&mut self, message: Value) {
let stdin = self.stdin.as_mut().expect("stdin open");
writeln!(stdin, "{message}").unwrap();
stdin.flush().unwrap();
}
fn next_id(&mut self) -> u32 {
let id = self.id;
self.id += 1;
id
}
fn handshake(&mut self) {
let id = self.next_id();
self.send(json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}}
}));
let _ = self.recv();
self.send(json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
}
fn recv(&self) -> Value {
let line = self
.rx
.recv_timeout(Duration::from_secs(10))
.expect("the server answers within 10 s");
serde_json::from_str(&line).unwrap_or_else(|error| panic!("invalid JSON: {error}\n{line}"))
}
fn call(&mut self, name: &str, args: &Value) -> Value {
let id = self.next_id();
self.send(json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {"name": name, "arguments": args}
}));
self.recv()
}
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
drop(self.stdin.take());
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
let _ = self.child.wait();
}
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn render_rows(app: &App, width: u16, height: u16) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| tix::app::render(app, f, f.area()))
.unwrap();
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect()
})
.collect()
}
fn open_time_tab(app: &mut App) {
let _ = app.handle_key(press(KeyCode::F(7))).unwrap();
assert_eq!(app.mode, Mode::Stats, "F7 opens the Stats page");
let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
assert_eq!(app.stats_tab, StatsTab::Time, "Tab switches tabs");
}
fn parsed(dir: &Path, number: u32) -> Issue {
let text = fs::read_to_string(dir.join(issue_filename(number)))
.unwrap_or_else(|error| panic!("issue #{number} reads: {error}"));
Issue::parse(&text).unwrap_or_else(|error| panic!("issue #{number} parses: {error}"))
}
fn time_events(issue: &Issue) -> Vec<(HistoryEventKind, String)> {
issue
.history
.events()
.into_iter()
.filter(|event| {
matches!(
event.kind,
HistoryEventKind::TimeEntryCreated
| HistoryEventKind::TimeEntryUpdated
| HistoryEventKind::TimeEntryDeleted
)
})
.map(|event| (event.kind, event.detail.clone()))
.collect()
}
fn history_lines(text: &str) -> Vec<String> {
text.lines()
.skip_while(|line| line.trim() != "## History")
.filter(|line| line.starts_with("- "))
.map(str::to_string)
.collect()
}
#[test]
fn c002_the_entry_model_refuses_each_bad_field_by_name() {
let negative = TimeEntry::new("e-1", -5, None, "ada", None).unwrap_err();
assert_eq!(negative[0].kind(), "invalid_duration");
assert_eq!(negative[0].field(), "duration_minutes");
let date = TimeEntry::new("e-1", 5, Some("not-a-date".into()), "ada", None).unwrap_err();
assert_eq!(date[0].kind(), "invalid_date");
assert_eq!(date[0].field(), "date");
let author = TimeEntry::new("e-1", 5, None, " ", None).unwrap_err();
assert_eq!(author[0].kind(), "missing_author");
assert_eq!(author[0].field(), "author");
let id = TimeEntry::new(" ", 5, None, "ada", None).unwrap_err();
assert_eq!(id[0].kind(), "malformed_entry");
assert_eq!(id[0].field(), "id");
let malformed = TimeEntry {
id: "e-1".into(),
duration_minutes: None,
date: None,
author: "ada".into(),
note: None,
extensions: Default::default(),
};
assert_eq!(malformed.minutes(), None);
assert_eq!(malformed.problems()[0].kind(), "invalid_duration");
let valid = TimeEntry::new(
"e-1",
90,
Some("2026-03-01".into()),
"ada",
Some("shipped".into()),
)
.expect("a valid entry is accepted");
assert!(valid.is_valid());
assert_eq!(valid.minutes(), Some(90));
}
#[test]
fn nfr002_c008_each_mutation_appends_exactly_one_attributable_event() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let before = parsed(dir, 1);
let added = ok(
dir,
&[
"time",
"add",
"1",
"--minutes",
"15",
"--author",
"ada",
"--note",
"review",
],
);
let created_id = added.split_whitespace().nth(1).unwrap().to_string();
let after_create = parsed(dir, 1);
assert_eq!(
time_events(&after_create).len(),
time_events(&before).len() + 1
);
let (kind, detail) = time_events(&after_create)
.into_iter()
.find(|(kind, _)| *kind == HistoryEventKind::TimeEntryCreated)
.expect("one create event");
assert_eq!(kind, HistoryEventKind::TimeEntryCreated);
assert_eq!(entry_id_in(&detail), Some(created_id.as_str()));
assert!(detail.contains("15m"), "{detail}");
ok(
dir,
&[
"time",
"edit",
"1",
"--entry",
&created_id,
"--minutes",
"20",
"--reason",
"recount",
],
);
let after_edit = parsed(dir, 1);
assert_eq!(
time_events(&after_edit).len(),
time_events(&after_create).len() + 1
);
let updates: Vec<String> = time_events(&after_edit)
.into_iter()
.filter(|(kind, _)| *kind == HistoryEventKind::TimeEntryUpdated)
.map(|(_, detail)| detail)
.collect();
let edit_detail = updates
.iter()
.find(|detail| entry_id_in(detail) == Some(created_id.as_str()))
.unwrap_or_else(|| panic!("the correction names the entry: {updates:#?}"));
assert!(
edit_detail.contains("15m") && edit_detail.contains("20m") && edit_detail.contains(" -> "),
"{edit_detail}"
);
assert_eq!(entry_id_in(edit_detail), Some(created_id.as_str()));
ok(
dir,
&[
"time",
"delete",
"1",
"--entry",
&created_id,
"--reason",
"wrong issue",
"--confirm",
],
);
let after_delete = parsed(dir, 1);
assert_eq!(
time_events(&after_delete).len(),
time_events(&after_edit).len() + 1
);
let deleted = time_events(&after_delete)
.into_iter()
.filter(|(kind, _)| *kind == HistoryEventKind::TimeEntryDeleted)
.map(|(_, detail)| detail)
.find(|detail| entry_id_in(detail) == Some(created_id.as_str()))
.unwrap_or_else(|| panic!("the deletion names the entry {created_id}"));
assert!(deleted.contains("20m"), "{deleted}");
assert!(
!after_delete
.time_entries
.iter()
.any(|entry| entry.id == created_id)
);
let final_issue = parsed(dir, 1);
assert_eq!(final_issue.status, before.status);
assert_eq!(final_issue.assignee, before.assignee);
assert_eq!(final_issue.estimate, before.estimate);
assert_eq!(final_issue.body, before.body);
}
#[test]
fn c003_a_refused_mutation_leaves_the_tree_byte_identical() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let refusals: &[(&[&str], &str)] = &[
(
&["time", "delete", "1", "--entry", "e-a1", "--reason", "why"],
"needs --confirm",
),
(
&[
"time",
"edit",
"1",
"--entry",
"e-a1",
"--minutes",
"5",
"--reason",
" ",
],
"missing_reason",
),
(
&[
"time",
"edit",
"1",
"--entry",
"e-nope",
"--minutes",
"5",
"--reason",
"why",
],
"unknown_entry",
),
(
&["time", "add", "1", "--minutes=-5", "--author", "ada"],
"invalid_duration",
),
(
&["time", "add", "1", "--minutes", "5", "--author", " "],
"missing_author",
),
];
for (args, expected) in refusals {
let before = tree(dir);
let (code, stdout, stderr) = refused(dir, args);
assert_ne!(code, 0, "tix {args:?}");
assert!(
stderr.contains(expected),
"tix {args:?} names the condition `{expected}`: {stderr}"
);
assert!(
stderr.contains("error["),
"a refusal is rendered as a diagnostic: {stderr}"
);
assert!(!stdout.contains("total_minutes"), "no half-total: {stdout}");
assert_eq!(
tree(dir),
before,
"tix {args:?} wrote nothing, but the tree moved"
);
}
let before = tree(dir);
ok(
dir,
&[
"time",
"delete",
"1",
"--entry",
"e-a1",
"--reason",
"why",
"--confirm",
],
);
assert_ne!(
tree(dir),
before,
"the accepted delete must change the tree"
);
}
#[test]
fn c003_a_no_op_edit_appends_no_event_and_writes_nothing() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let before = tree(dir);
let events_before = time_events(&parsed(dir, 1)).len();
let stdout = ok(
dir,
&[
"time",
"edit",
"1",
"--entry",
"e-a1",
"--minutes",
"90",
"--reason",
"no change",
],
);
assert!(stdout.starts_with("Unchanged"), "{stdout}");
assert_eq!(tree(dir), before, "a no-op edit writes nothing");
assert_eq!(time_events(&parsed(dir, 1)).len(), events_before);
let changed = ok(
dir,
&[
"time",
"edit",
"1",
"--entry",
"e-a1",
"--minutes",
"91",
"--reason",
"recount",
],
);
assert!(changed.starts_with("Updated"), "{changed}");
assert_ne!(tree(dir), before);
assert_eq!(time_events(&parsed(dir, 1)).len(), events_before + 1);
}
#[test]
fn c005_nothing_unknown_and_a_recorded_zero_are_three_distinct_answers() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let nothing = ok(dir, &["time", "total", "--issue", "3"]);
let unknown = ok(dir, &["time", "total", "--issue", "2"]);
let zero = ok(dir, &["time", "total", "--issue", "4"]);
assert_eq!(block(¬hing)["state"], "nothing");
assert_eq!(block(¬hing)["total_minutes"], "-");
assert_eq!(block(&unknown)["state"], "unknown");
assert_eq!(block(&unknown)["total_minutes"], "-");
assert_eq!(block(&zero)["state"], "recorded");
assert_eq!(block(&zero)["total_minutes"], "0");
let states = [
block(¬hing)["state"].clone(),
block(&unknown)["state"].clone(),
block(&zero)["state"].clone(),
];
assert_ne!(states[0], states[1]);
assert_ne!(states[1], states[2]);
assert_ne!(states[0], states[2]);
assert_eq!(opt_i128(&block(&zero)["total_minutes"]), Some(0));
assert_eq!(opt_i128(&block(¬hing)["total_minutes"]), None);
assert_eq!(opt_i128(&block(&unknown)["total_minutes"]), None);
let (_config, snapshot) = snapshot(&_tmp);
assert_eq!(
snapshot.total(&TimeFilter::new().for_issue(3)).state(),
TimeTotalState::Nothing
);
assert_eq!(
snapshot.total(&TimeFilter::new().for_issue(2)).state(),
TimeTotalState::Unknown
);
assert_eq!(
snapshot
.total(&TimeFilter::new().for_issue(4))
.total_minutes,
Some(0)
);
}
#[test]
fn c005_a_malformed_entry_blocks_no_unrelated_read() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let unrelated = ok(dir, &["time", "total", "--issue", "1"]);
assert_eq!(block(&unrelated)["state"], "recorded");
assert_eq!(block(&unrelated)["malformed_count"], "0");
let (code, all_stdout, all_stderr) = run(dir, &["time", "total"]);
assert_eq!(code, 0, "the read still succeeds: {all_stderr}");
assert_eq!(block(&all_stdout)["malformed_count"], "1");
assert_eq!(block(&all_stdout)["state"], "recorded");
assert!(
all_stderr.contains("invalid_duration") && all_stderr.contains("duration_minutes"),
"the finding names the field: {all_stderr}"
);
assert!(
all_stderr.contains("#2"),
"the finding names the issue: {all_stderr}"
);
let malformed = ok(dir, &["time", "total", "--issue", "2"]);
assert_eq!(block(&malformed)["state"], "unknown");
assert_eq!(block(&malformed)["total_minutes"], "-");
}
#[test]
fn fr005_c004_the_audit_is_readable_and_history_is_append_only() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let history = ok(dir, &["time", "history", "1"]);
assert_eq!(block(&history)["current"], "3");
assert_eq!(block(&history)["deleted_count"], "1");
assert_eq!(block(&history)["corrected_count"], "1");
assert!(
history.contains("e-old"),
"the deleted id is retained: {history}"
);
assert!(
history.contains("bob"),
"the deleting actor is retained: {history}"
);
let file = dir.join(issue_filename(1));
let before_lines = history_lines(&fs::read_to_string(&file).unwrap());
ok(
dir,
&[
"time",
"edit",
"1",
"--entry",
"e-a2",
"--minutes",
"45",
"--reason",
"recount",
],
);
let after_lines = history_lines(&fs::read_to_string(&file).unwrap());
assert_eq!(after_lines.len(), before_lines.len() + 1);
assert_eq!(after_lines[..before_lines.len()], before_lines[..]);
let after = ok(dir, &["time", "history", "1"]);
assert!(after.contains("e-old"), "{after}");
assert_eq!(block(&after)["corrected_count"], "2");
}
#[test]
fn fr003_the_breakdowns_partition_the_selection() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let by_issue = ok(dir, &["time", "total", "--by", "issue"]);
let blocks = by_issue
.split("\n\n")
.map(block)
.collect::<Vec<BTreeMap<String, String>>>();
assert_eq!(blocks.len(), 5, "{by_issue}");
assert_eq!(blocks[0]["group"], "all");
let counted: usize = blocks[1..]
.iter()
.map(|group| group["entry_count"].parse::<usize>().unwrap())
.sum();
let malformed: usize = blocks[1..]
.iter()
.map(|group| group["malformed_count"].parse::<usize>().unwrap())
.sum();
assert_eq!(counted, blocks[0]["entry_count"].parse::<usize>().unwrap());
assert_eq!(
malformed,
blocks[0]["malformed_count"].parse::<usize>().unwrap()
);
let period = ok(
dir,
&[
"time",
"total",
"--from",
"2026-03-01",
"--to",
"2026-03-31",
],
);
assert_eq!(block(&period)["undated_count"], "1");
assert_eq!(block(&period)["state"], "recorded");
assert!(
period.lines().count() > 0 && block(&period)["total_minutes"] != "-",
"{period}"
);
}
#[test]
fn nfr003_fr004_the_cli_and_mcp_text_channels_are_byte_identical() {
let (tmp, dir) = fixture();
let dir = dir.as_path();
let mut mcp = Mcp::start(tmp.path());
let cases: &[(&[&str], &str, Value)] = &[
(&["time", "show", "1"], "time_show", json!({"issue": "1"})),
(
&["time", "history", "1"],
"time_history",
json!({"issue": "1"}),
),
(&["time", "total"], "time_total", json!({})),
(
&["time", "total", "--issue", "2"],
"time_total",
json!({"issue": "2"}),
),
(
&["time", "total", "--issue", "4"],
"time_total",
json!({"issue": "4"}),
),
(
&["time", "total", "--assignee", "bob"],
"time_total",
json!({"assignee": "bob"}),
),
(
&[
"time",
"total",
"--from",
"2026-03-01",
"--to",
"2026-03-31",
],
"time_total",
json!({"from": "2026-03-01", "to": "2026-03-31"}),
),
(
&["time", "total", "--audit"],
"time_total",
json!({"audit": true}),
),
];
for (args, tool, tool_args) in cases {
let stdout = ok(dir, args);
let response = mcp.call(tool, tool_args);
assert_eq!(
text(&response),
stdout.as_str(),
"MCP `{tool}` and `tix {args:?}` must be the same bytes"
);
}
}
#[test]
fn nfr003_fr002_fr003_the_shared_calculation_is_one_number_on_every_surface() {
let (tmp, dir) = fixture();
let dir = dir.as_path();
let (config, snapshot) = snapshot(&tmp);
let mut mcp = Mcp::start(tmp.path());
let scopes: &[(&[&str], Value, TimeFilter)] = &[
(&["time", "total"], json!({}), TimeFilter::new()),
(
&["time", "total", "--issue", "1"],
json!({"issue": "1"}),
TimeFilter::new().for_issue(1),
),
(
&["time", "total", "--issue", "2"],
json!({"issue": "2"}),
TimeFilter::new().for_issue(2),
),
(
&["time", "total", "--issue", "3"],
json!({"issue": "3"}),
TimeFilter::new().for_issue(3),
),
(
&["time", "total", "--issue", "4"],
json!({"issue": "4"}),
TimeFilter::new().for_issue(4),
),
(
&["time", "total", "--assignee", "ada"],
json!({"assignee": "ada"}),
TimeFilter::new().for_assignee("ada"),
),
(
&[
"time",
"total",
"--from",
"2026-03-01",
"--to",
"2026-03-31",
],
json!({"from": "2026-03-01", "to": "2026-03-31"}),
TimeFilter::new().between("2026-03-01", "2026-03-31"),
),
];
for (args, tool_args, filter) in scopes {
let stdout = ok(dir, args);
let fields = cli_fields(&stdout);
let shared = shared_fields(&snapshot.total(filter));
assert_eq!(fields, shared, "CLI vs the shared snapshot for {args:?}");
let response = mcp.call("time_total", tool_args);
let payload = structured(&response);
assert_eq!(payload["state"], json!(shared.0), "MCP state for {args:?}");
match shared.1 {
None => assert!(
payload["total_minutes"].is_null(),
"MCP total is null for {args:?}: {payload}"
),
Some(minutes) => assert_eq!(
payload["total_minutes"].as_i64(),
Some(i64::try_from(minutes).unwrap()),
"MCP total for {args:?}"
),
}
assert_eq!(
usize::try_from(payload["entry_count"].as_u64().unwrap()).unwrap(),
shared.2,
"MCP entry_count for {args:?}"
);
let document = PortableTimeDocument::build(&config, &snapshot, filter)
.expect("a valid scope builds a document");
assert_eq!(portable_fields(&document), shared, "portable for {args:?}");
let mut state = StatsTimeState::new(snapshot.clone());
state.filter = filter.clone();
assert_eq!(
shared_fields(&state.total()),
shared,
"the TUI tab's total for {args:?}"
);
}
}
#[test]
fn fr004_c006_the_entries_and_their_absent_values_agree() {
let (tmp, dir) = fixture();
let dir = dir.as_path();
let (config, snapshot) = snapshot(&tmp);
let mut mcp = Mcp::start(tmp.path());
let stdout = ok(dir, &["time", "show", "1"]);
let response = mcp.call("time_show", &json!({"issue": "1"}));
assert_eq!(text(&response), stdout.as_str());
assert!(
stdout.contains("e-a1\t90\t2026-03-01\tada\tshipped"),
"{stdout}"
);
assert!(stdout.contains("e-a2\t30\t-\tada\t-"), "{stdout}");
assert!(stdout.contains("e-a0\t0\t2026-03-02\tada\t-"), "{stdout}");
let malformed = ok(dir, &["time", "show", "2"]);
assert!(malformed.contains("e-b1\t-\t-\tbob\t-"), "{malformed}");
let response = mcp.call("time_show", &json!({"issue": "2"}));
assert_eq!(text(&response), malformed.as_str());
let entry = &structured(&response)["entries"][0];
assert_eq!(entry["duration_minutes"], Value::Null);
assert_eq!(entry["id"], json!("e-b1"));
let document = PortableTimeDocument::build(&config, &snapshot, &TimeFilter::new().for_issue(2))
.expect("a valid scope");
let entry = &document.issues[0].entries[0];
assert_eq!(entry.id, "e-b1");
assert_eq!(entry.duration_minutes, None);
assert_eq!(entry.minutes, None);
assert!(!entry.valid);
}
#[test]
fn nfr003_fr005_the_audit_agrees_and_a_deletion_never_rewrites_history() {
let (tmp, dir) = fixture();
let dir = dir.as_path();
let (config, snapshot) = snapshot(&tmp);
let mut mcp = Mcp::start(tmp.path());
let stdout = ok(dir, &["time", "history", "1"]);
let response = mcp.call("time_history", &json!({"issue": "1"}));
assert_eq!(text(&response), stdout.as_str());
let audit = snapshot.audit(&TimeFilter::new().for_issue(1));
assert_eq!(audit.deleted_count, 1);
assert_eq!(audit.corrected_count, 1);
assert_eq!(
block(&stdout)["deleted_count"],
audit.deleted_count.to_string()
);
assert_eq!(
block(&stdout)["corrected_count"],
audit.corrected_count.to_string()
);
assert_eq!(structured(&response)["deleted_count"], json!(1));
let document = PortableTimeDocument::build(&config, &snapshot, &TimeFilter::new().for_issue(1))
.expect("a valid scope");
assert_eq!(document.audit.deleted_count, audit.deleted_count);
assert_eq!(document.audit.corrected_count, audit.corrected_count);
let row = &document.issues[0];
assert_eq!(row.deletions.len(), 1);
assert_eq!(row.deletions[0].entry_id.as_deref(), Some("e-old"));
assert_eq!(row.deletions[0].deleted_by.as_deref(), Some("bob"));
let state = StatsTimeState::new(snapshot.clone());
let view = state.audit_view();
assert_eq!(view.deleted_count, audit.deleted_count);
assert_eq!(view.corrected_count, audit.corrected_count);
let file = dir.join(issue_filename(1));
let before_lines = history_lines(&fs::read_to_string(&file).unwrap());
ok(
dir,
&[
"time",
"delete",
"1",
"--entry",
"e-a2",
"--reason",
"duplicate",
"--confirm",
],
);
let after_lines = history_lines(&fs::read_to_string(&file).unwrap());
assert_eq!(after_lines.len(), before_lines.len() + 1);
assert_eq!(after_lines[..before_lines.len()], before_lines[..]);
let issue = parsed(dir, 1);
assert!(!issue.time_entries.iter().any(|entry| entry.id == "e-a2"));
assert!(time_events(&issue).iter().any(|(kind, detail)| *kind
== HistoryEventKind::TimeEntryDeleted
&& detail.contains("30m")));
}
#[test]
fn fr002_c006_the_tui_time_tab_renders_the_shared_numbers() {
let (tmp, dir) = fixture();
let config = config(&tmp);
let mut app = App::new(&config).expect("the app opens");
open_time_tab(&mut app);
let (_config, snapshot) = snapshot(&tmp);
let shared = shared_fields(&snapshot.total(&TimeFilter::new()));
assert_eq!(shared.0, "recorded");
assert_eq!(shared.1, Some(120));
assert_eq!(shared.2, 4);
assert_eq!(shared.3, 1);
let before = tree(&dir);
let rows = render_rows(&app, 200, 50).join("\n");
assert!(rows.contains("total_minutes: 120"), "{rows}");
assert!(rows.contains("state: recorded"), "{rows}");
assert!(rows.contains("e-a1"), "the entry rows are drawn: {rows}");
assert_eq!(tree(&dir), before, "rendering the tab writes nothing");
let mut state = StatsTimeState::new(snapshot.clone());
state.filter = TimeFilter::new().for_issue(3);
assert_eq!(state.total().state(), TimeTotalState::Nothing);
let mut app = App::new(&config).unwrap();
open_time_tab(&mut app);
if let Some(state) = app.stats_time.as_mut() {
state.filter = TimeFilter::new().for_issue(3);
}
let rows = render_rows(&app, 200, 50).join("\n");
assert!(rows.contains("state: nothing"), "{rows}");
assert!(rows.contains("total_minutes: -"), "{rows}");
}
#[test]
fn c007_fr004_the_portable_document_round_trips() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let raw = ok(dir, &["export", "--target", "time"]);
let written: Value = serde_json::from_str(&raw).expect("one JSON document");
let read_back =
PortableTimeDocument::from_json(&raw).expect("this build re-reads what it wrote");
let plain = ok(dir, &["time", "total"]);
assert_eq!(read_back.total.state, block(&plain)["state"]);
assert_eq!(
read_back.total.total_minutes,
opt_i128(&block(&plain)["total_minutes"])
);
assert_eq!(
read_back.total.entry_count.to_string(),
block(&plain)["entry_count"]
);
assert_eq!(written["format"], json!("tix-time"));
assert_eq!(written["version"], json!(1));
let alpha = read_back
.issues
.iter()
.find(|issue| issue.number == 1)
.expect("issue #1 is a row");
let ids: Vec<&str> = alpha
.entries
.iter()
.map(|entry| entry.id.as_str())
.collect();
assert_eq!(ids, vec!["e-a1", "e-a0", "e-a2"]);
let again = PortableTimeDocument::from_json(&read_back.to_json_pretty().unwrap()).unwrap();
assert_eq!(again.to_json().unwrap(), read_back.to_json().unwrap());
}
fn memory_issue(number: u32, count: usize) -> String {
let mut items = String::new();
for index in 0..count {
items.push_str(&format!(
"- id: e-{number}-{index}\n duration_minutes: 7\n author: \"ada\"\n"
));
}
format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n\
time_entries:\n{items}---\n\nBody.\n"
)
}
#[test]
fn nfr001_totals_over_ten_thousand_entries_are_structural_and_linear() {
fn run(issue_count: u32, per_issue: usize) -> (TimeSnapshot, TimeTotal, Duration) {
let issues: Vec<Issue> = (1..=issue_count)
.map(|number| {
Issue::parse(&memory_issue(number, per_issue)).expect("the in-memory issue parses")
})
.collect();
let snapshot = TimeSnapshot::of(&issues);
let started = Instant::now();
let total = snapshot.total(&TimeFilter::new());
(snapshot, total, started.elapsed())
}
let (small, small_total, small_elapsed) = run(200, 50);
assert_eq!(
small.entries().len(),
10_000,
"the snapshot indexed every entry"
);
assert_eq!(
small_total.entry_count, 10_000,
"the total counted every entry"
);
assert_eq!(small_total.total_minutes, Some(70_000));
assert_eq!(small_total.malformed_count, 0);
println!(
"NFR-001: 10,000 entries aggregated in {small_elapsed:?} (excluded: file I/O; not asserted)"
);
let (large, large_total, large_elapsed) = run(400, 50);
assert_eq!(large.entries().len(), 20_000);
assert_eq!(large_total.entry_count, small_total.entry_count * 2);
assert_eq!(
large_total.total_minutes,
small_total.total_minutes.map(|minutes| minutes * 2)
);
println!(
"NFR-001: 20,000 entries aggregated in {large_elapsed:?} (doubling control; not asserted)"
);
}
#[test]
fn c001_c008_reads_write_nothing_and_a_mutation_touches_one_issue() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let before = tree(dir);
for args in [
&["time", "show", "1"][..],
&["time", "history", "1"][..],
&["time", "total"][..],
&["time", "total", "--by", "issue"][..],
&["time", "total", "--by", "assignee"][..],
&["time", "total", "--by", "date"][..],
&["time", "total", "--audit"][..],
&["export", "--target", "time"][..],
] {
let _ = ok(dir, args);
assert_eq!(tree(dir), before, "`tix {args:?}` wrote something");
}
ok(
dir,
&["time", "add", "2", "--minutes", "10", "--author", "bob"],
);
assert_ne!(tree(dir), before, "a mutation must change the tree");
let after = tree(dir);
for number in [1, 3, 4] {
let file = issue_filename(number);
assert_eq!(after[&file], before[&file], "issue #{number} is untouched");
}
assert_ne!(after[&issue_filename(2)], before[&issue_filename(2)]);
}
#[test]
fn c001_an_invalid_scope_is_refused_and_writes_nothing() {
let (_tmp, dir) = fixture();
let dir = dir.as_path();
let before = tree(dir);
let (code, stdout, stderr) = refused(dir, &["time", "total", "--from", "not-a-date"]);
assert_ne!(code, 0);
assert!(stderr.contains("invalid_date"), "{stderr}");
assert!(stderr.contains("help:"), "{stderr}");
assert!(!stdout.contains("total_minutes"), "{stdout}");
assert_eq!(tree(dir), before);
}