use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
use tix::model::{HistoryEventKind, Issue};
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 issue_markdown(number: u32, assignee: &str, entries: &str, history: &str) -> String {
format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"in progress\"\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(dir: &Path, number: u32, assignee: &str, entries: &str) {
write_issue_with_history(dir, number, assignee, entries, "");
}
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 entry_line(id: &str, minutes: i64, date: Option<&str>, author: &str) -> String {
let mut out = format!("- id: {id}\n duration_minutes: {minutes}\n");
if let Some(date) = date {
out.push_str(&format!(" date: {date:?}\n"));
}
out.push_str(&format!(" author: {author:?}\n"));
out
}
fn one_entry(id: &str, minutes: i64, date: &str, author: &str) -> String {
format!(
"time_entries:\n{}",
entry_line(id, minutes, Some(date), author)
)
}
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(entries) = fs::read_dir(¤t) else {
continue;
};
for entry in entries.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 parsed(dir: &Path, number: u32) -> Issue {
let text = fs::read_to_string(dir.join(issue_filename(number)))
.unwrap_or_else(|err| panic!("issue #{number} reads: {err}"));
Issue::parse(&text).unwrap_or_else(|err| panic!("issue #{number} parses: {err}"))
}
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 one_event(issue: &Issue, kind: HistoryEventKind) -> String {
let matching: Vec<String> = time_events(issue)
.into_iter()
.filter(|(found, _)| *found == kind)
.map(|(_, detail)| detail)
.collect();
assert_eq!(matching.len(), 1, "exactly one {kind}: {matching:#?}");
matching.into_iter().next().unwrap()
}
fn added_id(stdout: &str) -> String {
let line = stdout.lines().next().unwrap_or_default();
line.split_whitespace()
.nth(1)
.unwrap_or_else(|| panic!("no entry id in {stdout:?}"))
.to_string()
}
fn keys(stdout: &str) -> Vec<&str> {
stdout
.lines()
.filter_map(|line| line.split_once(':'))
.map(|(key, _)| key)
.collect()
}
#[test]
fn add_edit_and_delete_round_trip_through_the_shared_service() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(dir, 1, "@ada", "time_entries: []\n");
let added = ok(
dir,
&[
"time",
"add",
"1",
"--minutes",
"90",
"--date",
"2026-09-25",
"--author",
"alice",
"--note",
"Pairing and review",
],
);
let id = added_id(&added);
assert_eq!(
added,
format!("Added {id} to #1: 90m on 2026-09-25 by alice: Pairing and review\n")
);
let issue = parsed(dir, 1);
assert_eq!(
time_events(&issue).len(),
1,
"one event for one create: {:#?}",
time_events(&issue)
);
let created = one_event(&issue, HistoryEventKind::TimeEntryCreated);
assert!(
created.contains(&format!("entry {id}; created 90m on 2026-09-25 by alice")),
"{created}"
);
assert!(
!created.contains("reason:"),
"a create needs no reason: {created}"
);
assert_eq!(issue.time_entries.len(), 1);
assert_eq!(issue.time_entries[0].id, id);
assert_eq!(issue.time_entries[0].minutes(), Some(90));
assert_eq!(issue.time_entries[0].date.as_deref(), Some("2026-09-25"));
assert_eq!(issue.time_entries[0].author, "alice");
assert_eq!(
issue.time_entries[0].note.as_deref(),
Some("Pairing and review")
);
assert!(issue.status.is("in progress"));
assert_eq!(issue.assignee.as_deref(), Some("ada"));
assert_eq!(issue.estimate.as_deref(), Some("3d"));
assert_eq!(issue.title, "Issue 1");
assert_eq!(
ok(dir, &["time", "show", "1"]),
format!(
"id\tminutes\tdate\tauthor\tnote\n\
{id}\t90\t2026-09-25\talice\tPairing and review\n\
group: all\n\
scope: issue #1\n\
state: recorded\n\
total_minutes: 90\n\
entry_count: 1\n\
malformed_count: 0\n\
undated_count: 0\n\
deleted_count: -\n"
)
);
assert_eq!(
ok(
dir,
&[
"time",
"edit",
"1",
"--entry",
&id,
"--minutes",
"120",
"--clear-note",
"--reason",
"correction: paired twice",
],
),
format!(
"Updated {id} on #1: 90m on 2026-09-25 by alice: Pairing and review -> \
120m on 2026-09-25 by alice\n"
)
);
let issue = parsed(dir, 1);
assert_eq!(
time_events(&issue).len(),
2,
"one event per applied mutation"
);
let updated = one_event(&issue, HistoryEventKind::TimeEntryUpdated);
assert!(updated.contains("correction: paired twice"), "{updated}");
assert!(updated.contains("Pairing and review"), "{updated}");
assert_eq!(issue.time_entries[0].minutes(), Some(120));
assert_eq!(
issue.time_entries[0].date.as_deref(),
Some("2026-09-25"),
"the date survived"
);
assert_eq!(issue.time_entries[0].note, None, "the note was cleared");
assert_eq!(
ok(dir, &["time", "history", "1"]),
"scope: issue #1\n\
current: 1\n\
deleted_count: 0\n\
corrected_count: 1\n\
entry\tdeleted_at\tdeleted_by\n"
);
assert_eq!(
ok(
dir,
&[
"time",
"delete",
"1",
"--entry",
&id,
"--reason",
"wrong issue",
"--confirm"
],
),
format!("Deleted {id} from #1: 120m on 2026-09-25 by alice\n")
);
let issue = parsed(dir, 1);
assert_eq!(time_events(&issue).len(), 3, "created, updated, deleted");
let deleted = one_event(&issue, HistoryEventKind::TimeEntryDeleted);
assert!(deleted.contains("wrong issue"), "{deleted}");
assert!(deleted.contains("120m on 2026-09-25 by alice"), "{deleted}");
assert_eq!(entry_id_in(&deleted), Some(id.as_str()));
assert!(
issue.time_entries.is_empty(),
"the entry left the projection"
);
let history = ok(dir, &["time", "history", "1"]);
let lines: Vec<&str> = history.lines().collect();
assert_eq!(
&lines[..5],
&[
"scope: issue #1",
"current: 0",
"deleted_count: 1",
"corrected_count: 1",
"entry\tdeleted_at\tdeleted_by",
]
);
let row: Vec<&str> = lines[5].split('\t').collect();
assert_eq!(row.len(), 3, "entry, deleted_at, deleted_by: {history}");
assert_eq!(row[0], id);
assert!(
row[1].ends_with('Z') && row[1].contains('T'),
"an instant: {}",
row[1]
);
assert!(!row[2].is_empty(), "an actor: {history}");
assert_eq!(
ok(dir, &["time", "show", "1"]),
"id\tminutes\tdate\tauthor\tnote\n\
group: all\n\
scope: issue #1\n\
state: nothing\n\
total_minutes: -\n\
entry_count: 0\n\
malformed_count: 0\n\
undated_count: 0\n\
deleted_count: -\n"
);
}
#[test]
fn an_edit_that_names_no_field_and_an_edit_with_no_reason_are_both_refused() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(
dir,
1,
"@ada",
&one_entry("e-one", 30, "2026-01-02", "alice"),
);
let before = tree(dir);
let (_, _, stderr) = refused(
dir,
&["time", "edit", "1", "--entry", "e-one", "--minutes", "45"],
);
assert!(stderr.contains("missing_reason"), "{stderr}");
assert!(stderr.contains("--reason"), "{stderr}");
assert_eq!(tree(dir), before, "a refusal writes nothing");
let (_, _, stderr) = refused(
dir,
&["time", "edit", "1", "--entry", "e-one", "--reason", "why"],
);
assert!(stderr.contains("empty_edit"), "{stderr}");
assert_eq!(tree(dir), before, "a refusal writes nothing");
let (_, _, stderr) = refused(
dir,
&[
"time",
"edit",
"1",
"--entry",
"e-nope",
"--minutes",
"45",
"--reason",
"why",
],
);
assert!(stderr.contains("unknown_entry"), "{stderr}");
assert!(stderr.contains("e-nope"), "{stderr}");
assert_eq!(tree(dir), before, "a refusal writes nothing");
assert_eq!(
ok(
dir,
&[
"time",
"edit",
"1",
"--entry",
"e-one",
"--minutes",
"30",
"--reason",
"same"
]
),
"Unchanged e-one on #1: 30m on 2026-01-02 by alice\n"
);
assert_eq!(tree(dir), before, "a no-op correction writes nothing");
}
#[test]
fn a_refused_mutation_leaves_the_tree_byte_identical() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(
dir,
1,
"@ada",
&one_entry("e-one", 30, "2026-01-02", "alice"),
);
let before = tree(dir);
let (code, stdout, stderr) = refused(
dir,
&[
"time", "delete", "1", "--entry", "e-one", "--reason", "typo",
],
);
assert!(stdout.is_empty(), "a refusal prints no result: {stdout:?}");
assert_eq!(code, 1);
assert!(stderr.contains("--confirm"), "{stderr}");
assert!(
stderr.contains("History"),
"the remedy names what survives: {stderr}"
);
let (_, _, stderr) = refused(
dir,
&["time", "delete", "1", "--entry", "e-one", "--confirm"],
);
assert!(stderr.contains("missing_reason"), "{stderr}");
let (_, _, stderr) = refused(
dir,
&[
"time",
"delete",
"1",
"--entry",
"e-nope",
"--reason",
"typo",
"--confirm",
],
);
assert!(stderr.contains("unknown_entry"), "{stderr}");
assert!(stderr.contains("e-nope"), "{stderr}");
let (_, _, stderr) = refused(
dir,
&["time", "add", "1", "--minutes", "-5", "--author", "alice"],
);
assert!(stderr.contains("invalid_duration"), "{stderr}");
let (_, _, stderr) = refused(
dir,
&[
"time",
"add",
"1",
"--minutes",
"5",
"--author",
"alice",
"--date",
"2026-99-99",
],
);
assert!(stderr.contains("invalid_date"), "{stderr}");
let (_, _, stderr) = refused(
dir,
&["time", "add", "1", "--minutes", "5", "--author", " "],
);
assert!(stderr.contains("missing_author"), "{stderr}");
assert_eq!(tree(dir), before, "every refusal left the tree untouched");
let deleted = ok(
dir,
&[
"time",
"delete",
"1",
"--entry",
"e-one",
"--reason",
"wrong issue",
"--confirm",
],
);
assert_eq!(
deleted,
"Deleted e-one from #1: 30m on 2026-01-02 by alice\n"
);
assert_ne!(tree(dir), before);
let deleted_event = one_event(&parsed(dir, 1), HistoryEventKind::TimeEntryDeleted);
assert!(
deleted_event.contains("30m on 2026-01-02 by alice"),
"{deleted_event}"
);
assert!(deleted_event.contains("wrong issue"), "{deleted_event}");
}
#[test]
fn totals_come_from_the_shared_calculation_under_every_filter() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(dir, 1, "@ada", &one_entry("e-a", 30, "2026-09-01", "alice"));
write_issue(
dir,
2,
"@bob",
&format!(
"time_entries:\n{first}{second}",
first = entry_line("e-b", 45, Some("2026-10-01"), "bob"),
second = entry_line("e-c", 15, None, "carol"),
),
);
assert_eq!(
ok(dir, &["time", "total"]),
"group: all\n\
scope: all\n\
state: recorded\n\
total_minutes: 90\n\
entry_count: 3\n\
malformed_count: 0\n\
undated_count: 0\n\
deleted_count: -\n"
);
assert_eq!(
ok(dir, &["time", "total", "--assignee", "alice"]),
"group: all\n\
scope: assignee @alice\n\
state: recorded\n\
total_minutes: 30\n\
entry_count: 1\n\
malformed_count: 0\n\
undated_count: 0\n\
deleted_count: -\n"
);
assert_eq!(
ok(
dir,
&[
"time",
"total",
"--from",
"2026-09-01",
"--to",
"2026-09-30"
]
),
"group: all\n\
scope: period 2026-09-01..2026-09-30\n\
state: recorded\n\
total_minutes: 30\n\
entry_count: 1\n\
malformed_count: 0\n\
undated_count: 1\n\
deleted_count: -\n"
);
assert_eq!(
ok(dir, &["time", "total", "--by", "issue"]),
"group: all\n\
scope: all\n\
state: recorded\n\
total_minutes: 90\n\
entry_count: 3\n\
malformed_count: 0\n\
undated_count: 0\n\
deleted_count: -\n\
\n\
group: #1\n\
scope: all\n\
state: recorded\n\
total_minutes: 30\n\
entry_count: 1\n\
malformed_count: 0\n\
undated_count: 0\n\
deleted_count: -\n\
\n\
group: #2\n\
scope: all\n\
state: recorded\n\
total_minutes: 60\n\
entry_count: 2\n\
malformed_count: 0\n\
undated_count: 0\n\
deleted_count: -\n"
);
let by_date = ok(dir, &["time", "total", "--by", "date"]);
assert_eq!(
by_date
.lines()
.filter(|line| line.starts_with("group:"))
.collect::<Vec<&str>>(),
[
"group: all",
"group: 2026-09-01",
"group: 2026-10-01",
"group: (undated)"
]
);
let by_assignee = ok(dir, &["time", "total", "--by", "assignee"]);
assert!(
by_assignee.contains("group: @alice") && by_assignee.contains("group: @bob"),
"{by_assignee}"
);
let scoped = ok(dir, &["time", "total", "--issue", "1", "--by", "all"]);
assert!(scoped.contains("scope: issue #1"), "{scoped}");
assert!(scoped.contains("total_minutes: 30"), "{scoped}");
assert_eq!(scoped.matches("group:").count(), 1, "{scoped}");
}
#[test]
fn an_absent_total_is_a_dash_and_never_a_zero() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(dir, 1, "@ada", "time_entries: []\n");
write_issue(
dir,
2,
"@ada",
&one_entry("e-zero", 0, "2026-01-02", "alice"),
);
write_issue(
dir,
3,
"@ada",
"time_entries:\n- id: e-bad\n duration_minutes: -5\n author: \"alice\"\n",
);
let nothing = ok(dir, &["time", "show", "1"]);
assert!(nothing.contains("state: nothing"), "{nothing}");
assert!(nothing.contains("total_minutes: -\n"), "{nothing}");
assert!(!nothing.contains("total_minutes: 0"), "{nothing}");
let (code, unknown, stderr) = run(dir, &["time", "show", "3"]);
assert_eq!(code, 0, "a partial read is a report, not a failure");
assert!(unknown.contains("state: unknown"), "{unknown}");
assert!(unknown.contains("total_minutes: -\n"), "{unknown}");
assert!(unknown.contains("malformed_count: 1"), "{unknown}");
assert!(unknown.contains("e-bad\t-\t-\talice\t-"), "{unknown}");
assert!(stderr.contains("warning[E-tix-time]"), "{stderr}");
assert!(stderr.contains("kind: invalid_duration"), "{stderr}");
assert!(stderr.contains("entry: e-bad"), "{stderr}");
assert!(stderr.contains("= help:"), "{stderr}");
let recorded = ok(dir, &["time", "show", "2"]);
assert!(recorded.contains("state: recorded"), "{recorded}");
assert!(recorded.contains("total_minutes: 0\n"), "{recorded}");
assert!(
recorded.contains("e-zero\t0\t2026-01-02\talice\t-"),
"{recorded}"
);
}
#[test]
fn a_malformed_entry_is_reported_without_blocking_another_issues_read() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(
dir,
1,
"@ada",
&one_entry("e-good", 60, "2026-09-05", "alice"),
);
write_issue(
dir,
2,
"@bob",
"time_entries:\n\
- id: e-bad\n duration_minutes: -5\n date: \"not-a-date\"\n author: \"\"\n",
);
let (code, clean, stderr) = run(dir, &["time", "show", "1"]);
assert_eq!(code, 0);
assert!(clean.contains("total_minutes: 60"), "{clean}");
assert!(
stderr.is_empty(),
"no finding belongs to a clean issue: {stderr}"
);
let (code, malformed, stderr) = run(dir, &["time", "show", "2"]);
assert_eq!(code, 0);
assert!(malformed.contains("malformed_count: 1"), "{malformed}");
assert!(stderr.contains("entry: e-bad"), "{stderr}");
let (code, total, stderr) = run(dir, &["time", "total"]);
assert_eq!(code, 0);
assert!(total.contains("total_minutes: 60"), "{total}");
assert!(total.contains("entry_count: 1"), "{total}");
assert!(total.contains("malformed_count: 1"), "{total}");
assert!(total.contains("state: recorded"), "{total}");
assert!(stderr.contains("issue: #2"), "{stderr}");
}
#[test]
fn the_audit_context_is_opt_in_and_never_added_to_the_total() {
let (_tmp, dir) = project();
let dir = dir.as_path();
let history = "## History\n\n\
- 2026-01-02T03:04:05Z \u{2014} alice \u{2014} time_entry_deleted \u{2014} \
entry e-old; deleted 300m on 2026-01-02 by alice\n\n";
write_issue_with_history(
dir,
1,
"@ada",
&one_entry("e-a", 30, "2026-09-01", "alice"),
history,
);
let plain = ok(dir, &["time", "total"]);
assert!(plain.contains("scope: all"), "{plain}");
assert!(plain.contains("deleted_count: -\n"), "{plain}");
assert!(plain.contains("total_minutes: 30"), "{plain}");
let audit = ok(dir, &["time", "total", "--audit"]);
assert!(audit.contains("scope: audit"), "{audit}");
assert!(audit.contains("deleted_count: 1"), "{audit}");
assert!(audit.contains("total_minutes: 30"), "{audit}");
assert!(audit.contains("entry_count: 1"), "{audit}");
assert!(
!audit.contains("330"),
"the deleted value is never summed: {audit}"
);
let history = ok(dir, &["time", "history", "1"]);
assert!(history.contains("deleted_count: 1"), "{history}");
assert!(
history.contains("e-old\t2026-01-02T03:04:05Z\talice"),
"{history}"
);
assert!(history.contains("current: 1"), "{history}");
}
#[test]
fn an_archived_issue_is_read_by_name_and_refused_as_a_project_scope() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(dir, 1, "@ada", &one_entry("e-a", 30, "2026-09-01", "alice"));
write_issue(
dir,
7,
"@ada",
&one_entry("e-arch", 90, "2026-09-02", "alice"),
);
ok(dir, &["archive", "7"]);
let before = tree(dir);
let (code, total, stderr) = run(dir, &["time", "total"]);
assert_eq!(code, 0, "{stderr}");
assert!(total.contains("total_minutes: 30"), "{total}");
assert!(total.contains("entry_count: 1"), "{total}");
assert!(
stderr.is_empty(),
"an archived issue is not a project-total finding: {stderr}"
);
let show = ok(dir, &["time", "show", "7"]);
assert!(show.contains("e-arch\t90\t2026-09-02\talice\t-"), "{show}");
assert!(show.contains("state: recorded"), "{show}");
assert!(show.contains("total_minutes: 90"), "{show}");
let history = ok(dir, &["time", "history", "7"]);
assert!(history.contains("current: 1"), "{history}");
for args in [
vec!["time", "total", "7"],
vec!["time", "total", "--issue", "7"],
] {
let (code, stdout, stderr) = refused(dir, &args);
assert_eq!(code, 1, "tix {args:?}");
assert!(stdout.is_empty(), "tix {args:?} printed a total: {stdout}");
assert!(stderr.starts_with("error[E-tix-time]:"), "{stderr}");
assert!(stderr.contains("kind: archived_issue"), "{stderr}");
assert!(stderr.contains("issue: #7"), "{stderr}");
assert!(stderr.contains("is archived"), "{stderr}");
assert!(stderr.contains("`tix time show 7`"), "{stderr}");
assert!(stderr.contains("`tix time history 7`"), "{stderr}");
}
let (code, _, stderr) = refused(dir, &["time", "total", "--issue", "404"]);
assert_eq!(code, 1, "{stderr}");
assert!(stderr.contains("kind: unknown_issue"), "{stderr}");
assert_eq!(tree(dir), before, "the refusals wrote nothing");
}
#[test]
fn deleting_a_malformed_entry_prints_the_surfaces_absent_mark() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(
dir,
3,
"@ada",
"time_entries:\n- id: e-bad\n duration_minutes: -5\n author: \"\"\n",
);
let deleted = ok(
dir,
&[
"time",
"delete",
"3",
"--entry",
"e-bad",
"--reason",
"malformed",
"--confirm",
],
);
assert_eq!(deleted, "Deleted e-bad from #3: - on - by -\n");
assert!(!deleted.contains("?m"), "{deleted}");
assert!(!deleted.contains("by ?"), "{deleted}");
let issue = parsed(dir, 3);
assert!(
issue.time_entries.is_empty(),
"the malformed entry left the projection"
);
assert_eq!(time_events(&issue).len(), 1, "one deletion event");
let added = ok(
dir,
&["time", "add", "3", "--minutes", "10", "--author", "alice"],
);
assert_eq!(
added,
format!("Added {} to #3: 10m on - by alice\n", added_id(&added))
);
}
#[test]
fn a_refused_scope_says_what_is_wrong_and_what_to_do_and_writes_nothing() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(dir, 1, "@ada", &one_entry("e-a", 30, "2026-09-01", "alice"));
let before = tree(dir);
let cases: [(&[&str], &str, &str); 4] = [
(
&["time", "total", "--from", "2026-30-01"],
"invalid_date",
"is not a `YYYY-MM-DD` calendar date",
),
(
&[
"time",
"total",
"--from",
"2026-10-01",
"--to",
"2026-09-01",
],
"invalid_period",
"the period holds no date",
),
(
&["time", "total", "--assignee", " "],
"invalid_assignee",
"names no identity",
),
(
&["time", "total", "--issue", "99"],
"unknown_issue",
"is not among the issues this snapshot holds",
),
];
for (args, kind, reason) in cases {
let (code, stdout, stderr) = refused(dir, args);
assert_eq!(code, 1, "tix {args:?} should exit 1");
assert!(
stdout.is_empty(),
"tix {args:?} printed a total before refusing:\n{stdout}"
);
assert!(
stderr.starts_with("error[E-tix-time]:"),
"tix {args:?} should render an error block:\n{stderr}"
);
assert!(stderr.contains(&format!("kind: {kind}")), "{stderr}");
assert!(
stderr.contains(reason),
"the shared reason is the headline:\n{stderr}"
);
assert!(
stderr.contains("= help:"),
"the remedy is the help footer:\n{stderr}"
);
}
let (_, stdout, stderr) = refused(dir, &["time", "total", "--by", "sprint"]);
assert!(stdout.is_empty(), "{stdout:?}");
assert!(stderr.contains("unknown --by dimension"), "{stderr}");
assert!(
stderr.contains("`issue`, `assignee` and `date`"),
"{stderr}"
);
let (_, stdout, stderr) = refused(dir, &["time", "total", "1", "--issue", "2"]);
assert!(stdout.is_empty(), "{stdout:?}");
assert!(stderr.contains("names its issue once"), "{stderr}");
let (code, _, stderr) = refused(dir, &["time", "show", "99"]);
assert_eq!(code, 2, "{stderr}");
assert!(stderr.contains("issue #99"), "{stderr}");
assert_eq!(tree(dir), before, "no refused scope wrote anything");
}
#[test]
fn every_time_read_writes_nothing_and_leaves_the_other_commands_alone() {
let (_tmp, dir) = project();
let dir = dir.as_path();
write_issue(dir, 1, "@ada", &one_entry("e-a", 30, "2026-09-01", "alice"));
write_issue(dir, 2, "@bob", &one_entry("e-b", 45, "2026-10-01", "bob"));
let before = tree(dir);
for args in [
vec!["time", "show", "1"],
vec!["time", "history", "1"],
vec!["time", "total"],
vec!["time", "total", "--by", "assignee"],
vec!["time", "total", "--audit"],
vec!["time", "total", "2"],
vec!["list", "--all"],
vec!["view", "1"],
vec!["status"],
] {
ok(dir, &args);
assert_eq!(tree(dir), before, "tix {args:?} wrote something");
}
ok(
dir,
&["time", "add", "1", "--minutes", "10", "--author", "alice"],
);
let issue = parsed(dir, 1);
assert!(issue.status.is("in progress"));
assert_eq!(issue.assignee.as_deref(), Some("ada"));
assert_eq!(issue.estimate.as_deref(), Some("3d"));
assert_eq!(issue.title, "Issue 1");
assert!(issue.body.contains("Body prose that must survive."));
let list = ok(dir, &["list", "--all"]);
assert!(
list.contains("Issue 1") && list.contains("Issue 2"),
"{list}"
);
let view = ok(dir, &["view", "1"]);
assert!(view.contains("Issue 1"), "{view}");
let total = ok(dir, &["time", "total"]);
assert!(total.contains("total_minutes: 85"), "{total}");
assert!(total.contains("entry_count: 3"), "{total}");
assert_eq!(
keys(&total),
[
"group",
"scope",
"state",
"total_minutes",
"entry_count",
"malformed_count",
"undated_count",
"deleted_count"
]
);
}