tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix init-agents`: write an AI-usage section into `AGENTS.md` so that
//! autonomous agents in this repo know the exact CLI syntax.
//!
//! The section is bounded by `<!-- tix:start -->` / `<!-- tix:end -->`
//! markers.  A second run replaces the content between the markers
//! (idempotent, C-009).  Unrelated content in `AGENTS.md` is never
//! touched.

use std::path::{Path, PathBuf};

use crate::error::Result;

/// Marker pair that delimit the tix section in `AGENTS.md`.
const MARKER_START: &str = "<!-- tix:start -->";
const MARKER_END: &str = "<!-- tix:end -->";

/// Run `tix init-agents`: locate `AGENTS.md` in `cwd`, create or update
/// the idempotent tix block.  Returns the path that was written.
pub fn run(cwd: &Path) -> Result<PathBuf> {
    let path = cwd.join("AGENTS.md");
    // A single `.kittify/` existence check (C-006/NFR-008); no marker
    // probing, no config reads, no network.
    let spec_kitty = std::fs::metadata(cwd.join(".kittify"))
        .map(|m| m.is_dir())
        .unwrap_or(false);
    let block = build_section(spec_kitty);

    if path.exists() {
        let content = std::fs::read_to_string(&path)?;
        let new = insert_block(&content, &block);
        if new == content {
            println!("AGENTS.md already up to date");
        } else {
            atomic_write(&path, &new)?;
            println!("Updated {}", path.display());
        }
    } else {
        let new = format!("{}\n", block);
        atomic_write(&path, &new)?;
        println!("Created {}", path.display());
    }
    Ok(path)
}

/// The spec-kitty dogfooding section, rendered verbatim (FR-019) when
/// `.kittify/` is detected (see `contracts/agent-dogfooding.md`).
const DOGFOODING_SECTION: &str = "### Tracking Spec Kitty tasks with tix

This project runs Spec Kitty (detected via `.kittify/`). Use tix to track its task issues:

- Filing a Spec Kitty task/work package → `tix create --title \"<wp title>\" --priority high`
- Opening an issue → `tix list --status \"todo\"`
- Marking a task done → `tix move <number> done` (interactive); the commit hash is
  optional — pass `tix move <number> done --commit <hash>` to record it non-interactively
- Commenting on a task issue → `tix edit <number>` (body/comment section is plain markdown)";

/// Build the tix section for `AGENTS.md`.
///
/// Derived from `contracts/cli.md` — every clap-defined subcommand must
/// appear verbatim in the output (tested by a unit-level guard).
///
/// When `detect_spec_kitty` is `true` the output additionally contains the
/// dogfooding section between `### Updating` and `### Storage layout`
/// (FR-019); when `false` the output is byte-identical to today's block
/// (FR-020, locked by a golden test).
pub fn build_section(detect_spec_kitty: bool) -> String {
    // The generated block is built from constants so that its text is
    // deterministic across runs (no timestamps, no commit hashes).
    let mut section = format!(
        "{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.
{end}",
        start = MARKER_START,
        end = MARKER_END,
    );
    if detect_spec_kitty {
        // Splice the dogfooding block in after `### Updating`, before
        // `### Storage layout` (contract placement).
        section = section.replace(
            "### Storage layout",
            &format!("{DOGFOODING_SECTION}\n\n### Storage layout"),
        );
    }
    section
}

/// Insert or replace the tix block in `existing` with `block`.
///
/// - Both markers found → replace everything between them (inclusive).
/// - Only one marker → treat the file as unrelated, append a fresh block.
/// - No markers → append.
fn insert_block(existing: &str, block: &str) -> String {
    let has_start = existing.contains(MARKER_START);
    let has_end = existing.contains(MARKER_END);

    match (has_start, has_end) {
        (true, true) => {
            let start = existing.find(MARKER_START).unwrap();
            let end = existing.find(MARKER_END).unwrap() + MARKER_END.len();
            let region = &existing[start..end];
            // The block is byte-identical (ignoring surrounding whitespace):
            // keep the file untouched so re-runs are fully idempotent.
            if region.trim() == block.trim() {
                return existing.to_string();
            }
            let before = &existing[..start];
            let after = &existing[end..];
            format!("{before}{block}{after}")
        }
        (true, false) | (false, true) => {
            // Malformed — only one marker.  Treat as unrelated, append.
            let trimmed = existing.trim_end();
            format!("{}\n{}\n", trimmed, block)
        }
        (false, false) => {
            let trimmed = existing.trim_end();
            if trimmed.is_empty() {
                format!("{}\n", block)
            } else {
                format!("{}\n\n{}\n", trimmed, block)
            }
        }
    }
}

