use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tix::config::Config;
use tix::model::Issue;
use tix::storage::{IssueSelector, Store};
use tix::time_tracking::aggregate::{TimeGroup, TimeSnapshot, TimeTotal, TimeTotalState};
use tix::time_tracking::diagnostics::TimeDiagnostic;
use tix::time_tracking::entry::{TimeEntry, TimeEntryExtensions};
use tix::time_tracking::filters::{PeriodPlacement, TimeFilter, TimeFilterError, identity_cmp};
use tix::time_tracking::service::{NewTimeEntry, TimeEntryEdit, TimeMutationRequest, apply};
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]\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 store_of(tmp: &TempDir) -> Store {
let config = Config::load(tmp.path()).expect("the fixture loads");
Store::open(&config).expect("the store opens")
}
fn issue_filename(number: u32) -> String {
format!("2026-01-01-issue-{number}.md")
}
fn issue_markdown(number: u32, entries: &str, history: &[String]) -> String {
let mut markdown = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"in progress\"\n\
labels: []\npriority: \"medium\"\nassignee: \"bob\"\ncreated: \"2026-01-01\"\n\
updated: \"2026-01-01\"\n{entries}---\n\n"
);
if !history.is_empty() {
markdown.push_str("## History\n\n");
for line in history {
markdown.push_str(line);
markdown.push('\n');
}
markdown.push('\n');
}
markdown.push_str("Body prose that must survive.\n");
markdown
}
fn write_issue(dir: &Path, number: u32, entries: &str, history: &[String]) {
fs::write(
dir.join(issue_filename(number)),
issue_markdown(number, entries, history),
)
.unwrap();
}
fn issue_of(store: &Store, number: u32) -> Issue {
store
.get(IssueSelector::Number(number))
.expect("the issue reads")
}
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 parsed_issue(number: u32, entries: &str, history: &[String]) -> Issue {
Issue::parse(&issue_markdown(number, entries, history)).expect("the fixture parses")
}
fn entry(id: &str, minutes: i64, date: Option<&str>, author: &str) -> TimeEntry {
TimeEntry::new(id, minutes, date.map(str::to_string), author, None)
.expect("the fixture entry is valid")
}
fn hand_made(
id: &str,
minutes: Option<i64>,
date: Option<&str>,
author: &str,
note: Option<&str>,
) -> TimeEntry {
TimeEntry {
id: id.into(),
duration_minutes: minutes,
date: date.map(str::to_string),
author: author.into(),
note: note.map(str::to_string),
extensions: TimeEntryExtensions::default(),
}
}
fn memory_issue(number: u32, entries: Vec<TimeEntry>) -> Issue {
let mut issue = parsed_issue(number, "time_entries: []\n", &[]);
issue.time_entries = entries;
issue
}
fn findings(total: &TimeTotal) -> Vec<(&'static str, Option<&'static str>)> {
total
.diagnostics
.iter()
.map(|finding| (finding.kind.as_str(), finding.field))
.collect()
}
fn bytes(total: &TimeTotal) -> String {
let rendered: Vec<String> = total
.diagnostics
.iter()
.map(TimeDiagnostic::render)
.collect();
format!("{total:?}\n{}", rendered.join("\n"))
}
#[test]
fn c005_fr002_a_malformed_entry_is_excluded_counted_and_reported_never_zero() {
let issue = parsed_issue(
3,
&format!(
"time_entries:\n{}{}{}",
entry_line("e-ok", 90, Some("2026-03-01"), "alice"),
entry_line("e-bad", -5, Some("2026-03-02"), "alice"),
entry_line("e-ok2", 30, Some("2026-03-03"), "@alice"),
),
&[],
);
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
let total = snapshot.total(&TimeFilter::new());
assert_eq!(
total.total_minutes,
Some(120),
"the valid entries sum, and the malformed one contributes no number"
);
assert_ne!(
total.total_minutes,
Some(115),
"a `-5` duration must never be added as `-5`"
);
assert_eq!(total.entry_count, 2);
assert_eq!(total.malformed_count, 1);
assert!(
total.is_partial(),
"a total that dropped an entry must say so"
);
assert_eq!(total.state(), TimeTotalState::Recorded);
assert_eq!(
findings(&total),
vec![("invalid_duration", Some("duration_minutes"))],
"the excluded entry is reported, with its field"
);
let finding = &total.diagnostics[0];
assert_eq!(finding.issue, Some(3));
assert_eq!(finding.entry_id.as_deref(), Some("e-bad"));
assert_eq!(
finding.reason,
"duration_minutes -5 is negative; write a non-negative number of whole minutes"
);
assert!(
!finding.remedy.is_empty(),
"a finding carries what to do about it"
);
assert!(
finding.render().contains("help:"),
"the rendered finding carries the remedy: {}",
finding.render()
);
}
#[test]
fn c005_a_scope_whose_every_entry_is_malformed_reports_unknown_not_zero() {
let issue = memory_issue(
3,
vec![
hand_made("e-1", Some(-5), Some("2026-03-01"), "alice", None),
hand_made("e-2", None, Some("2026-03-02"), "alice", None),
],
);
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
let total = snapshot.total(&TimeFilter::new());
assert_eq!(
total.total_minutes, None,
"nothing usable was counted, so there is no total — not a zero one"
);
assert_eq!(total.entry_count, 0);
assert_eq!(total.malformed_count, 2);
assert_eq!(total.state(), TimeTotalState::Unknown);
assert_eq!(
findings(&total),
vec![
("invalid_duration", Some("duration_minutes")),
("invalid_duration", Some("duration_minutes")),
]
);
}
#[test]
fn c005_an_empty_scope_says_nothing_where_a_recorded_zero_says_zero() {
let with_zero = parsed_issue(
3,
&format!(
"time_entries:\n{}",
entry_line("e-zero", 0, Some("2026-03-01"), "alice")
),
&[],
);
let with_nothing = parsed_issue(4, "time_entries: []\n", &[]);
let snapshot = TimeSnapshot::of(&[with_zero, with_nothing]);
let recorded_zero = snapshot.total(&TimeFilter::new().for_issue(3));
let empty = snapshot.total(&TimeFilter::new().for_issue(4));
assert_eq!(recorded_zero.total_minutes, Some(0));
assert_eq!(recorded_zero.entry_count, 1);
assert_eq!(recorded_zero.state(), TimeTotalState::Recorded);
assert_eq!(
empty.total_minutes, None,
"an issue with no entry recorded nothing, which is not `0m`"
);
assert_eq!(empty.entry_count, 0);
assert_eq!(empty.malformed_count, 0);
assert_eq!(empty.state(), TimeTotalState::Nothing);
assert_ne!(
empty.state(),
recorded_zero.state(),
"the empty scope and the recorded zero must not be readable as the same state"
);
assert!(
empty.diagnostics.is_empty(),
"nothing recorded is not a finding"
);
}
#[test]
fn c005_each_malformed_field_is_diagnosed_with_its_own_kind_and_field() {
let issue = memory_issue(
7,
vec![
hand_made("", Some(30), Some("2026-03-01"), "alice", None),
hand_made("e-neg", Some(-1), Some("2026-03-02"), "alice", None),
hand_made("e-date", Some(30), Some("2026-02-30"), "alice", None),
hand_made("e-author", Some(30), Some("2026-03-03"), "", None),
hand_made(
"e-note",
Some(30),
Some("2026-03-04"),
"alice",
Some("one\ntwo"),
),
],
);
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
let total = snapshot.total(&TimeFilter::new());
assert_eq!(total.malformed_count, 5);
assert_eq!(total.entry_count, 0);
assert_eq!(total.total_minutes, None);
assert_eq!(
findings(&total),
vec![
("invalid_date", Some("date")),
("malformed_entry", Some("id")),
("invalid_duration", Some("duration_minutes")),
("missing_author", Some("author")),
("malformed_entry", Some("note")),
],
"one finding per field problem, in the index's own entry order: `2026-02-30` \
sorts before the March dates"
);
let repaired = memory_issue(
7,
vec![
entry("e-id", 30, Some("2026-03-01"), "alice"),
entry("e-neg", 1, Some("2026-03-02"), "alice"),
entry("e-date", 30, Some("2026-02-28"), "alice"),
entry("e-author", 30, Some("2026-03-03"), "alice"),
entry("e-note", 30, Some("2026-03-04"), "alice"),
],
);
let clean = TimeSnapshot::of(std::slice::from_ref(&repaired)).total(&TimeFilter::new());
assert!(clean.diagnostics.is_empty());
assert_eq!(clean.entry_count, 5);
assert_eq!(clean.total_minutes, Some(121));
}
#[test]
fn t013_the_filter_combines_issue_assignee_and_period_with_and() {
let snapshot = TimeSnapshot::of(&[
parsed_issue(
3,
&format!(
"time_entries:\n{}{}",
entry_line("e-a1", 90, Some("2026-03-01"), "alice"),
entry_line("e-a2", 30, Some("2026-03-15"), "@Ada"),
),
&[],
),
parsed_issue(
4,
&format!(
"time_entries:\n{}{}",
entry_line("e-b1", 60, Some("2026-03-05"), "ada"),
entry_line("e-b2", 10, Some("2026-04-01"), "alice"),
),
&[],
),
]);
let everything = snapshot.total(&TimeFilter::new());
assert_eq!(everything.total_minutes, Some(190));
assert_eq!(everything.entry_count, 4);
let by_issue = snapshot.total(&TimeFilter::new().for_issue(4));
assert_eq!(by_issue.total_minutes, Some(70));
assert_eq!(by_issue.entry_count, 2);
let by_assignee = snapshot.total(&TimeFilter::new().for_assignee("@ada"));
assert_eq!(
by_assignee.total_minutes,
Some(90),
"`@ada` and `ada` are one identity, so both of their entries match"
);
assert_eq!(by_assignee.entry_count, 2);
let by_period = snapshot.total(&TimeFilter::new().between("2026-03-05", "2026-03-15"));
assert_eq!(
by_period.total_minutes,
Some(90),
"both bounds are inclusive: March 5 and March 15 are inside"
);
assert_eq!(by_period.entry_count, 2);
let all_three = TimeFilter::new()
.for_issue(3)
.for_assignee("ada")
.between("2026-03-01", "2026-03-31");
let narrowed = snapshot.total(&all_three);
assert_eq!(
narrowed.total_minutes,
Some(30),
"issue 3 AND ada AND March leaves only March 15"
);
assert_eq!(narrowed.entry_count, 1);
let without_issue = snapshot.total(&TimeFilter {
issue: None,
..all_three.clone()
});
assert_eq!(without_issue.total_minutes, Some(90));
assert!(without_issue.entry_count > narrowed.entry_count);
}
#[test]
fn t013_period_bounds_are_inclusive_and_an_undated_entry_is_counted_not_dropped() {
let issue = parsed_issue(
3,
&format!(
"time_entries:\n{}{}{}",
entry_line("e-before", 5, Some("2026-02-28"), "alice"),
entry_line("e-edge", 15, Some("2026-03-01"), "alice"),
entry_line("e-undated", 45, None, "alice"),
),
&[],
);
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
let filter = TimeFilter::new().since("2026-03-01");
let total = snapshot.total(&filter);
assert_eq!(
total.total_minutes,
Some(15),
"the inclusive lower bound includes March 1 and excludes February 28"
);
assert_eq!(total.entry_count, 1);
assert_eq!(
total.undated_count, 1,
"the undated entry is inside the scope but no period can place it, so it is counted"
);
assert!(
total.is_partial(),
"a total with an unplaceable entry is not the whole story"
);
assert_eq!(filter.place(Some("2026-03-01")), PeriodPlacement::InPeriod);
assert_eq!(
filter.place(Some("2026-02-28")),
PeriodPlacement::OutOfPeriod
);
assert_eq!(filter.place(None), PeriodPlacement::Undated);
assert_eq!(
filter.place(Some("2026-02-30")),
PeriodPlacement::Unreadable,
"a date the validator refuses is not read as `outside the period`"
);
assert_eq!(
TimeFilter::new().place(None),
PeriodPlacement::InPeriod,
"with no period there is nothing to fail to place"
);
let unbounded = snapshot.total(&TimeFilter::new());
assert_eq!(unbounded.total_minutes, Some(65));
assert_eq!(unbounded.undated_count, 0);
assert!(!unbounded.is_partial());
let undated_only = parsed_issue(
9,
&format!("time_entries:\n{}", entry_line("e-u", 45, None, "alice")),
&[],
);
let undated_snapshot = TimeSnapshot::of(std::slice::from_ref(&undated_only));
let undated_total = undated_snapshot.total(&TimeFilter::new().since("2026-03-01"));
assert_eq!(undated_total.total_minutes, None);
assert_eq!(undated_total.entry_count, 0);
assert_eq!(undated_total.undated_count, 1);
assert_eq!(undated_total.state(), TimeTotalState::Unknown);
assert_ne!(
undated_total.state(),
TimeTotalState::Nothing,
"`nothing` is reserved for a scope that selected no entry at all"
);
assert_eq!(
undated_snapshot.total(&TimeFilter::new()).total_minutes,
Some(45)
);
}
#[test]
fn t013_the_caller_supplies_the_dates_and_no_clock_is_read() {
let issue = parsed_issue(
3,
&format!(
"time_entries:\n{}{}",
entry_line("e-epoch", 10, Some("1970-01-01"), "alice"),
entry_line("e-far", 20, Some("2999-12-31"), "alice"),
),
&[],
);
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
assert_eq!(snapshot.total(&TimeFilter::new()).total_minutes, Some(30));
assert_eq!(
snapshot
.total(&TimeFilter::new().since("1970-01-01").until("1970-01-01"))
.total_minutes,
Some(10),
"the caller's one-day period selects the entry inside it"
);
assert_eq!(
snapshot
.total(&TimeFilter::new().since("2999-12-31"))
.total_minutes,
Some(20),
"and a bound in the far future is a bound like any other"
);
let between = snapshot.total(&TimeFilter::new().between("2000-01-01", "2000-12-31"));
assert_eq!(between.total_minutes, None);
assert_eq!(between.state(), TimeTotalState::Nothing);
}
#[test]
fn t013_a_refused_filter_is_reported_before_any_entry_is_evaluated() {
let issue = parsed_issue(
3,
&format!(
"time_entries:\n{}",
entry_line("e-1", 90, Some("2026-03-01"), "alice")
),
&[],
);
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
let bad_bound = TimeFilter::new().since("2026-3-1");
let refused = snapshot.total(&bad_bound);
assert_eq!(refused.state(), TimeTotalState::InvalidFilter);
assert_eq!(
refused.total_minutes, None,
"an unreadable bound is not read as `no bound`"
);
assert_eq!(refused.entry_count, 0);
assert_eq!(
findings(&refused),
vec![("invalid_date", Some("date_from"))]
);
assert!(!refused.diagnostics[0].remedy.is_empty());
let inverted = snapshot.total(&TimeFilter::new().between("2026-03-31", "2026-03-01"));
assert_eq!(inverted.state(), TimeTotalState::InvalidFilter);
assert_eq!(
findings(&inverted),
vec![("invalid_period", Some("period"))]
);
let blank_identity = snapshot.total(&TimeFilter {
assignee: Some(" ".to_string()),
..TimeFilter::new()
});
assert_eq!(blank_identity.state(), TimeTotalState::InvalidFilter);
assert_eq!(
findings(&blank_identity),
vec![("invalid_assignee", Some("assignee"))]
);
let accepted = snapshot.total(&TimeFilter::new().since("2026-03-01"));
assert_eq!(accepted.state(), TimeTotalState::Recorded);
assert_eq!(accepted.total_minutes, Some(90));
assert!(bad_bound.problems().iter().any(|problem| matches!(
problem,
TimeFilterError::InvalidDate {
field: "date_from",
..
}
)));
assert!(TimeFilter::new().validate().is_ok());
}
#[test]
fn t013_selects_refuses_an_invalid_criterion_instead_of_matching_on_it() {
let alice = entry("e-1", 90, Some("2026-01-15"), "alice");
let unreadable = TimeFilter::new().since("0");
assert_eq!(unreadable.problems().len(), 1);
assert!(
!unreadable.selects(3, &alice),
"an unreadable bound must select nothing"
);
let blank = TimeFilter::new().for_assignee("");
assert!(
blank
.problems()
.iter()
.any(|problem| matches!(problem, TimeFilterError::InvalidAssignee { .. })),
"a blank identity is a refusal, not an absent criterion"
);
assert!(
!blank.selects(3, &alice),
"a blank identity must select nothing"
);
assert!(!TimeFilter::new().for_issue(4).selects(3, &alice));
assert!(TimeFilter::new().selects(3, &alice));
assert!(
TimeFilter::new()
.since("2026-01-01")
.for_assignee("@alice")
.selects(3, &alice)
);
assert!(!TimeFilter::new().since("2026-02-01").selects(3, &alice));
assert!(!TimeFilter::new().for_assignee("bob").selects(3, &alice));
}
#[test]
fn t013_an_absent_issue_is_not_an_issue_that_recorded_nothing() {
let snapshot = TimeSnapshot::of(&[
parsed_issue(
3,
&format!(
"time_entries:\n{}",
entry_line("e-1", 90, Some("2026-03-01"), "alice")
),
&[],
),
parsed_issue(4, "time_entries: []\n", &[]),
]);
let present = snapshot.total(&TimeFilter::new().for_issue(4));
let absent = snapshot.total(&TimeFilter::new().for_issue(99));
assert_eq!(present.state(), TimeTotalState::Nothing);
assert_eq!(absent.state(), TimeTotalState::UnknownIssue);
assert_ne!(
present.state(),
absent.state(),
"`this issue recorded nothing` and `this snapshot never held this issue` are \
different answers"
);
assert_eq!(absent.total_minutes, None);
assert_eq!(
findings(&absent),
vec![("unknown_issue", None)],
"the unknown scope carries the machine-readable name of its selector"
);
assert!(snapshot.holds_issue(4) && !snapshot.holds_issue(99));
}
#[test]
fn t013_filter_describe_names_one_scope_in_one_spelling() {
assert_eq!(TimeFilter::new().describe(), "all");
assert_eq!(
TimeFilter::new()
.for_issue(3)
.for_assignee("@ada")
.between("2026-03-01", "2026-03-31")
.including_deleted()
.describe(),
"issue #3, assignee @ada, period 2026-03-01..2026-03-31, audit"
);
assert_eq!(
TimeFilter::new().until("2026-03-31").describe(),
"period ..2026-03-31"
);
}
#[test]
fn t014_one_snapshot_answers_every_requested_scope_identically() {
let issues = vec![
parsed_issue(
3,
&format!(
"time_entries:\n{}",
entry_line("e-1", 90, Some("2026-03-01"), "alice")
),
&[],
),
parsed_issue(
4,
&format!(
"time_entries:\n{}",
entry_line("e-2", 30, Some("2026-03-02"), "ada")
),
&[],
),
];
let snapshot = TimeSnapshot::of(&issues);
assert_eq!(snapshot.entries().len(), 2, "each entry is indexed once");
assert_eq!(snapshot.issues(), &[3, 4]);
let filters = [
TimeFilter::new(),
TimeFilter::new().for_issue(3),
TimeFilter::new().for_assignee("ada"),
TimeFilter::new().between("2026-03-01", "2026-03-01"),
];
let batch = snapshot.totals(&filters);
assert_eq!(batch.len(), filters.len(), "one answer per requested scope");
for (filter, total) in filters.iter().zip(&batch) {
assert_eq!(
*total,
snapshot.total(filter),
"the batch path and the single path answer identically for {filter}"
);
}
assert_eq!(
batch
.iter()
.map(|total| total.total_minutes)
.collect::<Vec<_>>(),
vec![Some(120), Some(90), Some(30), Some(90)]
);
assert_eq!(snapshot.entries().len(), 2);
assert_eq!(snapshot.issues(), &[3, 4]);
}
#[test]
fn t016_findings_are_ordered_scope_first_then_entry_index_order() {
let snapshot = TimeSnapshot::of(&[parsed_issue(
3,
&format!(
"time_entries:\n{}{}",
entry_line("e-bad", -5, Some("2026-03-01"), "alice"),
entry_line("e-ok", 30, Some("2026-03-02"), "alice"),
),
&[],
)]);
let refused = snapshot.total(&TimeFilter {
issue: Some(3),
assignee: Some(String::new()),
..TimeFilter::new()
});
assert_eq!(refused.diagnostics.len(), 1);
assert!(refused.diagnostics[0].is_scope_finding());
let total = snapshot.total(&TimeFilter::new().for_issue(3));
assert_eq!(total.diagnostics.len(), 1);
assert!(
!total.diagnostics[0].is_scope_finding(),
"an entry finding names its entry"
);
assert_eq!(total.diagnostics[0].entry_id.as_deref(), Some("e-bad"));
}
#[test]
fn t014_breakdowns_cover_issues_identities_and_dates_deterministically() {
let issue3 = parsed_issue(
3,
&format!(
"time_entries:\n{}{}{}",
entry_line("e-a1", 90, Some("2026-03-01"), "alice"),
entry_line("e-a2", 30, Some("2026-03-15"), "@Ada"),
entry_line("e-a3", 45, None, "alice"),
),
&[],
);
let issue4 = parsed_issue(
4,
&format!(
"time_entries:\n{}",
entry_line("e-b1", 60, Some("2026-03-05"), "ada")
),
&[],
);
let issue5 = parsed_issue(5, "time_entries: []\n", &[]);
let snapshot = TimeSnapshot::of(&[issue3, issue4, issue5]);
let by_issue = snapshot.by_issue(&TimeFilter::new());
assert_eq!(
by_issue
.iter()
.map(|total| (total.group.label(), total.total_minutes, total.entry_count))
.collect::<Vec<_>>(),
vec![
("#3".to_string(), Some(165), 3),
("#4".to_string(), Some(60), 1),
("#5".to_string(), None, 0),
],
"every issue appears, including the one with no entry: its absence is a \
`nothing` answer"
);
assert_eq!(by_issue[2].state(), TimeTotalState::Nothing);
let by_assignee = snapshot.by_assignee(&TimeFilter::new());
assert_eq!(
by_assignee
.iter()
.map(|total| (total.group.label(), total.total_minutes))
.collect::<Vec<_>>(),
vec![
("@Ada".to_string(), Some(90)),
("@alice".to_string(), Some(135)),
],
"`@Ada` and `ada` group as one identity, ordered by canonical identity"
);
let by_date = snapshot.by_date(&TimeFilter::new());
assert_eq!(
by_date
.iter()
.map(|total| (total.group.label(), total.total_minutes))
.collect::<Vec<_>>(),
vec![
("2026-03-01".to_string(), Some(90)),
("2026-03-05".to_string(), Some(60)),
("2026-03-15".to_string(), Some(30)),
("(undated)".to_string(), Some(45)),
],
"dates ascend and the undated entry is shown last rather than dropped"
);
let bounded = snapshot.by_date(&TimeFilter::new().between("2026-03-05", "2026-03-15"));
assert_eq!(
bounded
.iter()
.map(|total| (
total.group.label(),
total.total_minutes,
total.undated_count,
total.state()
))
.collect::<Vec<_>>(),
vec![
(
"2026-03-05".to_string(),
Some(60),
0,
TimeTotalState::Recorded
),
(
"2026-03-15".to_string(),
Some(30),
0,
TimeTotalState::Recorded
),
("(undated)".to_string(), None, 1, TimeTotalState::Unknown),
],
"the unplaceable group reports `unknown`, never `nothing`: it selected a real entry"
);
let unknown = snapshot.by_issue(&TimeFilter::new().for_issue(99));
assert_eq!(
unknown.len(),
1,
"an unknown scope yields one total carrying the finding, not one group per issue"
);
assert_eq!(unknown[0].state(), TimeTotalState::UnknownIssue);
assert_eq!(identity_cmp("Ada", "ada"), std::cmp::Ordering::Equal);
assert_eq!(identity_cmp("ada", "bob"), std::cmp::Ordering::Less);
assert_eq!(
TimeGroup::Assignee("ADA".into()),
TimeGroup::Assignee("ada".into())
);
}
#[test]
fn t017_deleted_entries_are_absent_from_current_totals_and_reported_by_audit() {
let deleted = "- 2026-03-20T10:00:00Z \u{2014} alice \u{2014} time_entry_deleted \u{2014} \
entry e-gone; deleted 45m on 2026-03-04 by ada: scrapped; reason: duplicate"
.to_string();
let corrected = "- 2026-03-21T10:00:00Z \u{2014} alice \u{2014} time_entry_updated \u{2014} \
entry e-1; updated 60m on 2026-03-01 by alice -> 90m on 2026-03-01 by alice; \
reason: recount"
.to_string();
let issue = parsed_issue(
3,
&format!(
"time_entries:\n{}",
entry_line("e-1", 90, Some("2026-03-01"), "alice")
),
&[deleted, corrected],
);
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
let current = snapshot.total(&TimeFilter::new());
assert_eq!(
current.total_minutes,
Some(90),
"the deleted entry's 45 minutes are History's, never added back to the \
current total"
);
assert_eq!(current.entry_count, 1);
assert_eq!(
current.deleted_count, None,
"normal totals do not report audit context unless it was asked for"
);
let audited = snapshot.total(&TimeFilter::new().including_deleted());
assert_eq!(
audited.total_minutes, current.total_minutes,
"asking for the audit context changes what is reported, not what is summed"
);
assert_eq!(audited.deleted_count, Some(1));
assert_eq!(audited.entry_count, 1);
let audit = snapshot.audit(&TimeFilter::new().for_issue(3));
assert_eq!(audit.issues.len(), 1);
let context = &audit.issues[0];
assert_eq!(context.issue, 3);
assert_eq!(context.current, 1, "the current projection is one entry");
assert_eq!(context.deletions.len(), 1);
assert_eq!(context.deletions[0].issue, 3);
assert_eq!(context.deletions[0].entry_id.as_deref(), Some("e-gone"));
assert_eq!(context.deletions[0].deleted_at, "2026-03-20T10:00:00Z");
assert_eq!(context.deletions[0].deleted_by, "alice");
assert_eq!(
context.corrections, 1,
"a correction keeps a prior value too, so it is auditable rather than silent"
);
assert_eq!(audit.deleted_count, 1);
assert_eq!(audit.corrected_count, 1);
assert!(audit.diagnostics.is_empty());
let missing = snapshot.audit(&TimeFilter::new().for_issue(99));
assert!(missing.issues.is_empty());
assert_eq!(
missing
.diagnostics
.iter()
.map(|finding| finding.kind.as_str())
.collect::<Vec<_>>(),
vec!["unknown_issue"]
);
let refused = snapshot.audit(&TimeFilter::new().since("2026-3-1"));
assert!(refused.issues.is_empty());
assert_eq!(refused.diagnostics.len(), 1);
assert!(refused.diagnostics[0].is_scope_finding());
assert_eq!(
snapshot.deletions_on(3).len(),
1,
"the snapshot is unchanged by every read above"
);
assert_eq!(snapshot.corrections_on(3), 1);
assert_eq!(snapshot.corrections_on(99), 0);
}
#[test]
fn t017_the_audit_count_covers_the_selectors_issues_not_just_the_ones_with_current_entries() {
let deletion = |entry_id: &str| {
format!(
"- 2026-03-20T10:00:00Z \u{2014} alice \u{2014} time_entry_deleted \u{2014} \
entry {entry_id}; deleted 45m on 2026-03-04 by ada: scrapped; \
reason: duplicate"
)
};
let issue3 = parsed_issue(3, "time_entries: []\n", &[deletion("e-gone-3")]);
let issue4 = parsed_issue(
4,
&format!(
"time_entries:\n{}",
entry_line("e-keep", 30, Some("2026-03-01"), "alice")
),
&[deletion("e-gone-4")],
);
let issue5 = parsed_issue(5, "time_entries: []\n", &[deletion("e-gone-5")]);
let snapshot = TimeSnapshot::of(&[issue3, issue4, issue5]);
let only_deleted = snapshot.total(&TimeFilter::new().for_issue(3).including_deleted());
assert_eq!(only_deleted.state(), TimeTotalState::Nothing);
assert_eq!(only_deleted.total_minutes, None);
assert_eq!(only_deleted.entry_count, 0);
assert_eq!(only_deleted.deleted_count, Some(1));
let whole = snapshot.total(&TimeFilter::new().including_deleted());
assert_eq!(whole.deleted_count, Some(3));
assert_eq!(whole.entry_count, 1);
for filter in [
TimeFilter::new().including_deleted(),
TimeFilter::new().for_issue(3).including_deleted(),
TimeFilter::new().for_issue(4).including_deleted(),
TimeFilter::new().since("2026-03-01").including_deleted(),
] {
assert_eq!(
snapshot.total(&filter).deleted_count,
Some(snapshot.audit(&filter).deleted_count),
"{filter}: `total` and `audit` must not disagree about the prior values"
);
}
let per_issue = snapshot.by_issue(&TimeFilter::new().including_deleted());
assert_eq!(
per_issue
.iter()
.map(|total| total.deleted_count.unwrap_or(0))
.collect::<Vec<_>>(),
vec![1, 1, 1]
);
assert_eq!(snapshot.total(&TimeFilter::new()).deleted_count, None);
assert_eq!(
snapshot
.total(&TimeFilter::new().for_issue(3))
.deleted_count,
None
);
}
#[test]
fn t017_a_correction_moves_the_current_total_and_stays_auditable() {
let (tmp, dir) = repo();
write_issue(&dir, 3, "time_entries: []\n", &[]);
let store = store_of(&tmp);
let created = apply(
&store,
&TimeMutationRequest::create("3", NewTimeEntry::new(60, "alice").on("2026-03-01"))
.at("2026-03-01T10:00:00Z"),
)
.expect("the create applies");
let entry_id = created.outcome.entry_id.clone();
apply(
&store,
&TimeMutationRequest::edit("3", &entry_id, TimeEntryEdit::new().duration(90), "recount")
.at("2026-03-02T10:00:00Z"),
)
.expect("the correction applies");
let issue = issue_of(&store, 3);
assert_eq!(issue.time_entries.len(), 1);
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
let total = snapshot.total(&TimeFilter::new().for_issue(3));
assert_eq!(
total.total_minutes,
Some(90),
"the current projection carries the corrected value"
);
assert_eq!(snapshot.corrections_on(3), 1);
assert_eq!(snapshot.deletions_on(3).len(), 0);
let audit = snapshot.audit(&TimeFilter::new().for_issue(3));
assert_eq!(audit.corrected_count, 1);
assert_eq!(audit.issues[0].current, 1);
}
#[test]
fn nfr003_the_same_input_produces_the_same_bytes_twice() {
let build = |number| {
parsed_issue(
number,
&format!(
"time_entries:\n{}{}{}",
entry_line("e-a1", 90, Some("2026-03-01"), "alice"),
entry_line("e-a2", -5, Some("2026-03-02"), "@Ada"),
entry_line("e-note", 30, Some("2026-03-03"), "carol"),
),
&[
"- 2026-03-09T00:00:00Z \u{2014} bob \u{2014} time_entry_deleted \u{2014} \
entry e-z; deleted 15m on 2026-03-02 by bob: dedupe"
.to_string(),
],
)
};
let filter = TimeFilter::new()
.between("2026-03-01", "2026-03-31")
.including_deleted();
let first = TimeSnapshot::of(&[build(3)]);
let second = TimeSnapshot::of(&[build(3)]);
for _ in 0..2 {
let left = bytes(&first.total(&filter));
let right = bytes(&second.total(&filter));
assert_eq!(left, right, "the same issues must render the same bytes");
let left_grouped: Vec<String> = first.by_assignee(&filter).iter().map(bytes).collect();
let right_grouped: Vec<String> = second.by_assignee(&filter).iter().map(bytes).collect();
assert_eq!(left_grouped, right_grouped);
}
assert_eq!(
first.total(&filter),
second.total(&filter),
"and the values compare equal, not just their render"
);
assert_eq!(first.total(&filter).deleted_count, Some(1));
assert_eq!(first.total(&filter).total_minutes, Some(120));
}
#[test]
fn c008_aggregation_reads_the_issue_slice_and_writes_nothing() {
let (tmp, dir) = repo();
write_issue(
&dir,
3,
&format!(
"time_entries:\n{}",
entry_line("e-1", 90, Some("2026-03-01"), "alice")
),
&[
"- 2026-03-09T00:00:00Z \u{2014} bob \u{2014} time_entry_deleted \u{2014} \
entry e-z; deleted 15m on 2026-03-02 by bob: dedupe"
.to_string(),
],
);
let path = dir.join(issue_filename(3));
let before_file = fs::read_to_string(&path).unwrap();
let store = store_of(&tmp);
let issues = vec![issue_of(&store, 3)];
let before_render: Vec<String> = issues.iter().map(Issue::to_file_contents).collect();
let snapshot = TimeSnapshot::of(&issues);
let filter = TimeFilter::new().including_deleted();
let _ = snapshot.total(&filter);
let _ = snapshot.totals(&[filter.clone(), TimeFilter::new().for_issue(3)]);
let _ = snapshot.by_issue(&filter);
let _ = snapshot.by_assignee(&filter);
let _ = snapshot.by_date(&filter);
let _ = snapshot.audit(&filter);
let after_render: Vec<String> = issues.iter().map(Issue::to_file_contents).collect();
assert_eq!(
before_render, after_render,
"aggregation must not touch the issue slice it was handed"
);
assert_eq!(
fs::read_to_string(&path).unwrap(),
before_file,
"aggregation must not touch the file"
);
assert_eq!(
store
.get(IssueSelector::Number(3))
.unwrap()
.time_entries
.len(),
1
);
}
#[test]
fn nfr001_ten_thousand_entries_aggregate_within_the_budget() {
let mut entries = String::from("time_entries:\n");
for index in 0..10_000usize {
let day = (index % 28) + 1;
let author = format!("a{}", index % 7);
entries.push_str(&entry_line(
&format!("e-{index}"),
1,
Some(&format!("2026-03-{day:02}")),
&author,
));
}
let issue = parsed_issue(3, &entries, &[]);
assert_eq!(issue.time_entries.len(), 10_000);
let filter = TimeFilter::new()
.for_assignee("a1")
.between("2026-03-01", "2026-03-28")
.including_deleted();
let expected = (0..10_000usize).filter(|index| index % 7 == 1).count();
let started = Instant::now();
let snapshot = TimeSnapshot::of(std::slice::from_ref(&issue));
let total = snapshot.total(&filter);
let per_issue = snapshot.by_issue(&filter);
let per_assignee = snapshot.by_assignee(&filter);
let per_date = snapshot.by_date(&filter);
let audit = snapshot.audit(&filter);
let elapsed = started.elapsed();
assert_eq!(per_assignee.len(), 1, "one identity did all of that work");
assert_eq!(
per_assignee[0].entry_count, expected,
"every entry of that identity is counted"
);
assert_eq!(total.entry_count, expected);
assert_eq!(total.total_minutes, Some(expected as i128));
assert_eq!(per_issue.len(), 1);
let expected_days: std::collections::BTreeSet<usize> = (0..10_000usize)
.filter(|index| index % 7 == 1)
.map(|index| index % 28 + 1)
.collect();
assert_eq!(
per_date.len(),
expected_days.len(),
"one group per calendar date the selected entries fall on"
);
assert_eq!(
snapshot.by_date(&TimeFilter::new()).len(),
28,
"and every day of March has its own group when nothing is filtered"
);
assert_eq!(audit.deleted_count, 0);
assert!(
elapsed < Duration::from_millis(500),
"10,000 in-memory entries must aggregate within the NFR-001 budget, took {elapsed:?}"
);
let unbounded = snapshot.total(&TimeFilter::new());
assert_eq!(unbounded.total_minutes, Some(10_000));
assert_eq!(unbounded.entry_count, 10_000);
}
fn mixed_store() -> (TempDir, PathBuf, Store) {
let (tmp, dir) = repo();
let issue3 = format!(
"time_entries:\n{}{}{}{}{}{}",
entry_line("e-a1", 90, Some("2026-03-01"), "alice"),
entry_line("e-a2", 30, Some("2026-03-15"), "@Ada"),
entry_line("e-a3", 45, None, "alice"),
entry_line("e-a4", -5, Some("2026-03-02"), "alice"),
entry_line("e-a5", 10, Some("2026-02-30"), "carol"),
entry_line("e-a6", 20, Some("2026-03-20"), ""),
);
let issue4 = format!(
"time_entries:\n{}{}",
entry_line("e-b1", 60, Some("2026-03-05"), "ada"),
entry_line("e-b2", 0, Some("2026-03-06"), "alice"),
);
let deleted = "- 2026-01-05T00:00:00Z \u{2014} alice \u{2014} time_entry_deleted \u{2014} \
entry e-z1; deleted 45m on 2026-01-04 by ada: scrapped; reason: duplicate"
.to_string();
write_issue(&dir, 3, &issue3, &[deleted]);
write_issue(&dir, 4, &issue4, &[]);
write_issue(&dir, 5, "time_entries: []\n", &[]);
let store = store_of(&tmp);
(tmp, dir, store)
}
#[test]
fn the_mixed_fixture_reports_its_totals_its_counts_and_its_findings() {
let (_tmp, _dir, store) = mixed_store();
let issues: Vec<Issue> = [3, 4, 5]
.iter()
.map(|number| issue_of(&store, *number))
.collect();
let snapshot = TimeSnapshot::of(&issues);
let whole = snapshot.total(&TimeFilter::new());
assert_eq!(whole.entry_count, 5);
assert_eq!(whole.total_minutes, Some(225), "90 + 30 + 45 + 60 + 0");
assert_eq!(whole.malformed_count, 3);
assert_eq!(whole.undated_count, 0);
assert_eq!(whole.deleted_count, None);
assert_eq!(
findings(&whole),
vec![
("invalid_date", Some("date")),
("invalid_duration", Some("duration_minutes")),
("missing_author", Some("author")),
],
"the three malformed entries are reported in index order (the February date \
first), each with its field"
);
let march = TimeFilter::new()
.between("2026-03-01", "2026-03-31")
.including_deleted();
let in_march = snapshot.total(&march);
assert_eq!(
in_march.entry_count, 4,
"e-a3 is undated, e-a5 is unreadable"
);
assert_eq!(in_march.total_minutes, Some(180), "90 + 30 + 60 + 0");
assert_eq!(
in_march.undated_count, 1,
"the undated 45 minutes are inside the scope but outside the period, and are counted"
);
assert_eq!(in_march.malformed_count, 3);
assert_eq!(in_march.deleted_count, Some(1));
assert!(in_march.is_partial());
assert_eq!(in_march.state(), TimeTotalState::Recorded);
let per_issue = snapshot.by_issue(&march);
assert_eq!(
per_issue
.iter()
.map(|total| (
total.group.label(),
total.total_minutes,
total.entry_count,
total.malformed_count
))
.collect::<Vec<_>>(),
vec![
("#3".to_string(), Some(120), 2, 3),
("#4".to_string(), Some(60), 2, 0),
("#5".to_string(), None, 0, 0),
],
"issue 5 has no entry and still reports `nothing`; issue 3's undated entry is \
not in the period"
);
let per_assignee = snapshot.by_assignee(&march);
assert_eq!(
per_assignee
.iter()
.map(|total| (
total.group.label(),
total.total_minutes,
total.malformed_count
))
.collect::<Vec<_>>(),
vec![
("@Ada".to_string(), Some(90), 0),
("@alice".to_string(), Some(90), 1),
("@carol".to_string(), None, 1),
("(unattributed)".to_string(), None, 1),
],
"an identity's group carries its malformed entries as counts, not as minutes"
);
let audit = snapshot.audit(&TimeFilter::new().for_issue(3));
assert_eq!(audit.issues[0].current, 6);
assert_eq!(audit.deleted_count, 1);
assert_eq!(
audit.issues[0].deletions[0].entry_id.as_deref(),
Some("e-z1")
);
let scope = in_march.filter.describe();
let state = in_march.state().as_str();
let total = in_march.total_minutes;
let counted = in_march.entry_count;
let malformed = in_march.malformed_count;
let undated = in_march.undated_count;
let deleted = in_march.deleted_count;
let mut report = format!(
"scope={scope} state={state} total={total:?} counted={counted} \
malformed={malformed} undated={undated} deleted={deleted:?}\n"
);
for group in &per_issue {
let label = group.group.label();
let group_state = group.state().as_str();
let group_total = group.total_minutes;
let group_counted = group.entry_count;
let group_malformed = group.malformed_count;
report.push_str(&format!(
" issue {label} state={group_state} total={group_total:?} \
counted={group_counted} malformed={group_malformed}\n"
));
}
for finding in &in_march.diagnostics {
report.push_str(&format!(" finding {}\n", finding.render()));
}
println!("{report}");
assert!(report.contains("malformed=3"));
assert!(report.contains("invalid_duration"));
}