use std::path::{Path, PathBuf};
use crate::error::Result;
const MARKER_START: &str = "<!-- tix:start -->";
const MARKER_END: &str = "<!-- tix:end -->";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Detected {
pub spec_kitty: bool,
pub openspec: bool,
}
impl Detected {
pub const NONE: Self = Self {
spec_kitty: false,
openspec: false,
};
}
fn has_spec_kitty(root: &Path) -> bool {
std::fs::metadata(root.join(".kittify"))
.map(|m| m.is_dir())
.unwrap_or(false)
}
fn has_openspec(root: &Path) -> bool {
std::fs::metadata(root.join("openspec"))
.map(|m| m.is_dir())
.unwrap_or(false)
}
pub fn detect(root: &Path) -> Detected {
Detected {
spec_kitty: has_spec_kitty(root),
openspec: has_openspec(root),
}
}
pub fn detection_report(detected: Detected) -> Vec<String> {
let mut lines = Vec::new();
if detected.spec_kitty {
lines.push("Detected Spec Kitty (via .kittify/)".to_string());
}
if detected.openspec {
lines.push("Detected OpenSpec (via openspec/)".to_string());
}
lines
}
pub fn run(cwd: &Path) -> Result<PathBuf> {
let path = cwd.join("AGENTS.md");
let detected = detect(cwd);
for line in detection_report(detected) {
println!("{line}");
}
let block = build_section(detected);
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)
- Linking related task issues → `tix link <a> <b>` (symmetry is maintained; drop it with `tix unlink <a> <b>`)
- Populating `files` with the task's spec path → `tix edit <number> --files \"<path>\"`";
const OPENSPEC_SECTION: &str = "### Tracking OpenSpec work with tix
This project uses OpenSpec (detected via `openspec/`). Use tix to track its changes and tasks:
- Filing an OpenSpec change/task → `tix create --title \"<change>: <task>\" --labels openspec`
- Add the change's proposal/spec/plan to the issue markdown body.
- 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)
- Linking related change/task issues → `tix link <a> <b>` (symmetry is maintained; drop it with `tix unlink <a> <b>`)
- Populating `files` with the change's spec path → `tix edit <number> --files \"<path>\"`";
pub fn build_section(detected: Detected) -> 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)
- `tix link <a> <b>` / `tix unlink <a> <b>` link/unlink a pair of issues (symmetric, idempotent)
### 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,
);
let mut tracking = Vec::new();
if detected.spec_kitty {
tracking.push(DOGFOODING_SECTION);
}
if detected.openspec {
tracking.push(OPENSPEC_SECTION);
}
if !tracking.is_empty() {
section = section.replace(
"### Storage layout",
&format!("{}\n\n### Storage layout", tracking.join("\n\n")),
);
}
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::*;
fn states() -> [Detected; 4] {
[
Detected::NONE,
Detected {
spec_kitty: true,
openspec: false,
},
Detected {
spec_kitty: false,
openspec: true,
},
Detected {
spec_kitty: true,
openspec: true,
},
]
}
#[test]
fn build_section_contains_every_clap_subcommand() {
for detected in states() {
let section = build_section(detected);
let expected = [
"init",
"init-agents",
"create",
"edit",
"move",
"view",
"list",
"status",
"delete",
"link",
"unlink",
"export",
"import",
];
for cmd in &expected {
assert!(
section.contains(&format!("`tix {cmd}")),
"build_section({detected:?}) output missing subcommand {cmd}"
);
}
}
}
#[test]
fn build_section_has_marker_pair() {
for detected in states() {
let s = build_section(detected);
assert!(s.starts_with(MARKER_START), "bad start for {detected:?}");
assert!(s.ends_with(MARKER_END), "bad end for {detected:?}");
}
}
#[test]
fn build_section_none_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)
- `tix link <a> <b>` / `tix unlink <a> <b>` link/unlink a pair of issues (symmetric, idempotent)
### 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(Detected::NONE), golden);
}
#[test]
fn build_section_none_omits_both_sections() {
let s = build_section(Detected::NONE);
assert!(!s.contains("### Tracking Spec Kitty tasks with tix"));
assert!(!s.contains("### Tracking OpenSpec work with tix"));
}
#[test]
fn build_section_spec_kitty_contains_dogfooding_section() {
let s = build_section(Detected {
spec_kitty: true,
openspec: false,
});
assert!(s.contains("### Tracking Spec Kitty tasks with tix"));
assert!(!s.contains("### Tracking OpenSpec work with tix"));
for bullet in [
"`tix create --title \"<wp title>\" --priority high`",
"`tix list --status \"todo\"`",
"`tix move <number> done`",
"`tix edit <number>`",
"`tix link <a> <b>`",
"`tix edit <number> --files \"<path>\"`",
] {
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_openspec_contains_openspec_section() {
let s = build_section(Detected {
spec_kitty: false,
openspec: true,
});
assert!(s.contains("### Tracking OpenSpec work with tix"));
assert!(!s.contains("### Tracking Spec Kitty tasks with tix"));
assert!(s.contains("detected via `openspec/`"));
for bullet in [
"`tix create --title \"<change>: <task>\" --labels openspec`",
"`tix list --status \"todo\"`",
"`tix move <number> done`",
"`tix edit <number>`",
"`tix link <a> <b>`",
"`tix edit <number> --files \"<path>\"`",
] {
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 OpenSpec work with tix"),
"openspec section not placed between Updating and Storage layout"
);
}
#[test]
fn build_section_both_contains_both_sections_in_order() {
let s = build_section(Detected {
spec_kitty: true,
openspec: true,
});
let kitty = s.find("### Tracking Spec Kitty tasks with tix").unwrap();
let openspec = s.find("### Tracking OpenSpec work with tix").unwrap();
assert!(kitty < openspec, "spec-kitty section must precede openspec");
assert_eq!(
s.matches("### Tracking Spec Kitty tasks with tix").count(),
1
);
assert_eq!(s.matches("### Tracking OpenSpec work with tix").count(), 1);
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"));
assert!(slice.contains("### Tracking OpenSpec work with tix"));
}
#[test]
fn build_section_spec_kitty_builds_on_golden_block() {
let base = build_section(Detected::NONE);
let s = build_section(Detected {
spec_kitty: true,
openspec: false,
});
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 build_section_openspec_builds_on_golden_block() {
let base = build_section(Detected::NONE);
let s = build_section(Detected {
spec_kitty: false,
openspec: 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 detection_report_prints_one_line_per_detected_framework() {
assert!(detection_report(Detected::NONE).is_empty());
assert_eq!(
detection_report(Detected {
spec_kitty: true,
openspec: false,
}),
vec!["Detected Spec Kitty (via .kittify/)".to_string()]
);
assert_eq!(
detection_report(Detected {
spec_kitty: false,
openspec: true,
}),
vec!["Detected OpenSpec (via openspec/)".to_string()]
);
assert_eq!(
detection_report(Detected {
spec_kitty: true,
openspec: true,
}),
vec![
"Detected Spec Kitty (via .kittify/)".to_string(),
"Detected OpenSpec (via openspec/)".to_string(),
]
);
}
#[test]
fn detection_report_lines_match_the_generated_sections() {
for detected in states() {
let report = detection_report(detected);
let section = build_section(detected);
let reports_spec_kitty = report.iter().any(|l| l.contains("Spec Kitty"));
let reports_openspec = report.iter().any(|l| l.contains("OpenSpec"));
assert_eq!(
reports_spec_kitty,
section.contains("### Tracking Spec Kitty tasks with tix"),
"{detected:?}: report and section disagree on Spec Kitty"
);
assert_eq!(
reports_openspec,
section.contains("### Tracking OpenSpec work with tix"),
"{detected:?}: report and section disagree on OpenSpec"
);
assert_eq!(
report.iter().any(|l| l.contains(".kittify/")),
reports_spec_kitty,
"{detected:?}: Spec Kitty line must name the marker dir"
);
assert_eq!(
report.iter().any(|l| l.contains("openspec/")),
reports_openspec,
"{detected:?}: OpenSpec line must name the marker dir"
);
}
}
#[test]
fn detect_flags_present_directories() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(detect(dir.path()), Detected::NONE);
std::fs::create_dir_all(dir.path().join(".kittify")).unwrap();
assert_eq!(
detect(dir.path()),
Detected {
spec_kitty: true,
openspec: false,
}
);
std::fs::create_dir_all(dir.path().join("openspec")).unwrap();
assert_eq!(
detect(dir.path()),
Detected {
spec_kitty: true,
openspec: true,
}
);
}
#[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");
}
}