/// Atomic write: write to a sibling temp file, then rename over the target.
fn atomic_write(path: &Path, content: &str) -> Result<()> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    let tmp = parent.join(".tix-agents-tmp");
    std::fs::write(&tmp, content)?;
    std::fs::rename(&tmp, path)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_section_contains_every_clap_subcommand() {
        // Both detection states advertise the full CLI surface; the guard's
        // `expected` list is the arbiter and stays in sync with cli.rs.
        for spec_kitty in [false, true] {
            let section = build_section(spec_kitty);
            let expected = [
                "init",
                "init-agents",
                "create",
                "edit",
                "move",
                "view",
                "list",
                "status",
                "delete",
                "export",
                "import",
            ];
            for cmd in &expected {
                assert!(
                    section.contains(&format!("`tix {cmd}")),
                    "build_section({spec_kitty}) output missing subcommand {cmd}"
                );
            }
        }
    }

    #[test]
    fn build_section_has_marker_pair() {
        for spec_kitty in [false, true] {
            let s = build_section(spec_kitty);
            assert!(s.starts_with(MARKER_START), "bad start for {spec_kitty}");
            assert!(s.ends_with(MARKER_END), "bad end for {spec_kitty}");
        }
    }

    /// The golden pre-mission block: `build_section(false)` must be exactly
    /// this (FR-020). A future unrelated block change must update it
    /// deliberately.
    #[test]
    fn build_section_false_matches_golden_pre_mission_block() {
        let golden = "\
<!-- 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 -->";
        assert_eq!(build_section(false), golden);
    }

    #[test]
    fn build_section_true_contains_dogfooding_section() {
        let s = build_section(true);
        assert!(s.contains("### Tracking Spec Kitty tasks with tix"));
        for bullet in [
            "`tix create --title \"<wp title>\" --priority high`",
            "`tix list --status \"todo\"`",
            "`tix move <number> done`",
            "`tix edit <number>`",
        ] {
            assert!(s.contains(bullet), "missing bullet {bullet}");
        }
        // Placement: after "### Updating" and before "### Storage layout".
        let updating_end = s.find("### Updating").unwrap() + "### Updating".len();
        let storage_start = s.find("### Storage layout").unwrap();
        let slice = &s[updating_end..storage_start];
        assert!(
            slice.contains("### Tracking Spec Kitty tasks with tix"),
            "dogfooding section not placed between Updating and Storage layout"
        );
    }

    #[test]
    fn build_section_true_builds_on_golden_block() {
        let base = build_section(false);
        let s = build_section(true);
        // The dogfooding block is a strict superset with the exact insertion
        // marker boundary preserved.
        assert!(s.starts_with(&base[..base.find("### Updating").unwrap()]));
        assert!(s.ends_with(&base[base.find("### Storage layout").unwrap()..]));
        assert!(s.len() > base.len());
    }

    #[test]
    fn insert_block_replaces_between_markers() {
        let before = "line 1\nline 2\n";
        let after = "\nline 3\n";
        let existing = format!(
            "{}{}OLD_CONTENT{}{}",
            before, MARKER_START, MARKER_END, after
        );
        let new_block = format!("{}\nNEW_CONTENT\n{}", MARKER_START, MARKER_END);
        let result = insert_block(&existing, &new_block);
        assert!(result.starts_with("line 1\nline 2\n"));
        assert!(result.contains("NEW_CONTENT"));
        assert!(result.contains("line 3"));
        assert!(!result.contains("OLD_CONTENT"));
    }

    #[test]
    fn insert_block_appends_when_no_markers() {
        let existing = "some existing text\n";
        let new_block = "block";
        let result = insert_block(existing, new_block);
        assert!(result.contains("some existing text"));
        assert!(result.contains("block"));
    }

    #[test]
    fn insert_block_appends_when_single_marker() {
        let existing = "text <!-- tix:start --> orphaned\n";
        let new_block = format!("{}\nfresh\n{}", MARKER_START, MARKER_END);
        let result = insert_block(existing, &new_block);
        assert!(result.contains("text <!-- tix:start --> orphaned"));
        assert!(result.contains("fresh"));
        // Original stray marker + the appended block's marker.
        assert_eq!(result.matches(MARKER_START).count(), 2);
        assert_eq!(result.matches(MARKER_END).count(), 1);
    }

    #[test]
    fn insert_block_handles_empty_existing() {
        let existing = "";
        let new_block = format!("{}\nblock\n{}", MARKER_START, MARKER_END);
        let result = insert_block(existing, &new_block);
        assert!(result.contains("block"));
        assert_eq!(result.matches(MARKER_START).count(), 1);
    }

    #[test]
    fn atomic_write_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("AGENTS.md");
        atomic_write(&path, "hello").unwrap();
        assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
    }
}