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::commands::agents::build_section(tix::commands::agents::Detected::NONE)
}
const DOGFOODING_HEADING: &str = "### Tracking Spec Kitty tasks with tix";
const OPENSPEC_HEADING: &str = "### Tracking OpenSpec work with tix";
const SPEC_KIT_HEADING: &str = "### Tracking Spec Kit work 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());
assert!(!tmp.path().join("openspec").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!(
!block.contains(OPENSPEC_HEADING),
"openspec section must not appear without openspec/"
);
assert!(
!block.contains(SPEC_KIT_HEADING),
"spec-kit section must not appear without .specify/"
);
assert_eq!(block.trim(), golden_block());
}
#[test]
fn openspec_present_includes_openspec_section() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("openspec")).unwrap();
assert!(!tmp.path().join(".kittify").exists());
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(OPENSPEC_HEADING), "missing openspec heading");
assert!(block.contains("`openspec/`"));
assert!(block.contains("--labels openspec"));
assert!(block.contains("Opening an issue"));
assert!(
!block.contains(DOGFOODING_HEADING),
"spec-kitty section must not appear without .kittify/"
);
assert!(
!block.contains(SPEC_KIT_HEADING),
"spec-kit section must not appear without .specify/"
);
assert_eq!(block.matches(MARKER_START).count(), 1);
assert_eq!(block.matches(MARKER_END).count(), 1);
}
#[test]
fn spec_kit_present_includes_spec_kit_section() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".specify")).unwrap();
assert!(!tmp.path().join(".kittify").exists());
assert!(!tmp.path().join("openspec").exists());
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(SPEC_KIT_HEADING), "missing spec-kit heading");
assert!(block.contains("`.specify/`"));
assert!(block.contains("--labels speckit"));
assert!(block.contains("Opening an issue"));
assert!(
!block.contains(DOGFOODING_HEADING),
"spec-kitty section must not appear without .kittify/"
);
assert!(
!block.contains(OPENSPEC_HEADING),
"openspec section must not appear without openspec/"
);
assert_eq!(block.matches(MARKER_START).count(), 1);
assert_eq!(block.matches(MARKER_END).count(), 1);
}
#[test]
fn all_three_frameworks_include_all_sections_in_order() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".kittify")).unwrap();
fs::create_dir_all(tmp.path().join("openspec")).unwrap();
fs::create_dir_all(tmp.path().join(".specify")).unwrap();
let out = run_in(tmp.path());
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let block = read_agents(tmp.path());
let kitty = block.find(DOGFOODING_HEADING).unwrap();
let openspec = block.find(OPENSPEC_HEADING).unwrap();
let spec_kit = block.find(SPEC_KIT_HEADING).unwrap();
assert!(
kitty < openspec && openspec < spec_kit,
"sections must appear Spec Kitty, OpenSpec, Spec Kit"
);
assert_eq!(block.matches(DOGFOODING_HEADING).count(), 1);
assert_eq!(block.matches(OPENSPEC_HEADING).count(), 1);
assert_eq!(block.matches(SPEC_KIT_HEADING).count(), 1);
let stdout = String::from_utf8_lossy(&out.stdout);
let kitty_line = stdout.find("Detected Spec Kitty (via .kittify/)").unwrap();
let openspec_line = stdout.find("Detected OpenSpec (via openspec/)").unwrap();
let spec_kit_line = stdout.find("Detected Spec Kit (via .specify/)").unwrap();
assert!(kitty_line < openspec_line && openspec_line < spec_kit_line);
}
#[test]
fn committed_agents_block_matches_the_current_binary() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let agents = fs::read_to_string(root.join("AGENTS.md")).expect("repo AGENTS.md");
let start = agents.find(MARKER_START).expect("tix start marker");
let end = agents.find(MARKER_END).expect("tix end marker") + MARKER_END.len();
let committed = &agents[start..end];
let expected = tix::commands::agents::build_section(tix::commands::agents::detect(root));
assert_eq!(
committed, expected,
"the committed tix block is stale; run `tix init-agents`"
);
}
#[test]
fn spec_kit_flip_does_not_duplicate_or_stale() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join(".specify")).unwrap();
let _ = run_in(tmp.path());
let with_section = read_agents(tmp.path());
assert!(with_section.contains(SPEC_KIT_HEADING));
fs::remove_dir_all(tmp.path().join(".specify")).unwrap();
let _ = run_in(tmp.path());
let without = read_agents(tmp.path());
assert!(
!without.contains(SPEC_KIT_HEADING),
"stale spec-kit section after .specify/ removal"
);
assert_eq!(without.trim(), golden_block());
fs::create_dir_all(tmp.path().join(".specify")).unwrap();
let _ = run_in(tmp.path());
let back_again = read_agents(tmp.path());
assert!(back_again.contains(SPEC_KIT_HEADING));
assert_eq!(
back_again.matches(SPEC_KIT_HEADING).count(),
1,
"duplicated spec-kit section after flip"
);
assert_eq!(back_again.matches(MARKER_START).count(), 1);
assert_eq!(back_again.matches(MARKER_END).count(), 1);
}
#[test]
fn openspec_flip_does_not_duplicate_or_stale() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("openspec")).unwrap();
let _ = run_in(tmp.path());
let with_section = read_agents(tmp.path());
assert!(with_section.contains(OPENSPEC_HEADING));
fs::remove_dir_all(tmp.path().join("openspec")).unwrap();
let _ = run_in(tmp.path());
let without = read_agents(tmp.path());
assert!(
!without.contains(OPENSPEC_HEADING),
"stale openspec section after openspec/ removal"
);
assert_eq!(without.trim(), golden_block());
fs::create_dir_all(tmp.path().join("openspec")).unwrap();
let _ = run_in(tmp.path());
let back_again = read_agents(tmp.path());
assert!(back_again.contains(OPENSPEC_HEADING));
assert_eq!(
back_again.matches(OPENSPEC_HEADING).count(),
1,
"duplicated openspec section after flip"
);
assert_eq!(back_again.matches(MARKER_START).count(), 1);
assert_eq!(back_again.matches(MARKER_END).count(), 1);
}
#[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}"
);
}