use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use serde_json::Value;
use tempfile::TempDir;
use tix::analytics::events::cycle_scope;
use tix::analytics::series::Mode;
use tix::commands::export::chart_document;
use tix::config::Config;
use tix::model::{History, HistoryEvent, HistoryEventKind};
use tix::planning::Planner;
use tix::storage::Store;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
const CONFIG: &str = "issues_dir = \"{issues}\"\n\n\
[kanban.default]\ncolumns = [\"todo\", \"doing\", \"done\"]\n\n\
[kanban.default.wip.units]\npoints = 1\nhours = 5\n\n\
[cycles.\"Sprint 7\"]\nstart_date = \"2026-01-05\"\ndue_date = \"2026-01-09\"\n\n\
[cycles.\"Open Cycle\"]\nstart_date = \"2026-01-05\"\n";
fn project() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let issues = dir.path().join("issues");
fs::create_dir_all(&issues).unwrap();
fs::write(
dir.path().join("tix.toml"),
CONFIG.replace("{issues}", &issues.to_string_lossy()),
)
.unwrap();
(dir, issues)
}
fn project_with_remote() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let issues = dir.path().join("issues");
fs::create_dir_all(&issues).unwrap();
fs::write(
dir.path().join("tix.toml"),
format!(
"{}\n[export.gitlab]\nproject = \"acme/tix\"\n",
CONFIG.replace("{issues}", &issues.to_string_lossy())
),
)
.unwrap();
(dir, issues)
}
fn recorded(timestamp: &str, kind: HistoryEventKind, detail: &str) -> HistoryEvent {
HistoryEvent::new(timestamp, "alice", kind, detail)
}
fn addition(timestamp: &str) -> HistoryEvent {
recorded(timestamp, HistoryEventKind::ParentSet, "parent none -> #1")
}
fn completion(timestamp: &str) -> HistoryEvent {
recorded(
timestamp,
HistoryEventKind::TransitionAccepted,
"todo -> shipped (unstarted -> completed)",
)
}
fn issue_markdown(number: u32, estimate: Option<&str>, events: &[HistoryEvent]) -> String {
let mut frontmatter = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\nlabels: []\n\
cycles:\n - Sprint 7\n"
);
if let Some(estimate) = estimate {
frontmatter.push_str(&format!("estimate: \"{estimate}\"\n"));
}
frontmatter.push_str("---\n");
let mut history = History::new();
for event in events {
history.append(event.clone());
}
format!("{frontmatter}{}", history.render())
}
fn write_fixture(issues: &Path) {
fs::write(
issues.join("2026-01-01-issue-1.md"),
issue_markdown(
1,
Some("3points"),
&[
addition("2026-01-05T09:00:00Z"),
completion("2026-01-07T09:00:00Z"),
],
),
)
.unwrap();
fs::write(
issues.join("2026-01-01-issue-2.md"),
issue_markdown(
2,
Some("2points"),
&[
addition("2026-01-06T09:00:00Z"),
addition("2026-02-10T09:00:00Z"),
],
),
)
.unwrap();
}
fn stdout(dir: &Path, args: &[&str]) -> String {
let out = tix()
.current_dir(dir)
.args(args)
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(out).unwrap()
}
fn run(dir: &Path, args: &[&str]) -> (i32, String, String) {
let out = tix()
.current_dir(dir)
.args(args)
.assert()
.get_output()
.clone();
(
out.status.code().unwrap_or(-1),
String::from_utf8(out.stdout).unwrap(),
String::from_utf8(out.stderr).unwrap(),
)
}
fn refused(dir: &Path, args: &[&str]) -> (i32, String, String) {
let out = tix()
.current_dir(dir)
.args(args)
.assert()
.failure()
.get_output()
.clone();
(
out.status.code().unwrap_or(-1),
String::from_utf8(out.stdout).unwrap(),
String::from_utf8(out.stderr).unwrap(),
)
}
fn line<'a>(text: &'a str, prefix: &str) -> &'a str {
text.lines()
.find(|line| line.starts_with(prefix))
.unwrap_or_else(|| panic!("no `{prefix}` line in:\n{text}"))
}
fn row<'a>(table: &'a str, date: &str) -> &'a str {
line(table, date)
}
fn cell(row: &str, index: usize) -> &str {
row.split('\t')
.nth(index)
.unwrap_or_else(|| panic!("row `{row}` has no cell {index}"))
}
fn tree(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
let mut files = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
stack.push(path);
} else {
files.insert(path.clone(), fs::read(&path).unwrap());
}
}
}
files
}
fn store_of(issues: &Path, config: &Config) -> Store {
Store {
issues_dir: issues.to_path_buf(),
config: config.clone(),
}
}
fn sprint_scope(config: &Config, root: &Path) -> tix::analytics::events::AnalyticsScope {
let planner = Planner::from_config(config.clone(), root.to_path_buf());
cycle_scope(&planner, "Sprint 7").expect("the fixture declares the cycle")
}
#[test]
fn t025_count_report_is_explicit_and_an_unanswerable_value_is_a_dash() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, out, err) = run(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
],
);
assert_eq!(code, 0, "a readable series is a report: {err}");
assert_eq!(line(&out, "scope:"), "scope: cycle");
assert_eq!(line(&out, "id:"), "id: Sprint 7");
assert_eq!(line(&out, "period:"), "period: 2026-01-05..2026-01-09");
assert_eq!(
line(&out, "snapshot:"),
"snapshot: -",
"this build has no CLI flag that binds a snapshot, and an unbound scope says so"
);
assert_eq!(line(&out, "mode:"), "mode: count");
assert_eq!(line(&out, "unit:"), "unit: -", "count mode is unitless");
assert_eq!(line(&out, "formula:"), "formula: burndown.count.v1");
assert_eq!(line(&out, "days:"), "days: 5");
assert_eq!(
line(&out, "coverage:"),
"coverage: known=3 unknown=0 coverage_limited=2"
);
assert_eq!(line(&out, "diagnostics:"), "diagnostics: 1");
assert!(
out.contains("date\tscope_total\tremaining\tcompleted\tscope_added\tcompletions\tcoverage"),
"the table header names every column:\n{out}"
);
let added = row(&out, "2026-01-05");
assert_eq!(
[
cell(added, 1),
cell(added, 2),
cell(added, 3),
cell(added, 4),
cell(added, 5),
cell(added, 6)
],
["1", "1", "0", "1", "0", "known"]
);
let second = row(&out, "2026-01-06");
assert_eq!(
[cell(second, 1), cell(second, 2), cell(second, 4)],
["2", "2", "1"]
);
let completed = row(&out, "2026-01-07");
assert_eq!(
[
cell(completed, 1),
cell(completed, 2),
cell(completed, 3),
cell(completed, 4),
cell(completed, 5)
],
["2", "1", "1", "0", "1"],
"scope additions and completions are separate fields (FR-002)"
);
for date in ["2026-01-08", "2026-01-09"] {
let point = row(&out, date);
assert_eq!(
[
cell(point, 1),
cell(point, 2),
cell(point, 3),
cell(point, 4),
cell(point, 5),
cell(point, 6)
],
["2", "1", "1", "-", "-", "coverage_limited"],
"{date} was not observed, so its deltas are unknown — never zero"
);
}
assert!(
err.contains("missing_history"),
"the gap is named machine-readably:\n{err}"
);
assert!(
err.contains("2026-01-08..2026-01-09"),
"the gap names the days it covers:\n{err}"
);
assert!(
err.contains("help:"),
"a finding carries its remedy:\n{err}"
);
assert_eq!(
cell(row(&out, "2026-01-09"), 1),
"2",
"the issue added on 2026-02-10 does not reach the closed period"
);
}
#[test]
fn t024_the_period_flags_widen_the_series() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, out, err) = run(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--from",
"2026-01-01",
"--to",
"2026-01-11",
],
);
assert_eq!(code, 0, "{err}");
assert_eq!(line(&out, "period:"), "period: 2026-01-01..2026-01-11");
assert_eq!(line(&out, "days:"), "days: 11");
assert_eq!(
line(&out, "coverage:"),
"coverage: known=3 unknown=0 coverage_limited=8"
);
assert_eq!(
line(&out, "diagnostics:"),
"diagnostics: 2",
"one gap before the first recorded event and one after the last"
);
let anchored = row(&out, "2026-01-01");
assert_eq!(
[
cell(anchored, 1),
cell(anchored, 2),
cell(anchored, 3),
cell(anchored, 4),
cell(anchored, 5)
],
["0", "0", "0", "-", "-"],
"an anchored level is a real zero; the day's own deltas are unknown"
);
}
#[test]
fn t025_estimate_mode_uses_the_declared_unit_and_refuses_a_missing_one() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, out, err) = run(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"estimate",
"--unit",
"points",
],
);
assert_eq!(code, 0, "a declared unit is a report: {err}");
assert_eq!(line(&out, "mode:"), "mode: estimate");
assert_eq!(line(&out, "unit:"), "unit: points");
assert_eq!(line(&out, "formula:"), "formula: burndown.estimate.v1");
assert_eq!(
[
cell(row(&out, "2026-01-06"), 1),
cell(row(&out, "2026-01-06"), 4)
],
["5", "2"],
"3 points + 2 points on the 6th"
);
assert_eq!(
[
cell(row(&out, "2026-01-07"), 3),
cell(row(&out, "2026-01-07"), 5)
],
["3", "3"],
"issue 1's three points completed"
);
let (code, out, err) = refused(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"estimate",
],
);
assert_eq!(code, 1, "an unconvertible valuation is refused");
assert!(out.is_empty(), "a refusal prints no report: {out}");
assert!(err.contains("missing_conversion"), "stderr:\n{err}");
assert!(
err.contains("estimate mode needs an explicit unit conversion"),
"the reason:\n{err}"
);
assert!(
err.contains("help:") && err.contains("declare the unit under"),
"the shared remedy:\n{err}"
);
let (code, out, err) = refused(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"estimate",
"--unit",
"fortnights",
],
);
assert_eq!(code, 1);
assert!(out.is_empty());
assert!(err.contains("missing_conversion"), "stderr:\n{err}");
let (code, out, _) = run(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"count",
"--unit",
"points",
],
);
assert_eq!(code, 0);
assert_eq!(line(&out, "unit:"), "unit: -");
}
#[test]
fn t025_an_unenumerable_period_is_refused_with_its_remedy() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, out, err) = refused(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Open Cycle",
],
);
assert_eq!(code, 1);
assert!(out.is_empty(), "there is no report to print: {out}");
assert!(err.contains("invalid_period"), "stderr:\n{err}");
assert!(
err.contains("has no bound and the scope records no dated event"),
"the fold's own reason:\n{err}"
);
assert!(err.contains("help:"), "and its remedy:\n{err}");
}
#[test]
fn t026_a_projected_forecast_states_its_rate_dates_and_status() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, out, err) = run(
dir.path(),
&[
"analytics",
"projection",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--as-of",
"2026-01-09",
],
);
assert_eq!(code, 0, "a forecast is an answer: {err}");
assert_eq!(line(&out, "formula:"), "formula: burndown.projection.v1");
assert_eq!(line(&out, "as_of:"), "as_of: 2026-01-09");
assert_eq!(
line(&out, "end_date:"),
"end_date: 2026-01-09",
"the planning end date is the cycle's own"
);
assert_eq!(line(&out, "status:"), "status: projected");
assert_eq!(line(&out, "remaining:"), "remaining: 1");
assert_eq!(line(&out, "observed_rate:"), "observed_rate: 0.25");
assert_eq!(
line(&out, "projected_completion:"),
"projected_completion: 2026-01-13"
);
assert_eq!(
line(&out, "meets_end_date:"),
"meets_end_date: false",
"a forecast past the planning end is stated, not softened"
);
}
#[test]
fn t026_an_unanswerable_forecast_states_unknown_and_never_invents_a_date() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, out, err) = run(
dir.path(),
&[
"analytics",
"projection",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--as-of",
"2026-02-01",
],
);
assert_eq!(code, 0, "the status is the answer: {err}");
assert_eq!(line(&out, "status:"), "status: insufficient_data");
assert_eq!(
line(&out, "projected_completion:"),
"projected_completion: -"
);
assert_eq!(line(&out, "meets_end_date:"), "meets_end_date: -");
assert_eq!(line(&out, "remaining:"), "remaining: -");
assert!(
err.contains("projection_unknown") && err.contains("help:"),
"the status is explained with a remedy:\n{err}"
);
let (code, out, err) = run(
dir.path(),
&[
"analytics",
"projection",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--to",
"2026-01-06",
"--as-of",
"2026-01-06",
],
);
assert_eq!(code, 0, "{err}");
assert_eq!(line(&out, "end_date:"), "end_date: 2026-01-06");
assert_eq!(line(&out, "status:"), "status: unknown");
assert_eq!(line(&out, "remaining:"), "remaining: 2");
assert_eq!(line(&out, "observed_rate:"), "observed_rate: 0");
assert_eq!(
line(&out, "projected_completion:"),
"projected_completion: -"
);
assert!(err.contains("projection_unknown"), "stderr:\n{err}");
}
#[test]
fn t026_a_forecast_without_a_planning_end_is_refused_with_the_flag_that_helps() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, out, err) = refused(
dir.path(),
&[
"analytics",
"projection",
"--scope",
"cycle",
"--id",
"Open Cycle",
"--as-of",
"2026-01-06",
],
);
assert_eq!(code, 1);
assert!(out.is_empty(), "no report was produced: {out}");
assert!(
err.contains("declares no planning end date"),
"the reason:\n{err}"
);
assert!(
err.contains("--to") && err.contains("help:"),
"the remedy names the flag that resolves it:\n{err}"
);
let (code, _, err) = refused(
dir.path(),
&[
"analytics",
"projection",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--as-of",
"2026-1-9",
],
);
assert_eq!(code, 1);
assert!(err.contains("invalid_period"), "stderr:\n{err}");
assert!(err.contains("YYYY-MM-DD"), "the remedy:\n{err}");
}
#[test]
fn t024_an_unknown_scope_or_mode_word_is_refused_by_name() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, _, err) = refused(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"epic",
"--id",
"Sprint 7",
],
);
assert_eq!(code, 1);
assert!(err.contains("unknown --scope"), "stderr:\n{err}");
assert!(
err.contains("`cycle`") && err.contains("`milestone`"),
"{err}"
);
let (code, _, err) = refused(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"bananas",
],
);
assert_eq!(code, 1);
assert!(err.contains("unknown --mode"), "stderr:\n{err}");
assert!(
err.contains("`count`") && err.contains("`estimate`"),
"{err}"
);
}
#[test]
fn t027_export_emits_the_versioned_document_and_is_deterministic() {
let (dir, issues) = project();
write_fixture(&issues);
let args = [
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"count",
];
let first = stdout(dir.path(), &args);
let second = stdout(dir.path(), &args);
assert_eq!(first, second, "the same input produces the same bytes");
let json: Value = serde_json::from_str(&first).expect("the export is JSON");
assert_eq!(json["format"], "tix-chart");
assert_eq!(json["schema_version"], 1);
assert_eq!(json["scope"]["kind"], "cycle");
assert_eq!(json["scope"]["id"], "Sprint 7");
assert_eq!(json["scope"]["period"]["start"], "2026-01-05");
assert_eq!(json["scope"]["period"]["end"], "2026-01-09");
assert!(json["scope"]["snapshot_id"].is_null());
assert_eq!(json["mode"], "count");
assert!(json["unit"].is_null(), "count mode is unitless");
assert_eq!(json["precision"], "whole_issues");
assert_eq!(json["formula_version"], "burndown.count.v1");
assert_eq!(json["coverage"]["known"], 3);
assert_eq!(json["coverage"]["coverage_limited"], 2);
assert!(
json["diagnostics"].is_array() && json["projection"].is_null(),
"no forecast was asked for"
);
assert_eq!(json["series"]["points"].as_array().unwrap().len(), 5);
assert_eq!(json["series"]["points"][3]["scope_added"], Value::Null);
assert_eq!(json["series"]["points"][3]["coverage"], "coverage_limited");
assert_eq!(json["series"]["points"][2]["completions"], 1);
}
#[test]
fn t027_the_output_file_is_byte_identical_to_the_core_document() {
let (dir, issues) = project();
write_fixture(&issues);
let config = Config::load(dir.path()).expect("the fixture loads");
let store = store_of(&issues, &config);
let scope = sprint_scope(&config, dir.path());
for (mode, unit, forecast) in [
(Mode::Count, None, None),
(Mode::Estimate, Some("points"), None),
(Mode::Count, None, Some(("2026-01-09", "2026-01-09"))),
] {
let expected = chart_document(&config, &store, &scope, mode, unit, forecast)
.expect("the core builds the document")
.to_json_pretty()
.unwrap();
let mut args = vec![
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
mode.as_str(),
];
if let Some(unit) = unit {
args.extend(["--unit", unit]);
}
if let Some((as_of, _)) = forecast {
args.extend(["--as-of", as_of]);
}
assert_eq!(
stdout(dir.path(), &args),
expected,
"the CLI prints the core's bytes for {mode} {unit:?}"
);
let out = dir.path().join(format!(
"chart-{}-{}.json",
mode.as_str(),
unit.unwrap_or("none")
));
let mut args = args.clone();
args.extend(["--output", out.to_str().unwrap()]);
let printed = stdout(dir.path(), &args);
assert_eq!(
printed.trim_end(),
out.to_str().unwrap(),
"`--output` prints the path it wrote"
);
assert_eq!(
fs::read_to_string(&out).unwrap(),
expected,
"`--output` writes the core's bytes for {mode} {unit:?}"
);
fs::remove_file(&out).unwrap();
}
}
#[test]
fn t027_export_carries_a_forecast_and_requires_its_mode() {
let (dir, issues) = project();
write_fixture(&issues);
let out = stdout(
dir.path(),
&[
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"count",
"--as-of",
"2026-01-09",
],
);
let json: Value = serde_json::from_str(&out).unwrap();
assert_eq!(json["projection"]["status"], "projected");
assert_eq!(json["projection"]["projected_completion"], "2026-01-13");
assert_eq!(
json["projection"]["formula_version"],
"burndown.projection.v1"
);
assert_eq!(json["projection"]["end_date"], "2026-01-09");
let estimated = stdout(
dir.path(),
&[
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"estimate",
"--unit",
"points",
],
);
let json: Value = serde_json::from_str(&estimated).unwrap();
assert_eq!(json["mode"], "estimate");
assert_eq!(json["unit"], "points");
assert_eq!(json["precision"], "whole_units");
assert_eq!(json["formula_version"], "burndown.estimate.v1");
let (code, out, err) = refused(
dir.path(),
&[
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
],
);
assert_eq!(code, 1);
assert!(out.is_empty(), "no document was produced: {out}");
assert!(err.contains("`--mode`"), "the reason:\n{err}");
assert!(
err.contains("help:") && err.contains("--mode count"),
"the remedy:\n{err}"
);
}
#[test]
fn t027_an_export_of_an_unanswerable_fold_still_emits_its_document() {
let (dir, issues) = project();
write_fixture(&issues);
let args = [
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"estimate",
"--unit",
"fortnights",
];
let (code, out, err) = run(dir.path(), &args);
assert_eq!(code, 0, "the document is still produced: {err}");
let json: Value = serde_json::from_str(&out).expect("the export is JSON");
assert_eq!(json["format"], "tix-chart");
assert_eq!(json["unit"], "fortnights");
assert_eq!(
json["series"]["points"].as_array().unwrap().len(),
0,
"no day was folded"
);
assert_eq!(json["coverage"]["known"], 0);
assert_eq!(
json["series"]["diagnostics"][0]["kind"], "missing_conversion",
"the document carries why there is no day:\n{out}"
);
let (code, _, err) = refused(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"estimate",
"--unit",
"fortnights",
],
);
assert_eq!(code, 1, "there is no report to print: {err}");
assert!(err.contains("missing_conversion"), "stderr:\n{err}");
}
#[test]
fn t027_a_configured_remote_target_is_diagnosed_and_the_values_stay_local() {
let (dir, issues) = project_with_remote();
write_fixture(&issues);
let (code, out, err) = run(
dir.path(),
&[
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"count",
],
);
assert_eq!(
code, 0,
"the local document never invokes the remote tool: {err}"
);
let json: Value = serde_json::from_str(&out).unwrap();
assert_eq!(json["format"], "tix-chart");
let findings = json["diagnostics"].as_array().unwrap();
assert_eq!(
findings.len(),
1,
"one finding per configured target:\n{out}"
);
assert_eq!(findings[0]["kind"], "unsupported_remote_field");
assert_eq!(findings[0]["target"], "gitlab");
assert_eq!(
json["series"]["points"].as_array().unwrap().len(),
5,
"every day stays in the local document"
);
let (code, out, err) = run(
dir.path(),
&[
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
],
);
assert_eq!(code, 0);
assert_eq!(
line(&out, "diagnostics:"),
"diagnostics: 2",
"the fold's gap and the remote finding"
);
assert!(
err.contains("unsupported_remote_field [gitlab]"),
"the remote finding names the target:\n{err}"
);
}
#[test]
fn t028_every_verb_writes_nothing_of_its_own() {
let (dir, issues) = project();
write_fixture(&issues);
let before = tree(dir.path());
for args in [
vec![
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
],
vec![
"analytics",
"burndown",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"estimate",
"--unit",
"points",
],
vec![
"analytics",
"projection",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--as-of",
"2026-01-09",
],
vec![
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"count",
],
] {
let (code, _, err) = run(dir.path(), &args);
assert_eq!(code, 0, "{args:?} failed: {err}");
}
assert_eq!(
tree(dir.path()),
before,
"a read writes nothing and deletes nothing"
);
let out = dir.path().join("charts").join("sprint.json");
fs::create_dir_all(out.parent().unwrap()).unwrap();
let (code, _, err) = run(
dir.path(),
&[
"analytics",
"export",
"--scope",
"cycle",
"--id",
"Sprint 7",
"--mode",
"count",
"--output",
out.to_str().unwrap(),
],
);
assert_eq!(code, 0, "{err}");
assert!(out.is_file(), "the requested document exists");
let after: BTreeMap<PathBuf, Vec<u8>> = tree(dir.path())
.into_iter()
.filter(|(path, _)| path != &out)
.collect();
assert_eq!(after, before, "only the caller's path was written");
}
#[test]
fn t028_the_existing_commands_are_unchanged() {
let (dir, issues) = project();
write_fixture(&issues);
let (code, out, err) = run(dir.path(), &["list", "--all"]);
assert_eq!(code, 0, "tix list still works: {err}");
assert!(out.contains("Issue 1") && out.contains("Issue 2"), "{out}");
let (code, out, err) = run(dir.path(), &["status"]);
assert_eq!(code, 0, "tix status still works: {err}");
assert!(out.contains("todo"), "{out}");
}