tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Integration tests for `tix init-agents`, driven through the compiled
//! binary only. Each test runs in a fresh temp dir standing in for the repo
//! root (AGENTS.md is written there, no tix.toml required).

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()
}

/// The verbatim golden pre-mission block (FR-020). Must match
/// `build_section(false)` exactly.
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"));
    // grouped-layout documentation (IC-06 / C-009): tix create defaults the
    // assignee from git, lands in an assignee segment, and plain files go to
    // unassigned/. `tix edit --assignee` moves the file between segments.
    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);
}

// T021 — spec-kitty detection + idempotency tests ------------------------------------

#[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();

    // Create with .kittify/ present.
    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));

    // Remove .kittify/ and re-run: section must disappear.
    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());

    // Re-create .kittify/ and re-run: section must return, no duplication.
    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}"
    );
}