use std::path::{Path, PathBuf};
use crate::error::Result;
const MARKER_START: &str = "<!-- tix:start -->";
const MARKER_END: &str = "<!-- tix:end -->";
pub fn run(cwd: &Path) -> Result<PathBuf> {
let path = cwd.join("AGENTS.md");
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)
}
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)";
pub fn build_section(detect_spec_kitty: bool) -> String {
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 {
section = section.replace(
"### Storage layout",
&format!("{DOGFOODING_SECTION}\n\n### Storage layout"),
);
}
section
}
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];
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) => {
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)
}
}
}
}
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() {
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}");
}
}
#[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}");
}
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);
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"));
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");
}
}