use std::fs;
use std::path::Path;
use std::process::Output;
use assert_cmd::Command;
use tempfile::TempDir;
const MARKER_START: &str = "<!-- tix:start -->";
const MARKER_END: &str = "<!-- tix:end -->";
fn tix() -> Command {
Command::cargo_bin("tix").unwrap()
}
fn run_in(dir: &Path) -> Output {
tix().current_dir(dir).arg("init-agents").output().unwrap()
}
fn read_agents(dir: &Path) -> String {
fs::read_to_string(dir.join("AGENTS.md")).unwrap()
}
fn golden_block() -> String {
"\
<!-- tix:start -->
## tix — issue tracker (AI agent usage)
Conventional usage for autonomous issue management. Run from the repository root.
### Viewing
- `tix list` list issues (board ownership)
- `tix list --status \"in progress\"` filter by column
- `tix status` board summary: column -> count
- `tix view|show <number|slug>` full issue incl. frontmatter + body
- `tix init-agents` regenerate this AGENTS.md section
### Creating
- `tix create --title \"Title\" --priority high --labels bug --assignee @me`
- `tix create` interactive (needs a TTY)
- `--assignee @me` resolves to the configured git author identity; bare
handles like `@alice` are stored as-is. Without `--assignee`, the assignee
defaults to the git identity, falling back to unassigned when git is absent
(never an error)
### Updating
- `tix edit <number> --title \"New\" --priority urgent`
- `tix edit <number> --assignee <value>` physically moves the issue into the
new assignee's segment; `--assignee \"\"` clears it (moves to unassigned/)
- `tix move <number> \"in progress\"` move between board columns
- `tix move <number> done --commit <hash>` hand-picked finish (commit recorded)
### Storage layout
- New issues are stored grouped by assignee:
`docs/issues/<assignee>/YYYY-MM-DD-<slug>.md` (bare handles `@alice` and
full identities `Name <email>` both work; a missing assignee lands in
`docs/issues/unassigned/`; legacy flat files at `docs/issues/` still read).
### Closing
- `tix move <number> done` optional commit prompt (needs a TTY)
- `tix delete <number> --yes` remove an issue
### External sync (if available)
- `tix export --target gitlab` push local issues to GitLab
- `tix export --target linear` push local issues to Linear
- `tix import --target gitlab` pull open issues from GitLab
- `tix import --target linear` pull open issues from Linear
### Safety
- Root `tix.toml` defines columns; run `tix init` only to create the default.
- `.md` files under `docs/issues/` are the source of truth — a plain editor works too.
<!-- tix:end -->"
.to_string()
}
const DOGFOODING_HEADING: &str = "### Tracking Spec Kitty tasks with tix";
#[test]
fn missing_file_is_created_and_rerun_is_idempotent() {
let tmp = TempDir::new().unwrap();
assert!(!tmp.path().join("AGENTS.md").exists());
let first = run_in(tmp.path());
assert!(
first.status.success(),
"stderr: {}",
String::from_utf8_lossy(&first.stderr)
);
let stdout = String::from_utf8_lossy(&first.stdout).to_string();
assert!(stdout.contains("Created"), "stdout: {stdout}");
let once = read_agents(tmp.path());
assert!(once.contains(MARKER_START), "missing start marker: {once}");
assert!(once.contains(MARKER_END), "missing end marker: {once}");
assert!(once.contains("tix list"));
assert!(once.contains("tix create"));
assert!(once.contains("tix export"));
assert!(
once.contains("docs/issues/<assignee>/"),
"missing grouped-layout path: {once}"
);
assert!(
once.contains("unassigned/"),
"missing unassigned fallback: {once}"
);
assert!(
once.contains("--assignee"),
"missing --assignee semantics: {once}"
);
assert!(
once.contains("git"),
"missing git-identity default note: {once}"
);
let second = run_in(tmp.path());
assert!(second.status.success());
let twice = read_agents(tmp.path());
assert_eq!(once, twice, "re-run must not change AGENTS.md");
assert_eq!(twice.matches(MARKER_START).count(), 1, "duplicated block");
}
#[test]
fn unrelated_content_outside_markers_is_byte_preserved() {
let tmp = TempDir::new().unwrap();
let before = "line one\nline two\n";
let after = "\nline three\n";
let old_block = format!("{MARKER_START}\nstale block\n{MARKER_END}");
fs::write(
tmp.path().join("AGENTS.md"),
format!("{before}{old_block}{after}"),
)
.unwrap();
let out = run_in(tmp.path());
assert!(out.status.success());
let updated = read_agents(tmp.path());
assert!(
updated.starts_with(before),
"before region changed: {updated}"
);
assert!(updated.ends_with(after), "after region changed: {updated}");
assert!(!updated.contains("stale block"), "old block not replaced");
assert_eq!(updated.matches(MARKER_START).count(), 1);
let rerun = run_in(tmp.path());
assert!(rerun.status.success());
assert_eq!(read_agents(tmp.path()), updated, "re-run not idempotent");
}
#[test]
fn half_marker_file_is_appended_without_touching_original() {
let tmp = TempDir::new().unwrap();
let original = "some weird text <!-- tix:start --> that is incomplete\n";
fs::write(tmp.path().join("AGENTS.md"), original).unwrap();
let out = run_in(tmp.path());
assert!(out.status.success());
let updated = read_agents(tmp.path());
assert!(
updated.starts_with(original),
"original content must be preserved: {updated}"
);
assert_eq!(
updated.matches(MARKER_START).count(),
2,
"expected original + one appended block, got: {updated}"
);
assert_eq!(updated.matches(MARKER_END).count(), 1);
}
#[test]
fn kittify_present_includes_dogfooding_section() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".kittify")).unwrap();
let out = run_in(tmp.path());
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let block = read_agents(tmp.path());
assert!(
block.contains(DOGFOODING_HEADING),
"missing dogfooding heading"
);
assert!(block.contains("Filing a Spec Kitty task/work package"));
assert!(block.contains("Opening an issue"));
assert!(block.contains("Marking a task done"));
assert!(block.contains("Commenting on a task issue"));
assert_eq!(block.matches(MARKER_START).count(), 1);
assert_eq!(block.matches(MARKER_END).count(), 1);
}
#[test]
fn no_kittify_omits_dogfooding_section() {
let tmp = TempDir::new().unwrap();
assert!(!tmp.path().join(".kittify").exists());
let out = run_in(tmp.path());
assert!(out.status.success());
let block = read_agents(tmp.path());
assert!(
!block.contains(DOGFOODING_HEADING),
"dogfooding section must not appear without .kittify/"
);
assert_eq!(block.trim(), golden_block());
}
#[test]
fn kittify_flip_does_not_duplicate_or_stale() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".kittify")).unwrap();
let _ = run_in(tmp.path());
let with_section = read_agents(tmp.path());
assert!(with_section.contains(DOGFOODING_HEADING));
fs::remove_dir_all(tmp.path().join(".kittify")).unwrap();
let _ = run_in(tmp.path());
let without = read_agents(tmp.path());
assert!(
!without.contains(DOGFOODING_HEADING),
"stale dogfooding section after .kittify/ removal"
);
assert_eq!(without.trim(), golden_block());
fs::create_dir_all(tmp.path().join(".kittify")).unwrap();
let _ = run_in(tmp.path());
let back_again = read_agents(tmp.path());
assert!(back_again.contains(DOGFOODING_HEADING));
assert_eq!(
back_again.matches(DOGFOODING_HEADING).count(),
1,
"duplicated dogfooding section after flip"
);
assert_eq!(back_again.matches(MARKER_START).count(), 1);
assert_eq!(back_again.matches(MARKER_END).count(), 1);
}
#[test]
fn double_run_prints_already_up_to_date() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".kittify")).unwrap();
let first = run_in(tmp.path());
assert!(first.status.success());
let stdout = String::from_utf8_lossy(&first.stdout);
assert!(stdout.contains("Created"));
let second = run_in(tmp.path());
assert!(second.status.success());
let stdout = String::from_utf8_lossy(&second.stdout);
assert!(
stdout.contains("already up to date"),
"expected already-up-to-date message, got: {stdout}"
);
}