use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn repo() -> TempDir {
let tmp = TempDir::new().unwrap();
tix().current_dir(tmp.path()).arg("init").assert().success();
tmp
}
fn issue(dir: &Path, title: &str) {
tix()
.current_dir(dir)
.args([
"create",
"--title",
title,
"--assignee",
"alice",
"--status",
"todo",
])
.assert()
.success();
}
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 create_view(dir: &Path, name: &str, extra: &[&str]) -> String {
let mut args = vec!["view", "create", "--name", name];
args.extend_from_slice(extra);
let text = stdout(dir, &args);
let line = text
.lines()
.find_map(|line| line.strip_prefix("Created "))
.unwrap_or_else(|| panic!("no `Created <id>` line in {text:?}"));
line.trim().to_string()
}
fn config_path(dir: &Path) -> PathBuf {
dir.join("tix.toml")
}
fn read_config(dir: &Path) -> String {
fs::read_to_string(config_path(dir)).unwrap()
}
fn issue_snapshot(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
fn walk(dir: &Path, out: &mut Vec<(PathBuf, Vec<u8>)>) {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, out);
} else if path.extension().is_some_and(|ext| ext == "md") {
out.push((path.clone(), fs::read(&path).unwrap()));
}
}
}
let root = dir.join("docs").join("issues");
let mut out = Vec::new();
walk(&root, &mut out);
out.sort_by(|left, right| left.0.cmp(&right.0));
assert!(!out.is_empty(), "expected at least one issue file");
out
}
#[test]
fn create_list_and_show_round_trip_the_normalized_plan() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(
dir,
"Active",
&[
"--filter",
"status:todo",
"--sort",
"due",
"--group",
"cycle",
],
);
assert!(id.starts_with("v-"), "generated id, got {id:?}");
let rows = stdout(dir, &["view", "list"]);
assert_eq!(rows.lines().count(), 1, "one view: {rows:?}");
let columns: Vec<&str> = rows.lines().next().unwrap().split('\t').collect();
assert_eq!(
columns,
vec![id.as_str(), "Active", "repository", "resolved"]
);
let shown = stdout(dir, &["view", "show", &id]);
assert!(shown.contains(&format!("id: {id}")), "{shown}");
assert!(shown.contains("name: Active"), "{shown}");
assert!(shown.contains("scope: repository"), "{shown}");
assert!(shown.contains("query: status:todo"), "{shown}");
assert!(shown.contains("sort: due"), "{shown}");
assert!(shown.contains("group: cycle"), "{shown}");
}
#[test]
fn a_rename_preserves_the_stable_id_and_the_plan() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(dir, "Active", &["--filter", "status:todo", "--sort", "due"]);
let updated = stdout(dir, &["view", "update", &id, "--name", "Renamed"]);
assert!(updated.contains(&format!("Updated {id}")), "{updated}");
let shown = stdout(dir, &["view", "show", &id]);
assert!(shown.contains("name: Renamed"), "{shown}");
assert!(shown.contains("query: status:todo"), "{shown}");
assert!(shown.contains("sort: due"), "{shown}");
assert_eq!(stdout(dir, &["view", "list"]).lines().count(), 1);
}
#[test]
fn a_partial_plan_update_carries_over_unnamed_fields() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(
dir,
"Triaged",
&[
"--filter",
"status:todo",
"--sort",
"due",
"--group",
"cycle",
],
);
stdout(dir, &["view", "update", &id, "--sort", "created"]);
let shown = stdout(dir, &["view", "show", &id]);
assert!(
shown.contains("sort: created"),
"the named sort is applied: {shown}"
);
assert!(
shown.contains("query: status:todo"),
"the stored filter survives: {shown}"
);
assert!(
shown.contains("group: cycle"),
"the stored group survives: {shown}"
);
}
#[test]
fn an_update_that_changes_nothing_reports_unchanged() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(dir, "Active", &["--filter", "status:todo"]);
let before = read_config(dir);
let again = stdout(dir, &["view", "update", &id, "--name", "Active"]);
assert!(again.contains(&format!("Unchanged {id}")), "{again}");
assert_eq!(
read_config(dir),
before,
"an idempotent update writes nothing"
);
}
#[test]
fn duplicate_allocates_a_new_id_and_copies_the_plan() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(dir, "Active", &["--filter", "status:todo", "--sort", "due"]);
let duplicated = stdout(dir, &["view", "duplicate", &id, "--name", "Copy"]);
let copy = duplicated
.lines()
.find_map(|line| line.strip_prefix("Duplicated "))
.unwrap()
.trim()
.to_string();
assert_ne!(copy, id, "a duplicate gets a new stable id");
let shown = stdout(dir, &["view", "show", ©]);
assert!(shown.contains("name: Copy"), "{shown}");
assert!(shown.contains("query: status:todo"), "{shown}");
assert!(shown.contains("sort: due"), "{shown}");
assert_eq!(stdout(dir, &["view", "list"]).lines().count(), 2);
assert!(stdout(dir, &["view", "show", &id]).contains("name: Active"));
}
#[test]
fn share_replaces_only_the_scope_metadata() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(dir, "Active", &["--filter", "status:todo"]);
let shared = stdout(
dir,
&[
"view",
"share",
&id,
"--scope",
"user",
"--owner",
"alice",
"--shared-with",
"bob",
],
);
assert!(shared.contains(&format!("Shared {id}")), "{shared}");
let shown = stdout(dir, &["view", "show", &id]);
assert!(
shown.contains("scope: user owner=alice shared_with=bob"),
"{shown}"
);
assert!(
shown.contains("query: status:todo"),
"the plan survived: {shown}"
);
let user_rows = stdout(dir, &["view", "list", "--scope", "user"]);
assert!(user_rows.contains(&id), "{user_rows}");
assert!(stdout(dir, &["view", "list", "--scope", "project"]).is_empty());
}
#[test]
fn a_scope_conflict_is_refused_and_writes_no_view() {
let tmp = repo();
let dir = tmp.path();
let before = read_config(dir);
let conflicts: &[&[&str]] = &[
&["view", "create", "--name", "R", "--project", "p1"],
&["view", "create", "--name", "P", "--scope", "project"],
&["view", "create", "--name", "U", "--scope", "user"],
];
for args in conflicts {
tix()
.current_dir(dir)
.args(*args)
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("scope_conflict"));
}
assert_eq!(read_config(dir), before, "a refusal writes no view");
assert!(stdout(dir, &["view", "list"]).is_empty());
}
#[test]
fn a_project_scope_that_does_not_resolve_is_refused() {
let tmp = repo();
let dir = tmp.path();
tix()
.current_dir(dir)
.args([
"view",
"create",
"--name",
"P",
"--scope",
"project",
"--project",
"p-platform",
])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("not in the project registry"));
assert!(stdout(dir, &["view", "list"]).is_empty());
}
#[test]
fn an_unknown_view_is_a_not_found_exit() {
let tmp = repo();
let dir = tmp.path();
for args in [
vec!["view", "show", "nope"],
vec!["view", "apply", "nope"],
vec!["view", "delete", "nope", "--confirm"],
] {
tix()
.current_dir(dir)
.args(&args)
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("no saved view matches"));
}
}
#[test]
fn delete_without_confirmation_is_refused_and_writes_nothing() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(dir, "Active", &["--filter", "status:todo"]);
let before = read_config(dir);
tix()
.current_dir(dir)
.args(["view", "delete", &id])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("--confirm"));
assert_eq!(
read_config(dir),
before,
"an unconfirmed delete writes nothing"
);
assert!(stdout(dir, &["view", "show", &id]).contains("name: Active"));
}
#[test]
fn delete_removes_only_the_definition() {
let tmp = repo();
let dir = tmp.path();
issue(dir, "Fix login");
let id = create_view(dir, "Active", &["--filter", "status:todo"]);
let issues_before = issue_snapshot(dir);
let deleted = stdout(dir, &["view", "delete", &id, "--confirm"]);
assert!(deleted.contains(&format!("Deleted {id}")), "{deleted}");
assert!(!read_config(dir).contains(&id), "the definition is gone");
assert!(stdout(dir, &["view", "list"]).is_empty());
assert_eq!(issue_snapshot(dir), issues_before);
}
#[test]
fn apply_is_read_only_and_reports_the_result_and_active_marker() {
let tmp = repo();
let dir = tmp.path();
issue(dir, "Fix login");
issue(dir, "Ship board");
let id = create_view(dir, "Active", &["--filter", "status:todo"]);
let config_before = read_config(dir);
let issues_before = issue_snapshot(dir);
let applied = stdout(dir, &["view", "apply", &id]);
assert!(applied.contains(&format!("id: {id}")), "{applied}");
assert!(applied.contains("query: status:todo"), "{applied}");
assert!(applied.contains(&format!("active: {id}")), "{applied}");
let mut listed: Vec<&str> = applied
.lines()
.find_map(|line| line.strip_prefix("issues: "))
.expect("the apply names the matching issues")
.split(',')
.collect();
listed.sort_unstable();
assert_eq!(listed, vec!["1", "2"], "{applied}");
assert!(applied.contains("count: 2"), "{applied}");
assert_eq!(read_config(dir), config_before, "apply wrote tix.toml");
assert_eq!(issue_snapshot(dir), issues_before, "apply wrote an issue");
}
#[test]
fn status_reports_field_level_divergence_and_in_sync() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(dir, "Active", &["--filter", "status:todo", "--sort", "due"]);
let divergent = stdout(dir, &["view", "status", &id, "--filter", "label:urgent"]);
assert!(divergent.contains("in-sync: false"), "{divergent}");
assert!(
divergent.contains("difference: plan.text_query saved=status:todo live=label:urgent"),
"{divergent}"
);
assert!(
divergent.contains("difference: plan.sort saved=due live=(unset)"),
"{divergent}"
);
let matching = stdout(
dir,
&[
"view",
"status",
&id,
"--filter",
"status:todo",
"--sort",
"due",
],
);
assert!(matching.contains("in-sync: true"), "{matching}");
assert!(!matching.contains("difference:"), "{matching}");
}
#[test]
fn status_without_an_id_reports_the_missing_marker_instead_of_in_sync() {
let tmp = repo();
let dir = tmp.path();
let out = stdout(dir, &["view", "status"]);
assert!(out.contains("in-sync: false"), "{out}");
assert!(
out.contains("no active saved view is selected"),
"a missing marker is an explicit diagnostic: {out}"
);
}
#[test]
fn unknown_metadata_is_reported_not_refused_and_survives_an_update() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(dir, "Active", &["--filter", "status:todo"]);
let config = read_config(dir);
let injected = config.replace(
&format!("[views.{id}]"),
&format!("[views.{id}]\nx-extra = \"kept\""),
);
assert_ne!(config, injected, "the view section must exist to augment");
fs::write(config_path(dir), &injected).unwrap();
let shown = stdout(dir, &["view", "show", &id]);
assert!(shown.contains("unknown_metadata"), "{shown}");
assert!(shown.contains("x-extra"), "{shown}");
stdout(dir, &["view", "update", &id, "--name", "Renamed"]);
assert!(
read_config(dir).contains("x-extra = \"kept\""),
"an update must not drop unknown metadata"
);
}
#[test]
fn visibility_is_an_absolute_snapshot_and_invalid_columns_are_refused() {
let tmp = repo();
let dir = tmp.path();
let id = create_view(
dir,
"Board",
&["--hide-columns", "done", "--show-mismatch", "true"],
);
let shown = stdout(dir, &["view", "show", &id]);
assert!(shown.contains("hide-columns: done"), "{shown}");
assert!(shown.contains("show-mismatch: true"), "{shown}");
tix()
.current_dir(dir)
.args(["view", "create", "--name", "Bad", "--hide-columns", "nope"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("does not configure"));
}
#[test]
fn an_invalid_sort_key_is_refused_before_persistence() {
let tmp = repo();
let dir = tmp.path();
let before = read_config(dir);
tix()
.current_dir(dir)
.args(["view", "create", "--name", "S", "--sort", "bogus"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("not a supported sort key"));
assert_eq!(read_config(dir), before);
}
#[test]
fn printing_an_issue_and_the_existing_query_surfaces_are_unchanged() {
let tmp = repo();
let dir = tmp.path();
issue(dir, "Fix login bug");
let printed = stdout(dir, &["view", "1"]);
assert!(printed.contains("title: Fix login bug"), "{printed}");
assert!(stdout(dir, &["show", "1"]).contains("title: Fix login bug"));
tix()
.current_dir(dir)
.args(["view", "1", "--show", "relationships"])
.assert()
.success();
tix()
.current_dir(dir)
.args(["view", "1", "--show", "bogus"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("unknown --show value"));
assert!(stdout(dir, &["list", "--filter", "status:todo"]).contains("Fix login bug"));
assert!(stdout(dir, &["status"]).contains("Total"));
tix()
.current_dir(dir)
.arg("view")
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains(
"needs an issue selector or a subcommand",
));
}