use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn fr_bin() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("fr");
path
}
fn create_test_project(root: &Path) {
let frame_dir = root.join("frame");
fs::create_dir_all(frame_dir.join("tracks")).unwrap();
fs::write(frame_dir.join(".actor"), "null\n").unwrap();
fs::write(
frame_dir.join("project.toml"),
r#"[project]
name = "test-project"
[agent]
cc_focus = "main"
[[tracks]]
id = "main"
name = "Main Track"
state = "active"
file = "tracks/main.md"
[[tracks]]
id = "side"
name = "Side Track"
state = "active"
file = "tracks/side.md"
[ids.prefixes]
main = "M"
side = "S"
"#,
)
.unwrap();
fs::write(
frame_dir.join("tracks/main.md"),
"\
# Main Track
> The main work stream.
## Backlog
- [ ] `M-001` First task #core
- added: 2025-05-01
- [>] `M-002` Second task #core #cc
- added: 2025-05-02
- dep: M-001
- [ ] `M-003` Third task with subtasks
- added: 2025-05-03
- [ ] `M-003.1` Sub one
- added: 2025-05-03
- [ ] `M-003.2` Sub two
- added: 2025-05-03
## Parked
- [~] `M-010` Parked idea
- added: 2025-04-15
## Done
- [x] `M-000` Setup project
- added: 2025-04-20
- resolved: 2025-04-25
",
)
.unwrap();
fs::write(
frame_dir.join("tracks/side.md"),
"\
# Side Track
## Backlog
- [ ] `S-001` Side task one
- added: 2025-05-01
- [ ] `S-002` Side task two
- added: 2025-05-02
## Done
",
)
.unwrap();
fs::write(
frame_dir.join("inbox.md"),
"\
# Inbox
- Bug in parser #bug
Stack trace points to line 142.
- Think about design #design
- Quick note
",
)
.unwrap();
}
fn write_track(root: &Path, track_id: &str, body: &str) {
fs::write(
root.join("frame")
.join("tracks")
.join(format!("{track_id}.md")),
body,
)
.unwrap();
}
fn run_fr(dir: &Path, args: &[&str]) -> (String, String, bool) {
let output = Command::new(fr_bin())
.args(args)
.current_dir(dir)
.env("XDG_CONFIG_HOME", dir.join(".xdg-config"))
.output()
.expect("failed to run fr");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(stdout, stderr, output.status.success())
}
fn run_fr_env(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> (String, String, bool) {
let mut cmd = Command::new(fr_bin());
cmd.args(args)
.current_dir(dir)
.env("XDG_CONFIG_HOME", dir.join(".xdg-config"));
for (k, v) in env {
cmd.env(k, v);
}
let output = cmd.output().expect("failed to run fr");
(
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
output.status.success(),
)
}
fn run_fr_ok(dir: &Path, args: &[&str]) -> String {
let (stdout, stderr, success) = run_fr(dir, args);
if !success {
panic!(
"fr {:?} failed:\nstdout: {}\nstderr: {}",
args, stdout, stderr
);
}
stdout
}
#[test]
fn test_list_default() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["list"]);
assert!(out.contains("Main Track"));
assert!(out.contains("M-001"));
assert!(out.contains("Side Track"));
assert!(out.contains("S-001"));
assert!(
!out.contains("M-000"),
"default list should not show done tasks"
);
}
#[test]
fn test_list_state_done_shows_done_tasks() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let human = run_fr_ok(tmp.path(), &["list", "main", "--state", "done"]);
assert!(
human.contains("M-000") && human.contains("Setup project"),
"human list --state done should show the done task: {human}"
);
assert!(
!human.contains("M-001"),
"should not show todo tasks: {human}"
);
let json = run_fr_ok(tmp.path(), &["list", "main", "--state", "done", "--json"]);
assert!(
json.contains("M-000"),
"json list --state done should include M-000"
);
}
#[test]
fn test_projects_prune_removes_not_found() {
let base = tempfile::TempDir::new().unwrap();
let live = base.path().join("live");
let ghost = base.path().join("ghost");
create_test_project(&live);
create_test_project(&ghost);
run_fr_ok(base.path(), &["projects", "add", live.to_str().unwrap()]);
run_fr_ok(base.path(), &["projects", "add", ghost.to_str().unwrap()]);
fs::remove_dir_all(&ghost).unwrap();
let dry = run_fr_ok(base.path(), &["projects", "prune", "--dry-run", "--json"]);
assert!(dry.contains("ghost"));
assert!(!dry.contains("\"live\"") && !dry.contains("/live\""));
let still = run_fr_ok(base.path(), &["projects", "list", "--json"]);
assert!(still.contains("/ghost"), "dry-run must not remove anything");
let pruned = run_fr_ok(base.path(), &["projects", "prune"]);
assert!(pruned.contains("Removed 1 not-found project"));
let after = run_fr_ok(base.path(), &["projects", "list", "--json"]);
assert!(after.contains("/live"));
assert!(!after.contains("/ghost"));
let again = run_fr_ok(base.path(), &["projects", "prune"]);
assert!(again.contains("No not-found projects"));
}
#[test]
fn test_list_specific_track() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["list", "main"]);
assert!(out.contains("M-001"));
assert!(!out.contains("S-001"));
}
#[test]
fn test_list_with_state_filter() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["list", "main", "--state", "active"]);
assert!(out.contains("M-002"));
assert!(!out.contains("M-001")); }
#[test]
fn test_list_with_tag_filter() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["list", "main", "--tag", "cc"]);
assert!(out.contains("M-002"));
assert!(!out.contains("M-001")); }
#[test]
fn test_list_json() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["list", "main", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.is_array());
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1); assert_eq!(arr[0]["track"], "main");
assert!(arr[0]["tasks"].is_array());
}
#[test]
fn test_show() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["show", "M-001"]);
assert!(out.contains("First task"));
assert!(out.contains("added: 2025-05-01"));
}
#[test]
fn test_show_json() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["show", "M-002", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["id"], "M-002");
assert_eq!(parsed["state"], "active");
assert!(
parsed["deps"]
.as_array()
.unwrap()
.contains(&serde_json::json!("M-001"))
);
}
#[test]
fn test_show_not_found() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (_stdout, stderr, success) = run_fr(tmp.path(), &["show", "NOEXIST-999"]);
assert!(!success);
assert!(stderr.contains("not found"));
}
#[test]
fn test_ready() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["ready"]);
assert!(out.contains("M-001"));
assert!(!out.contains("M-002"));
assert!(out.contains("S-001"));
}
#[test]
fn test_ready_cc() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let frame_dir = tmp.path().join("frame");
fs::write(
frame_dir.join("tracks/side.md"),
"\
# Side Track
## Backlog
- [ ] `S-001` Side task one
- added: 2025-05-01
- [ ] `S-002` Side task two #cc
- added: 2025-05-02
## Done
",
)
.unwrap();
let out = run_fr_ok(tmp.path(), &["ready", "--cc"]);
assert!(out.contains("S-002"));
assert!(!out.contains("M-001"));
assert!(!out.contains("M-002"));
assert!(!out.contains("S-001"));
}
#[test]
fn test_ready_cc_no_focus() {
let tmp = tempfile::TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
fs::create_dir_all(frame_dir.join("tracks")).unwrap();
fs::write(
frame_dir.join("project.toml"),
r#"[project]
name = "test-project"
[[tracks]]
id = "main"
name = "Main Track"
state = "active"
file = "tracks/main.md"
[ids.prefixes]
main = "M"
"#,
)
.unwrap();
fs::write(
frame_dir.join("tracks/main.md"),
"\
# Main Track
## Backlog
- [ ] `M-001` Task with cc #cc
- added: 2025-05-01
## Done
",
)
.unwrap();
fs::write(frame_dir.join("inbox.md"), "# Inbox\n").unwrap();
let out = run_fr_ok(tmp.path(), &["ready", "--cc"]);
assert!(out.contains("M-001"));
}
#[test]
fn test_ready_cc_ordering() {
let tmp = tempfile::TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
fs::create_dir_all(frame_dir.join("tracks")).unwrap();
fs::write(
frame_dir.join("project.toml"),
r#"[project]
name = "test-project"
[agent]
cc_focus = "main"
[[tracks]]
id = "main"
name = "Main Track"
state = "active"
file = "tracks/main.md"
[[tracks]]
id = "side"
name = "Side Track"
state = "active"
file = "tracks/side.md"
[ids.prefixes]
main = "M"
side = "S"
"#,
)
.unwrap();
fs::write(
frame_dir.join("tracks/main.md"),
"\
# Main Track
## Backlog
- [ ] `M-001` Main cc task #cc
- added: 2025-05-01
## Done
",
)
.unwrap();
fs::write(
frame_dir.join("tracks/side.md"),
"\
# Side Track
## Backlog
- [ ] `S-001` Side cc task #cc
- added: 2025-05-01
## Done
",
)
.unwrap();
fs::write(frame_dir.join("inbox.md"), "# Inbox\n").unwrap();
let out = run_fr_ok(tmp.path(), &["ready", "--cc", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let tasks = parsed["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0]["track"].as_str().unwrap(), "main");
assert_eq!(tasks[1]["track"].as_str().unwrap(), "side");
}
#[test]
fn test_track_cc_focus_clear() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["track", "cc-focus", "--clear"]);
assert!(out.contains("cleared"));
let config_text = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(!config_text.contains("cc_focus"));
let _out = run_fr_ok(tmp.path(), &["ready", "--cc"]);
}
#[test]
fn test_ready_json() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["ready", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed["tasks"].is_array());
}
#[test]
fn test_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["blocked"]);
assert!(out.is_empty() || !out.contains("M-"));
}
#[test]
fn test_search() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["search", "subtasks"]);
assert!(out.contains("M-003"));
}
#[test]
fn test_search_with_track_filter() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["search", "task", "--track", "side"]);
assert!(out.contains("S-001"));
assert!(!out.contains("M-001"));
}
#[test]
fn test_inbox_list() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["inbox"]);
assert!(out.contains("Bug in parser"));
assert!(out.contains("Think about design"));
assert!(out.contains("Quick note"));
}
#[test]
fn test_inbox_json() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["inbox", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.is_array());
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 3);
assert_eq!(arr[0]["title"], "Bug in parser");
assert!(
arr[0]["tags"]
.as_array()
.unwrap()
.contains(&serde_json::json!("bug"))
);
}
#[test]
fn test_tracks() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["tracks"]);
assert!(out.contains("Main Track"));
assert!(out.contains("Side Track"));
}
#[test]
fn test_tracks_json() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["tracks", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.is_array());
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 2);
}
#[test]
fn test_stats() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["stats"]);
assert!(out.contains("Main Track"));
assert!(out.contains("Total"));
}
#[test]
fn test_stats_json() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["stats", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed["totals"].is_object());
}
#[test]
fn test_recent() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["recent"]);
assert!(out.contains("M-000"));
assert!(out.contains("Setup project"));
}
#[test]
fn test_deps() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["deps", "M-002"]);
assert!(out.contains("M-002"));
assert!(out.contains("M-001"));
}
#[test]
fn deps_reports_a_shared_dependency_as_already_shown() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_track(
tmp.path(),
"main",
"# Main Track\n\n## Backlog\n\n\
- [ ] `M-001` Root\n - dep: M-002, M-003\n\
- [ ] `M-002` Left\n - dep: M-004\n\
- [ ] `M-003` Right\n - dep: M-004\n\
- [ ] `M-004` Shared leaf\n\n## Done\n",
);
let out = run_fr_ok(tmp.path(), &["deps", "M-001"]);
assert!(
out.contains("M-004 (already shown)"),
"expected the second path to M-004 to be marked as a repeat:\n{out}"
);
assert!(
!out.contains("(circular)"),
"a diamond is not a cycle:\n{out}"
);
assert!(out.contains("[ ] M-004 Shared leaf"), "{out}");
}
#[test]
fn deps_stops_a_cycle_at_the_root() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_track(
tmp.path(),
"main",
"# Main Track\n\n## Backlog\n\n\
- [ ] `M-005` Cycle a\n - dep: M-006\n\
- [ ] `M-006` Cycle b\n - dep: M-005\n\n## Done\n",
);
let out = run_fr_ok(tmp.path(), &["deps", "M-005"]);
assert!(out.contains("M-005 (circular)"), "{out}");
assert_eq!(
out.lines().filter(|l| !l.trim().is_empty()).count(),
3,
"{out}"
);
}
#[test]
fn deps_reports_a_dangling_dependency_as_not_found() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_track(
tmp.path(),
"main",
"# Main Track\n\n## Backlog\n\n- [ ] `M-007` Dangling\n - dep: M-999\n\n## Done\n",
);
let out = run_fr_ok(tmp.path(), &["deps", "M-007"]);
assert!(out.contains("M-999 (not found)"), "{out}");
}
#[test]
fn search_archive_is_opt_out_not_opt_in() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let archive = tmp.path().join("frame").join("archive");
fs::create_dir_all(&archive).unwrap();
fs::write(
archive.join("main.md"),
"# Main Track — Archive\n\n## Done\n\n- [x] `M-900` Archived widget\n - resolved: 2024-01-02\n",
)
.unwrap();
let out = run_fr_ok(tmp.path(), &["search", "widget"]);
assert!(out.contains("[archive:main]"), "{out}");
assert!(out.contains("M-900"), "{out}");
let out = run_fr_ok(tmp.path(), &["search", "--no-archive", "widget"]);
assert!(
!out.contains("M-900"),
"--no-archive should skip it:\n{out}"
);
let (_, stderr, ok) = run_fr(tmp.path(), &["search", "-a", "widget"]);
assert!(!ok, "the removed -a flag should be an error, not a no-op");
assert!(stderr.contains("unexpected argument"), "{stderr}");
}
#[test]
fn search_json_reports_all_matched_fields() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["--json", "search", "M-001"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["pattern"], "M-001");
let tasks = parsed["tasks"].as_array().unwrap();
let by_id = |id: &str| {
tasks
.iter()
.find(|t| t["id"] == id)
.unwrap_or_else(|| panic!("{id} missing from {out}"))
};
assert_eq!(by_id("M-001")["matched_fields"][0], "id");
assert_eq!(by_id("M-002")["matched_fields"][0], "dep");
assert!(parsed["archived"].is_array());
assert!(parsed["inbox"].is_array());
}
#[test]
fn test_check() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["check"]);
assert!(out.contains("valid"));
}
#[test]
fn test_check_json() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["check", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["valid"], true);
}
#[test]
fn test_add_task() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["add", "main", "New task from CLI"]);
assert!(out.contains("M-011"));
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("New task from CLI"));
assert!(track.contains("M-011"));
}
#[test]
fn test_add_task_after() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(
tmp.path(),
&["add", "main", "After first", "--after", "M-001"],
);
assert!(out.contains("M-011"));
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let pos_001 = track.find("M-001").unwrap();
let pos_011 = track.find("M-011").unwrap();
let pos_002 = track.find("M-002").unwrap();
assert!(pos_011 > pos_001);
assert!(pos_011 < pos_002);
}
#[test]
fn test_push_task() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["push", "main", "Top priority task"]);
assert!(out.contains("M-011"));
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let pos_011 = track.find("M-011").unwrap();
let pos_001 = track.find("M-001").unwrap();
assert!(pos_011 < pos_001);
}
#[test]
fn test_sub_task() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["sub", "M-001", "New subtask"]);
assert!(out.contains("M-001.1"));
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("M-001.1"));
assert!(track.contains("New subtask"));
}
#[test]
fn test_state_change() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["state", "M-001", "active"]);
assert!(out.contains("M-001"));
assert!(out.contains("active"));
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("[>] `M-001`"));
}
#[test]
fn test_state_done_adds_resolved() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["state", "M-001", "done"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("[x] `M-001`"));
assert!(track.contains("resolved:"));
}
#[test]
fn state_change_moves_a_task_to_the_section_its_state_calls_for() {
let expectations = [
("done", "## Done"),
("parked", "## Parked"),
("todo", "## Backlog"),
("active", "## Backlog"),
("blocked", "## Backlog"),
];
let starts = [
(
"backlog",
"## Backlog\n\n- [ ] `M-001` Task\n\n## Parked\n\n## Done\n",
),
(
"parked",
"## Backlog\n\n## Parked\n\n- [~] `M-001` Task\n\n## Done\n",
),
(
"done",
"## Backlog\n\n## Parked\n\n## Done\n\n- [x] `M-001` Task\n - resolved: 2026-01-01\n",
),
];
for (start_name, body) in starts {
for (state, want_section) in expectations {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let path = tmp.path().join("frame/tracks/main.md");
fs::write(&path, format!("# Main\n\n{body}")).unwrap();
run_fr_ok(tmp.path(), &["state", "M-001", state]);
let track = fs::read_to_string(&path).unwrap();
let idx = track.find("`M-001`").expect("task survived");
let landed = track[..idx]
.rmatch_indices("## ")
.next()
.map(|(i, _)| track[i..].lines().next().unwrap())
.expect("task sits under a section header");
assert_eq!(
landed, want_section,
"M-001 starting in {start_name}, set to {state}, landed under {landed:?}\n{track}"
);
}
}
}
#[test]
fn test_tag_add_remove() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["tag", "M-001", "add", "urgent"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("#urgent"));
run_fr_ok(tmp.path(), &["tag", "M-001", "rm", "urgent"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!track.contains("#urgent"));
}
#[test]
fn test_dep_add_remove() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["dep", "M-003", "add", "M-010"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("dep: M-010"));
run_fr_ok(tmp.path(), &["dep", "M-003", "rm", "M-010"]);
let track_content = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
!track_content.contains("dep: M-010"),
"dep should be removed from M-003"
);
}
#[test]
fn test_note() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["note", "M-001", "This is a CLI note."]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("This is a CLI note."));
}
#[test]
fn test_note_append() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["note", "M-001", "First note."]);
run_fr_ok(tmp.path(), &["note", "M-001", "Second note."]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains("First note."),
"first note should be preserved"
);
assert!(
track.contains("Second note."),
"second note should be appended"
);
}
#[test]
fn test_note_replace() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["note", "M-001", "First note."]);
run_fr_ok(
tmp.path(),
&["note", "M-001", "Replacement note.", "--replace"],
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
!track.contains("First note."),
"first note should be replaced"
);
assert!(track.contains("Replacement note."));
}
#[test]
fn test_ref() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["ref", "M-001", "doc/design.md"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("ref: doc/design.md"));
}
#[test]
fn test_spec() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["spec", "M-001", "doc/spec.md#section"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("spec: doc/spec.md#section"));
}
#[test]
fn test_title() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["title", "M-001", "Updated title from CLI"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("Updated title from CLI"));
assert!(!track.contains("First task"));
}
#[test]
fn test_mv_top() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["mv", "M-003", "--top"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let pos_003 = track.find("M-003").unwrap();
let pos_001 = track.find("M-001").unwrap();
assert!(pos_003 < pos_001);
}
#[test]
fn test_mv_after() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["mv", "M-001", "--after", "M-002"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let pos_002 = track.find("M-002").unwrap();
let pos_001 = track.find("M-001").unwrap();
assert!(pos_001 > pos_002);
}
#[test]
fn test_mv_done_task_cross_track() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["mv", "M-000", "--track", "side"]);
assert!(out.contains("(side)"), "unexpected output: {out}");
let main = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!main.contains("Setup project"), "still in source: {main}");
let side = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
let done_pos = side
.find("## Done")
.expect("side should have a Done section");
let task_pos = side.find("Setup project").expect("task should be in side");
assert!(task_pos > done_pos, "task should be under Done: {side}");
assert!(side.contains("resolved:"), "resolved date lost: {side}");
let task_line = side
.lines()
.find(|l| l.contains("Setup project"))
.expect("task line");
assert!(
task_line.trim_start().starts_with("- [x]"),
"task should still be done: {task_line}"
);
}
#[test]
fn test_mv_parked_task_cross_track() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["mv", "M-010", "--track", "side"]);
let side = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
let parked_pos = side
.find("## Parked")
.expect("side should gain a Parked section");
let task_pos = side
.find("Parked idea")
.expect("parked task should be in side");
assert!(task_pos > parked_pos, "task should be under Parked: {side}");
}
#[test]
fn test_commands_understand_actor_token_ids() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["actor", "set", "b"]);
let add = run_fr_ok(tmp.path(), &["add", "main", "Tokened task"]);
assert!(add.contains("M-b1"), "expected M-b1, got: {add}");
run_fr_ok(tmp.path(), &["add", "main", "Second tokened"]);
assert!(run_fr_ok(tmp.path(), &["show", "M-b1"]).contains("Tokened task"));
run_fr_ok(tmp.path(), &["tag", "M-b1", "add", "urgent"]);
run_fr_ok(tmp.path(), &["dep", "M-b1", "add", "M-b2"]);
run_fr_ok(tmp.path(), &["note", "M-b1", "a note"]);
run_fr_ok(tmp.path(), &["title", "M-b1", "Renamed"]);
run_fr_ok(tmp.path(), &["state", "M-b1", "active"]);
run_fr_ok(tmp.path(), &["deps", "M-b1"]);
run_fr_ok(tmp.path(), &["mv", "M-b2", "--top"]);
let out = run_fr_ok(tmp.path(), &["mv", "M-b1", "--track", "side"]);
assert!(out.contains("(side)"), "cross-track mv failed: {out}");
let side = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
assert!(side.contains("S-b1") && side.contains("Renamed"), "{side}");
}
#[test]
fn test_inbox_add() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["inbox", "New inbox item", "--tag", "bug"]);
let inbox = fs::read_to_string(tmp.path().join("frame/inbox.md")).unwrap();
assert!(inbox.contains("New inbox item"));
assert!(inbox.contains("#bug"));
}
#[test]
fn test_triage() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["triage", "1", "--track", "main"]);
assert!(out.contains("M-011"));
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("Bug in parser"));
assert!(track.contains("M-011"));
let inbox = fs::read_to_string(tmp.path().join("frame/inbox.md")).unwrap();
assert!(!inbox.contains("Bug in parser"));
}
#[test]
fn test_triage_top() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["triage", "2", "--track", "main", "--top"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let pos_design = track.find("Think about design").unwrap();
let pos_001 = track.find("M-001").unwrap();
assert!(pos_design < pos_001);
}
#[test]
fn test_track_new() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "new", "feat", "Features"]);
assert!(tmp.path().join("frame/tracks/feat.md").exists());
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(config.contains("feat"));
assert!(config.contains("Features"));
}
#[test]
fn test_track_shelve() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(config.contains("\"shelved\""));
}
#[test]
fn test_track_activate() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
run_fr_ok(tmp.path(), &["track", "activate", "side"]);
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
let active_count = config.matches("\"active\"").count();
assert_eq!(active_count, 2);
}
#[test]
fn test_add_to_shelved_track_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
let (_out, err, ok) = run_fr(tmp.path(), &["add", "side", "New task"]);
assert!(!ok, "adding to a shelved track should fail");
assert!(
err.contains("shelved"),
"error should mention shelved: {err}"
);
assert!(
err.contains("fr track activate side"),
"error should suggest activating the track: {err}"
);
let side = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
assert!(!side.contains("New task"));
}
#[test]
fn test_push_to_shelved_track_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
let (_out, err, ok) = run_fr(tmp.path(), &["push", "side", "Urgent"]);
assert!(!ok, "pushing to a shelved track should fail");
assert!(
err.contains("shelved"),
"error should mention shelved: {err}"
);
}
#[test]
fn test_sub_to_shelved_track_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
let (_out, err, ok) = run_fr(tmp.path(), &["sub", "S-001", "A subtask"]);
assert!(!ok, "adding a subtask in a shelved track should fail");
assert!(
err.contains("shelved"),
"error should mention shelved: {err}"
);
}
#[test]
fn test_triage_to_shelved_track_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
let (_out, err, ok) = run_fr(tmp.path(), &["triage", "1", "--track", "side"]);
assert!(!ok, "triaging into a shelved track should fail");
assert!(
err.contains("shelved"),
"error should mention shelved: {err}"
);
}
#[test]
fn test_mv_into_shelved_track_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
let (_out, err, ok) = run_fr(tmp.path(), &["mv", "M-001", "--track", "side"]);
assert!(!ok, "moving a task into a shelved track should fail");
assert!(
err.contains("shelved"),
"error should mention shelved: {err}"
);
}
#[test]
fn test_import_to_shelved_track_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let import_file = tmp.path().join("import.md");
fs::write(&import_file, "- [ ] Imported task\n").unwrap();
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
let (_out, err, ok) = run_fr(
tmp.path(),
&["import", import_file.to_str().unwrap(), "--track", "side"],
);
assert!(!ok, "importing into a shelved track should fail");
assert!(
err.contains("shelved"),
"error should mention shelved: {err}"
);
}
#[test]
fn test_state_active_in_shelved_track_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
let (_out, err, ok) = run_fr(tmp.path(), &["state", "S-001", "active"]);
assert!(!ok, "activating a task in a shelved track should fail");
assert!(
err.contains("shelved"),
"error should mention shelved: {err}"
);
let (_out, err, ok) = run_fr(tmp.path(), &["start", "S-001"]);
assert!(!ok, "`fr start` in a shelved track should fail");
assert!(
err.contains("shelved"),
"error should mention shelved: {err}"
);
}
#[test]
fn test_state_non_active_in_shelved_track_allowed() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "shelve", "side"]);
run_fr_ok(tmp.path(), &["state", "S-001", "done"]);
let side = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
assert!(side.contains("[x] `S-001`"));
}
#[test]
fn test_track_cc_focus() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "cc-focus", "side"]);
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(config.contains("cc_focus = \"side\""));
}
#[test]
fn test_clean() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["clean"]);
assert!(out.contains("clean"));
}
#[test]
fn test_clean_dry_run() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["clean", "--dry-run"]);
assert!(out.contains("dry run"));
}
#[test]
fn test_import() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let import_file = tmp.path().join("import.md");
fs::write(
&import_file,
"\
- [ ] Imported task one #core
- [ ] Imported task two #design
- [ ] Imported sub
",
)
.unwrap();
let out = run_fr_ok(
tmp.path(),
&["import", import_file.to_str().unwrap(), "--track", "main"],
);
assert!(out.contains("imported"));
assert!(out.contains("M-011"));
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains("Imported task one"));
assert!(track.contains("Imported task two"));
assert!(track.contains("Imported sub"));
}
#[test]
fn test_import_top() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let import_file = tmp.path().join("import.md");
fs::write(&import_file, "- [ ] Top import\n").unwrap();
run_fr_ok(
tmp.path(),
&[
"import",
import_file.to_str().unwrap(),
"--track",
"main",
"--top",
],
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let pos_import = track.find("Top import").unwrap();
let pos_001 = track.find("M-001").unwrap();
assert!(pos_import < pos_001);
}
#[test]
fn test_not_a_project() {
let tmp = tempfile::TempDir::new().unwrap();
let (_stdout, stderr, success) = run_fr(tmp.path(), &["list"]);
assert!(!success);
assert!(stderr.contains("not a Frame project") || stderr.contains("error"));
}
#[test]
fn test_add_to_nonexistent_track() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (_stdout, stderr, success) = run_fr(tmp.path(), &["add", "nonexist", "Task"]);
assert!(!success);
assert!(stderr.contains("error"));
}
#[test]
fn test_state_invalid() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (_stdout, stderr, success) = run_fr(tmp.path(), &["state", "M-001", "invalid_state"]);
assert!(!success);
assert!(stderr.contains("unknown state"));
}
#[test]
fn test_help() {
let out = run_fr_ok(Path::new("."), &["--help"]);
assert!(out.contains("frame"));
assert!(out.contains("list"));
assert!(out.contains("add"));
}
#[test]
fn test_add_then_show() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let add_out = run_fr_ok(tmp.path(), &["add", "main", "Workflow test task"]);
let id = add_out.trim();
let show_out = run_fr_ok(tmp.path(), &["show", id]);
assert!(show_out.contains("Workflow test task"));
}
#[test]
fn test_add_then_state_then_show() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let add_out = run_fr_ok(tmp.path(), &["add", "side", "Side workflow"]);
let id = add_out.trim();
run_fr_ok(tmp.path(), &["state", id, "active"]);
let show_out = run_fr_ok(tmp.path(), &["show", id, "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&show_out).unwrap();
assert_eq!(parsed["state"], "active");
}
#[test]
fn test_found_from() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(
tmp.path(),
&["add", "main", "Found bug", "--found-from", "M-001"],
);
let id = out.trim();
let show_out = run_fr_ok(tmp.path(), &["show", id]);
assert!(show_out.contains("Found while working on M-001"));
}
#[test]
fn test_track_rename_name() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(
tmp.path(),
&["track", "rename", "side", "--name", "New Side"],
);
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(config.contains("\"New Side\""));
let track_content = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
assert!(track_content.starts_with("# New Side"));
}
#[test]
fn test_track_rename_id() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "rename", "side", "--new-id", "aux"]);
assert!(!tmp.path().join("frame/tracks/side.md").exists());
assert!(tmp.path().join("frame/tracks/aux.md").exists());
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(config.contains("\"aux\""));
assert!(config.contains("tracks/aux.md"));
}
#[test]
fn test_track_rename_prefix_yes() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(
tmp.path(),
&["track", "rename", "side", "--prefix", "AUX", "--yes"],
);
assert!(out.contains("Renaming prefix S → AUX"));
let track_content = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
assert!(track_content.contains("AUX-001"));
assert!(track_content.contains("AUX-002"));
assert!(!track_content.contains("`S-001`"));
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(config.contains("\"AUX\""));
}
#[test]
fn test_track_rename_prefix_dry_run() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(
tmp.path(),
&["track", "rename", "side", "--prefix", "AUX", "--dry-run"],
);
assert!(out.contains("dry run"));
let track_content = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
assert!(track_content.contains("`S-001`"));
assert!(track_content.contains("`S-002`"));
}
#[test]
fn test_track_delete_empty() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["track", "new", "empty", "Empty Track"]);
assert!(tmp.path().join("frame/tracks/empty.md").exists());
run_fr_ok(tmp.path(), &["track", "delete", "empty"]);
assert!(!tmp.path().join("frame/tracks/empty.md").exists());
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(!config.contains("\"empty\""));
}
#[test]
fn test_track_delete_non_empty_fails() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (_, stderr, success) = run_fr(tmp.path(), &["track", "delete", "main"]);
assert!(!success);
assert!(stderr.contains("tasks") || stderr.contains("not empty") || stderr.contains("has"));
}
#[test]
fn test_init_with_tracks() {
let tmp = tempfile::TempDir::new().unwrap();
let out = run_fr_ok(
tmp.path(),
&[
"init",
"--name",
"Test Project",
"--track",
"api",
"API Layer",
],
);
assert!(out.contains("[>] frame initialized"));
assert!(out.contains("project.toml"));
assert!(out.contains("inbox.md"));
assert!(out.contains("tracks/api.md"));
let toml_content = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
let parsed: toml::Value = toml::from_str(&toml_content).unwrap();
assert_eq!(parsed["project"]["name"].as_str().unwrap(), "Test Project");
assert!(toml_content.contains("[clean]"));
assert!(toml_content.contains("[ui]"));
assert!(toml_content.contains("[agent]"));
assert!(toml_content.contains("[[tracks]]"));
assert!(toml_content.contains("id = \"api\""));
assert!(toml_content.contains("[ids.prefixes]"));
assert!(tmp.path().join("frame/tracks/api.md").exists());
assert!(tmp.path().join("frame/inbox.md").exists());
}
#[test]
fn test_init_already_exists() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "First"]);
let (stdout, stderr, success) = run_fr(tmp.path(), &["init", "--name", "Second"]);
assert!(!success);
let combined = format!("{}{}", stdout, stderr);
assert!(combined.contains("frame/ already exists"));
assert!(combined.contains("--force"));
}
#[test]
fn test_init_force_reinitialize() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "First"]);
let out = run_fr_ok(tmp.path(), &["init", "--name", "Second", "--force"]);
assert!(out.contains("[>] frame initialized"));
let toml_content = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(toml_content.contains("\"Second\""));
}
#[test]
fn test_init_gitignore_added() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
let out = run_fr_ok(tmp.path(), &["init", "--name", "Git Project"]);
assert!(
out.contains("added frame/.* to .gitignore"),
"summary should name the pattern: {out}"
);
let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(gitignore.contains("frame/.*"), "{gitignore}");
assert!(
!gitignore.contains("frame/.state.json"),
"should not enumerate individual files: {gitignore}"
);
}
#[test]
fn test_init_gitignore_no_git() {
let tmp = tempfile::TempDir::new().unwrap();
let out = run_fr_ok(tmp.path(), &["init", "--name", "No Git"]);
assert!(!out.contains(".gitignore"));
}
#[test]
fn test_init_gitignore_already_present() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
fs::write(
tmp.path().join(".gitignore"),
"frame/.state.json\nframe/.lock\nframe/.recovery.log\nframe/.actor\n",
)
.unwrap();
let out = run_fr_ok(tmp.path(), &["init", "--name", "Already"]);
assert!(!out.contains("added frame/.state.json"));
}
#[test]
fn test_init_gitignore_partial() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
fs::write(tmp.path().join(".gitignore"), "frame/.lock\n").unwrap();
let out = run_fr_ok(tmp.path(), &["init", "--name", "Partial"]);
assert!(
out.contains("added frame/.* to .gitignore"),
"summary should name the pattern: {out}"
);
let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(gitignore.contains("frame/.*"));
assert_eq!(
gitignore
.lines()
.filter(|l| l.trim() == "frame/.lock")
.count(),
1,
"pre-existing line preserved exactly once: {gitignore}"
);
}
#[test]
fn test_mv_promote() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["mv", "M-003.1", "--promote"]);
assert!(out.contains("M-003.1"));
let list_out = run_fr_ok(tmp.path(), &["list", "main", "--json"]);
assert!(list_out.contains("Sub two"));
assert!(list_out.contains("Sub one"));
}
#[test]
fn test_mv_parent() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["mv", "M-001", "--parent", "M-002"]);
assert!(out.contains("M-001"));
let show_out = run_fr_ok(tmp.path(), &["show", "M-002"]);
assert!(show_out.contains("First task"));
}
#[test]
fn test_mv_promote_top_level_error() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (_, stderr, success) = run_fr(tmp.path(), &["mv", "M-001", "--promote"]);
assert!(!success);
assert!(stderr.contains("already top-level") || stderr.contains("AlreadyTopLevel"));
}
#[test]
fn test_mv_parent_cycle_error() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (_, stderr, success) = run_fr(tmp.path(), &["mv", "M-003", "--parent", "M-003.1"]);
assert!(!success);
assert!(stderr.contains("cycle") || stderr.contains("CycleDetected"));
}
#[test]
fn test_mv_promote_parent_conflict() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (_, stderr, success) = run_fr(
tmp.path(),
&["mv", "M-003.1", "--promote", "--parent", "M-001"],
);
assert!(!success);
assert!(
stderr.contains("cannot be used with")
|| stderr.contains("conflict")
|| stderr.contains("the argument")
);
}
#[test]
fn test_mv_parent_depth_exceeded() {
let tmp = tempfile::TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
fs::create_dir_all(frame_dir.join("tracks")).unwrap();
fs::write(
frame_dir.join("project.toml"),
r#"[project]
name = "depth-test"
[[tracks]]
id = "deep"
name = "Deep Track"
state = "active"
file = "tracks/deep.md"
[ids.prefixes]
deep = "D"
"#,
)
.unwrap();
fs::write(
frame_dir.join("tracks/deep.md"),
"\
# Deep Track
## Backlog
- [ ] `D-001` Root
- [ ] `D-001.1` Child
- [ ] `D-001.1.1` Grandchild
- [ ] `D-002` Another root
## Done
",
)
.unwrap();
fs::write(frame_dir.join("inbox.md"), "# Inbox\n").unwrap();
let (_, stderr, success) = run_fr(tmp.path(), &["mv", "D-002", "--parent", "D-001.1.1"]);
assert!(!success);
assert!(
stderr.contains("depth") || stderr.contains("DepthExceeded") || stderr.contains("nesting")
);
}
#[test]
fn test_show_context_subtask() {
let tmp = tempfile::tempdir().unwrap();
create_test_project(tmp.path());
let (stdout, _, success) = run_fr(tmp.path(), &["show", "M-003.1", "--context"]);
assert!(success);
assert!(stdout.contains("── Parent ── M-003"));
assert!(stdout.contains("── Task ── M-003.1"));
assert!(stdout.contains("state: todo"));
}
#[test]
fn test_show_context_top_level() {
let tmp = tempfile::tempdir().unwrap();
create_test_project(tmp.path());
let (stdout, _, success) = run_fr(tmp.path(), &["show", "M-003", "--context"]);
assert!(success);
assert!(!stdout.contains("── Parent ──"));
assert!(stdout.contains("── Task ── M-003"));
}
#[test]
fn test_show_no_context_unchanged() {
let tmp = tempfile::tempdir().unwrap();
create_test_project(tmp.path());
let (stdout, _, success) = run_fr(tmp.path(), &["show", "M-003.1"]);
assert!(success);
assert!(!stdout.contains("── Parent ──"));
assert!(!stdout.contains("── Task ──"));
}
#[test]
fn test_show_json_always_has_ancestors() {
let tmp = tempfile::tempdir().unwrap();
create_test_project(tmp.path());
let (stdout, _, success) = run_fr(tmp.path(), &["show", "M-003.1", "--json"]);
assert!(success);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let ancestors = json["ancestors"].as_array().unwrap();
assert_eq!(ancestors.len(), 1);
assert_eq!(ancestors[0]["id"], "M-003");
assert_eq!(ancestors[0]["title"], "Third task with subtasks");
}
#[test]
fn test_show_json_top_level_empty_ancestors() {
let tmp = tempfile::tempdir().unwrap();
create_test_project(tmp.path());
let (stdout, _, success) = run_fr(tmp.path(), &["show", "M-003", "--json"]);
assert!(success);
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(json.get("ancestors").is_none() || json["ancestors"].as_array().unwrap().is_empty());
}
#[test]
fn test_recovery_empty() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["recovery"]);
assert!(out.contains("No recovery log entries") || out.is_empty() || out.contains("recovery"));
}
#[test]
fn test_recovery_path() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["recovery", "path"]);
assert!(out.contains(".recovery.log"));
assert!(out.contains("frame"));
}
#[test]
fn test_recovery_prune_all_empty() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["recovery", "prune", "--all"]);
assert!(out.contains("0") || out.contains("pruned") || out.contains("No"));
}
#[test]
fn test_recovery_with_entries() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let recovery_path = tmp.path().join("frame/.recovery.log");
let ts = "2026-02-10T12:00:00Z";
let content = format!(
"<!-- frame recovery log — append-only error recovery data\n This file captures data that Frame couldn't save normally.\n If something went missing, check here.\n View with: fr recovery\n Prune old entries: fr recovery prune\n Safe to delete if empty or stale. -->\n\n---\n## {} — write: test failure\n\nSource: tracks/main.md\n\n```text\nlost content here\n```\n\n---\n",
ts
);
fs::write(&recovery_path, content).unwrap();
let out = run_fr_ok(tmp.path(), &["recovery"]);
assert!(out.contains("write: test failure") || out.contains("test failure"));
}
#[test]
fn test_recovery_json() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let recovery_path = tmp.path().join("frame/.recovery.log");
let ts = "2026-02-10T12:00:00Z";
let content = format!(
"<!-- frame recovery log — append-only error recovery data\n This file captures data that Frame couldn't save normally.\n If something went missing, check here.\n View with: fr recovery\n Prune old entries: fr recovery prune\n Safe to delete if empty or stale. -->\n\n---\n## {} — parser: dropped lines\n\nSource: inbox.md\n\n```text\nstray line\n```\n\n---\n",
ts
);
fs::write(&recovery_path, content).unwrap();
let out = run_fr_ok(tmp.path(), &["recovery", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.is_array());
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["category"], "parser");
assert_eq!(arr[0]["description"], "dropped lines");
}
#[test]
fn test_recovery_prune_all_with_entries() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let recovery_path = tmp.path().join("frame/.recovery.log");
let ts = "2026-02-10T12:00:00Z";
let content = format!(
"<!-- frame recovery log — append-only error recovery data\n This file captures data that Frame couldn't save normally.\n If something went missing, check here.\n View with: fr recovery\n Prune old entries: fr recovery prune\n Safe to delete if empty or stale. -->\n\n---\n## {} — write: failure\n\n---\n",
ts
);
fs::write(&recovery_path, content).unwrap();
let out = run_fr_ok(tmp.path(), &["recovery", "prune", "--all"]);
assert!(out.contains("1") || out.contains("pruned"));
let out2 = run_fr_ok(tmp.path(), &["recovery"]);
assert!(out2.contains("No recovery log entries") || !out2.contains("write: failure"));
}
#[test]
fn test_recovery_limit() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let recovery_path = tmp.path().join("frame/.recovery.log");
let content = "\
<!-- frame recovery log — append-only error recovery data
This file captures data that Frame couldn't save normally.
If something went missing, check here.
View with: fr recovery
Prune old entries: fr recovery prune
Safe to delete if empty or stale. -->
---
## 2026-02-10T11:00:00Z — parser: first entry
---
## 2026-02-10T12:00:00Z — write: second entry
---
";
fs::write(&recovery_path, content).unwrap();
let out = run_fr_ok(tmp.path(), &["recovery", "--limit", "1"]);
assert!(out.contains("second entry"));
assert!(!out.contains("first entry"));
}
#[test]
fn test_check_with_lost_task() {
let tmp = tempfile::TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
fs::create_dir_all(frame_dir.join("tracks")).unwrap();
fs::write(
frame_dir.join("project.toml"),
r#"[project]
name = "test-project"
[[tracks]]
id = "main"
name = "Main Track"
state = "active"
file = "tracks/main.md"
[ids.prefixes]
main = "M"
"#,
)
.unwrap();
fs::write(
frame_dir.join("tracks/main.md"),
"\
# Main Track
## Backlog
- [!] `M-001` Recovered task #lost
- added: 2025-05-01
## Done
",
)
.unwrap();
fs::write(frame_dir.join("inbox.md"), "# Inbox\n").unwrap();
let out = run_fr_ok(tmp.path(), &["check"]);
assert!(out.contains("#lost") || out.contains("lost"));
}
#[test]
fn test_check_json_with_recovery_log() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let recovery_path = tmp.path().join("frame/.recovery.log");
let content = "\
<!-- frame recovery log — append-only error recovery data
This file captures data that Frame couldn't save normally.
If something went missing, check here.
View with: fr recovery
Prune old entries: fr recovery prune
Safe to delete if empty or stale. -->
---
## 2026-02-10T12:00:00Z — write: test
---
";
fs::write(&recovery_path, content).unwrap();
let out = run_fr_ok(tmp.path(), &["check", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed["info"].is_array());
let info = parsed["info"].as_array().unwrap();
assert!(info.iter().any(|i| i["type"] == "recovery_log"));
}
#[test]
fn test_init_claims_null_and_writes_both_files() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "Tokened"]);
let actors = fs::read_to_string(tmp.path().join("frame/actors.toml")).unwrap();
let parsed: toml::Value = toml::from_str(&actors).unwrap();
assert_eq!(
parsed["actors"]["null"]["state"].as_str().unwrap(),
"active"
);
let actor = fs::read_to_string(tmp.path().join("frame/.actor")).unwrap();
assert_eq!(actor.trim(), "null");
}
#[test]
fn test_init_force_does_not_clobber_actors() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "First"]);
run_fr_ok(tmp.path(), &["actor", "set", "a", "--name", "mine"]);
run_fr_ok(tmp.path(), &["init", "--name", "Second", "--force"]);
let actors = fs::read_to_string(tmp.path().join("frame/actors.toml")).unwrap();
assert!(
actors.contains("[actors.a]"),
"actors.toml clobbered: {actors}"
);
assert!(actors.contains("mine"));
}
#[test]
fn test_actor_status_missing_registry_reports_unclaimed() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::remove_file(tmp.path().join("frame/.actor")).unwrap();
let (stdout, _stderr, success) = run_fr(tmp.path(), &["actor"]);
assert!(
success,
"fr actor should not error on a registry-less project"
);
assert!(stdout.contains("unclaimed"), "stdout: {stdout}");
assert!(!tmp.path().join("frame/actors.toml").exists());
}
#[test]
fn test_first_mint_auto_claims_token() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::remove_file(tmp.path().join("frame/.actor")).unwrap();
let (stdout, stderr, success) = run_fr(tmp.path(), &["add", "main", "First in fresh clone"]);
assert!(success, "stderr: {stderr}");
let id = stdout.trim();
assert!(
id.starts_with("M-") && id.chars().nth(2).is_some_and(|c| c.is_ascii_alphabetic()),
"expected a tokened id, got {id}"
);
assert!(stderr.contains("Claimed actor token"), "stderr: {stderr}");
let token = fs::read_to_string(tmp.path().join("frame/.actor"))
.unwrap()
.trim()
.to_string();
assert_ne!(token, "null");
assert_eq!(id, format!("M-{token}1"));
let registry = fs::read_to_string(tmp.path().join("frame/actors.toml")).unwrap();
assert!(
registry.contains(&format!("[actors.{token}]")),
"{registry}"
);
let (_stdout2, stderr2, success2) = run_fr(tmp.path(), &["add", "main", "Second"]);
assert!(success2);
assert!(
!stderr2.contains("Claimed actor token"),
"stderr2: {stderr2}"
);
}
#[test]
fn test_dry_run_clean_on_unclaimed_clone_mints_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::remove_file(tmp.path().join("frame/.actor")).unwrap();
let main_path = tmp.path().join("frame/tracks/main.md");
let main = fs::read_to_string(&main_path).unwrap();
fs::write(
&main_path,
main.replace("## Backlog\n", "## Backlog\n\n- [ ] Task with no id\n"),
)
.unwrap();
let (stdout, _stderr, success) = run_fr(tmp.path(), &["clean", "--dry-run"]);
assert!(success);
assert!(
!stdout.contains("IDs assigned"),
"unclaimed clone must not mint on a dry run: {stdout}"
);
assert!(!tmp.path().join("frame/.actor").exists());
assert!(!tmp.path().join("frame/actors.toml").exists());
}
#[test]
fn test_mint_errors_when_frontier_empty_and_unclaimed() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::remove_file(tmp.path().join("frame/.actor")).unwrap();
let alphabet = [
"a", "b", "c", "d", "e", "f", "g", "h", "j", "k", "m", "n", "p", "q", "r", "s", "t", "u",
"v", "w", "x", "y", "z",
];
let mut registry = String::new();
for t in alphabet {
registry.push_str(&format!(
"[actors.{t}]\nname = \"other\"\nstate = \"active\"\nclaimed = \"2026-01-01\"\n\n"
));
}
fs::write(tmp.path().join("frame/actors.toml"), registry).unwrap();
let track_before = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let (_stdout, stderr, success) = run_fr(tmp.path(), &["add", "main", "Should not be created"]);
assert!(!success, "mint should fail when no token can be claimed");
assert!(stderr.contains("fr actor set"), "stderr: {stderr}");
let track_after = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert_eq!(track_before, track_after);
assert!(!track_after.contains("Should not be created"));
assert!(!tmp.path().join("frame/.actor").exists());
}
#[test]
fn test_mv_cross_track_mints_in_movers_namespace() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["actor", "set", "c"]);
let out = run_fr_ok(tmp.path(), &["mv", "M-001", "--track", "side"]);
assert!(out.contains("S-c1"), "out: {out}");
let side = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
assert!(side.contains("S-c1"), "side: {side}");
let main = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!main.contains("First task"), "M-001 should have moved out");
}
#[test]
fn test_mv_promote_mints_in_movers_namespace() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["actor", "set", "c"]);
let out = run_fr_ok(tmp.path(), &["mv", "M-003.1", "--promote"]);
assert!(out.contains("M-c1"), "out: {out}");
}
#[test]
fn test_cross_track_move_aborts_when_frontier_empty_and_unclaimed() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::remove_file(tmp.path().join("frame/.actor")).unwrap();
let alphabet = [
"a", "b", "c", "d", "e", "f", "g", "h", "j", "k", "m", "n", "p", "q", "r", "s", "t", "u",
"v", "w", "x", "y", "z",
];
let mut registry = String::new();
for t in alphabet {
registry.push_str(&format!(
"[actors.{t}]\nname = \"other\"\nstate = \"active\"\nclaimed = \"2026-01-01\"\n\n"
));
}
fs::write(tmp.path().join("frame/actors.toml"), registry).unwrap();
let main_before = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let side_before = fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap();
let (_stdout, stderr, success) = run_fr(tmp.path(), &["mv", "M-001", "--track", "side"]);
assert!(
!success,
"cross-track move should fail when no token is claimable"
);
assert!(stderr.contains("fr actor set"), "stderr: {stderr}");
assert_eq!(
main_before,
fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap()
);
assert_eq!(
side_before,
fs::read_to_string(tmp.path().join("frame/tracks/side.md")).unwrap()
);
assert!(!tmp.path().join("frame/.actor").exists());
}
fn create_dep_project(root: &Path) {
let frame_dir = root.join("frame");
fs::create_dir_all(frame_dir.join("tracks")).unwrap();
fs::write(frame_dir.join(".actor"), "null\n").unwrap();
fs::write(
frame_dir.join("project.toml"),
r#"[project]
name = "dep-project"
[[tracks]]
id = "alpha"
name = "Alpha"
state = "active"
file = "tracks/alpha.md"
[[tracks]]
id = "beta"
name = "Beta"
state = "active"
file = "tracks/beta.md"
[[tracks]]
id = "gamma"
name = "Gamma"
state = "active"
file = "tracks/gamma.md"
[[tracks]]
id = "delta"
name = "Delta"
state = "active"
file = "tracks/delta.md"
[ids.prefixes]
alpha = "A"
beta = "B"
gamma = "C"
delta = "D"
"#,
)
.unwrap();
fs::write(
frame_dir.join("tracks/alpha.md"),
"\
# Alpha
## Backlog
- [ ] `A-001` First alpha
- added: 2025-05-01
- [ ] `A-005` Movable task
- added: 2025-05-02
- [ ] `A-0050` Decoy with a similar id
- added: 2025-05-03
## Done
",
)
.unwrap();
fs::write(
frame_dir.join("tracks/beta.md"),
"\
# Beta
## Backlog
- [ ] `B-001` Depends on the movable task
- added: 2025-05-01
- dep: A-005
- [ ] `B-002` Depends on the decoy
- added: 2025-05-02
- dep: A-0050
## Done
",
)
.unwrap();
fs::write(
frame_dir.join("tracks/gamma.md"),
"\
# Gamma
## Backlog
## Done
",
)
.unwrap();
fs::write(
frame_dir.join("tracks/delta.md"),
"\
# Delta
## Backlog
- [ ] `D-001` Also depends on the movable task
- added: 2025-05-01
- dep: A-005
## Done
",
)
.unwrap();
}
#[test]
fn test_mv_cross_track_updates_dep_reference() {
let tmp = tempfile::TempDir::new().unwrap();
create_dep_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["mv", "A-005", "--track", "gamma"]);
assert!(out.contains("A-005 → C-001"), "out: {out}");
let beta = fs::read_to_string(tmp.path().join("frame/tracks/beta.md")).unwrap();
assert!(beta.contains("dep: C-001"), "beta: {beta}");
assert!(!beta.contains("dep: A-005\n"), "stale dep remained: {beta}");
let gamma = fs::read_to_string(tmp.path().join("frame/tracks/gamma.md")).unwrap();
assert!(gamma.contains("`C-001`"), "gamma: {gamma}");
}
#[test]
fn test_mv_cross_track_updates_dep_in_movers_namespace() {
let tmp = tempfile::TempDir::new().unwrap();
create_dep_project(tmp.path());
run_fr_ok(tmp.path(), &["actor", "set", "c"]);
let out = run_fr_ok(tmp.path(), &["mv", "A-005", "--track", "gamma"]);
assert!(out.contains("A-005 → C-c1"), "out: {out}");
let beta = fs::read_to_string(tmp.path().join("frame/tracks/beta.md")).unwrap();
assert!(beta.contains("dep: C-c1"), "beta: {beta}");
}
#[test]
fn test_mv_cross_track_updates_multiple_dependents() {
let tmp = tempfile::TempDir::new().unwrap();
create_dep_project(tmp.path());
run_fr_ok(tmp.path(), &["mv", "A-005", "--track", "gamma"]);
let beta = fs::read_to_string(tmp.path().join("frame/tracks/beta.md")).unwrap();
let delta = fs::read_to_string(tmp.path().join("frame/tracks/delta.md")).unwrap();
assert!(beta.contains("dep: C-001"), "beta: {beta}");
assert!(delta.contains("dep: C-001"), "delta: {delta}");
}
#[test]
fn test_mv_cross_track_no_false_dep_rewrite() {
let tmp = tempfile::TempDir::new().unwrap();
create_dep_project(tmp.path());
run_fr_ok(tmp.path(), &["mv", "A-005", "--track", "gamma"]);
let beta = fs::read_to_string(tmp.path().join("frame/tracks/beta.md")).unwrap();
assert!(beta.contains("dep: C-001"), "beta: {beta}");
assert!(
beta.contains("dep: A-0050"),
"decoy dep was wrongly rewritten: {beta}"
);
}
#[test]
fn test_mv_cross_track_then_check_clean() {
let tmp = tempfile::TempDir::new().unwrap();
create_dep_project(tmp.path());
run_fr_ok(tmp.path(), &["mv", "A-005", "--track", "gamma"]);
let check = run_fr_ok(tmp.path(), &["check"]);
assert!(check.contains("✓ project is valid"), "check: {check}");
assert!(
!check.contains("dangling"),
"check reported a dangling dep: {check}"
);
}
#[test]
fn test_actor_set_null_creates_registry_on_legacy_project() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["actor", "set", "null"]);
assert!(tmp.path().join("frame/actors.toml").exists());
let actor = fs::read_to_string(tmp.path().join("frame/.actor")).unwrap();
assert_eq!(actor.trim(), "null");
}
#[test]
fn test_actor_claim_picks_a_token() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "Claimer"]);
let out = run_fr_ok(tmp.path(), &["actor", "claim"]);
assert!(out.contains("claimed token"), "out: {out}");
let actor = fs::read_to_string(tmp.path().join("frame/.actor"))
.unwrap()
.trim()
.to_string();
assert_ne!(actor, "null");
assert_eq!(actor.len(), 1);
}
#[test]
fn test_actor_set_rejects_invalid_token() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "Strict"]);
let (_o, _e, ok_upper) = run_fr(tmp.path(), &["actor", "set", "A"]);
assert!(!ok_upper);
let (_o, _e, ok_i) = run_fr(tmp.path(), &["actor", "set", "i"]);
assert!(!ok_i);
}
#[test]
fn test_actor_retire_then_reclaim() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "Retirer"]);
run_fr_ok(tmp.path(), &["actor", "set", "a"]);
run_fr_ok(tmp.path(), &["actor", "retire", "a"]);
let listing = run_fr_ok(tmp.path(), &["actor", "list"]);
assert!(listing.contains("retired"), "list: {listing}");
let out = run_fr_ok(tmp.path(), &["actor", "set", "a"]);
assert!(out.contains("reclaimed"), "out: {out}");
}
#[test]
fn test_actor_list_json() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "Lister"]);
let out = run_fr_ok(tmp.path(), &["actor", "list", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let rows = parsed.as_array().unwrap();
assert!(rows.iter().any(|r| r["token"] == "null"));
}
#[test]
fn test_actor_set_owned_by_another_refused() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "Owner"]);
let actors_path = tmp.path().join("frame/actors.toml");
let mut content = fs::read_to_string(&actors_path).unwrap();
content.push_str(
"\n[actors.a]\nname = \"other-machine\"\nstate = \"active\"\nclaimed = \"2026-06-01\"\n",
);
fs::write(&actors_path, content).unwrap();
let (stdout, stderr, success) = run_fr(tmp.path(), &["actor", "set", "a"]);
assert!(!success);
let combined = format!("{stdout}{stderr}");
assert!(combined.contains("already claimed"), "combined: {combined}");
}
#[test]
fn test_info_human_primary() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["info"]);
assert!(out.contains("version"), "out: {out}");
assert!(out.contains(env!("CARGO_PKG_VERSION")), "out: {out}");
assert!(out.contains("test-project"), "out: {out}");
assert!(out.contains("actor"), "out: {out}");
assert!(out.contains("primary"), "out: {out}");
assert!(
!out.contains("null"),
"human output should not show literal null: {out}"
);
assert!(out.contains("tracks"), "out: {out}");
assert!(out.contains('2'), "out: {out}");
}
#[test]
fn test_info_json_primary() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["info", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(parsed["project"], "test-project");
assert_eq!(parsed["actor"], "null"); assert_eq!(parsed["tracks"], 2);
let frame_dir = parsed["frame_dir"].as_str().unwrap();
assert!(frame_dir.ends_with("frame"), "frame_dir: {frame_dir}");
assert!(
Path::new(frame_dir).is_absolute(),
"frame_dir should be absolute: {frame_dir}"
);
}
#[test]
fn test_info_json_tokened() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::write(tmp.path().join("frame/.actor"), "a\n").unwrap();
let out = run_fr_ok(tmp.path(), &["info", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["actor"], "a");
}
#[test]
fn test_info_json_unclaimed_is_read_only() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::remove_file(tmp.path().join("frame/.actor")).unwrap();
assert!(!tmp.path().join("frame/actors.toml").exists());
let out = run_fr_ok(tmp.path(), &["info", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(
parsed["actor"].is_null(),
"actor should be JSON null: {out}"
);
assert!(
!tmp.path().join("frame/.actor").exists(),
"fr info must not create .actor"
);
assert!(
!tmp.path().join("frame/actors.toml").exists(),
"fr info must not create actors.toml"
);
}
#[test]
fn test_info_human_unclaimed() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::remove_file(tmp.path().join("frame/.actor")).unwrap();
let out = run_fr_ok(tmp.path(), &["info"]);
assert!(out.contains("unclaimed"), "out: {out}");
}
fn git(dir: &Path, args: &[&str]) -> bool {
Command::new("git")
.current_dir(dir)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn clone_with_worktree(tmp: &Path) -> Option<(PathBuf, PathBuf)> {
let main = tmp.join("main");
fs::create_dir_all(&main).unwrap();
create_test_project(&main);
let ignore: String = frame::io::project_io::LOCAL_ONLY_FRAME_FILES
.iter()
.map(|name| format!("frame/{}\n", name))
.collect();
fs::write(main.join(".gitignore"), ignore).unwrap();
if !git(&main, &["init", "-q"]) {
return None;
}
git(&main, &["add", "-A"]);
let committed = git(
&main,
&[
"-c",
"user.name=frame-test",
"-c",
"user.email=frame@test.invalid",
"commit",
"-q",
"-m",
"init",
],
);
if !committed {
return None;
}
let worktree = tmp.join("wt");
if !git(
&main,
&["worktree", "add", "-q", "--detach", worktree.to_str()?],
) {
return None;
}
Some((main, worktree))
}
#[test]
fn test_worktrees_of_one_clone_do_not_mint_the_same_id() {
let tmp = tempfile::TempDir::new().unwrap();
let Some((main, worktree)) = clone_with_worktree(tmp.path()) else {
return; };
let first = run_fr_ok(&main, &["add", "main", "from main"]);
assert_eq!(first.trim(), "M-011");
let second = run_fr_ok(&worktree, &["add", "main", "from worktree"]);
assert_eq!(
second.trim(),
"M-012",
"worktree reissued a number the main tree already handed out"
);
let third = run_fr_ok(&main, &["add", "main", "from main again"]);
assert_eq!(third.trim(), "M-013");
assert!(main.join(".git/frame-ids.toml").is_file());
let (status, _, ok) = run_fr(&main, &["--json", "check"]);
assert!(ok, "check should pass: {status}");
}
#[test]
fn test_colliding_subtask_ids_are_detected_and_repaired() {
let tmp = tempfile::TempDir::new().unwrap();
let Some((main, worktree)) = clone_with_worktree(tmp.path()) else {
return; };
let mine = run_fr_ok(&main, &["sub", "M-003", "from main"]);
let theirs = run_fr_ok(&worktree, &["sub", "M-003", "from worktree"]);
assert_eq!(
mine.trim(),
theirs.trim(),
"the known open collision; if this ever stops holding, \
subtask minting grew a frontier and this test is the wrong shape"
);
assert_eq!(mine.trim(), "M-003.3");
let merged = fs::read_to_string(main.join("frame/tracks/main.md"))
.unwrap()
.replace(
" - [ ] `M-003.3` from main\n",
" - [ ] `M-003.3` from main\n - [ ] `M-003.3` from worktree\n",
);
fs::write(main.join("frame/tracks/main.md"), &merged).unwrap();
assert_eq!(merged.matches("`M-003.3`").count(), 2, "merge staged");
let (out, _, _) = run_fr(&main, &["check"]);
assert!(
out.contains("M-003.3 is duplicated"),
"should report the collision: {out}"
);
run_fr_ok(&main, &["clean"]);
let after = fs::read_to_string(main.join("frame/tracks/main.md")).unwrap();
assert!(
after.contains("`M-003.3` from main") && after.contains("`M-003.4` from worktree"),
"the second copy should take the next child number: {after}"
);
let (recheck, _, _) = run_fr(&main, &["check"]);
assert!(
!recheck.contains("duplicated"),
"duplicate survived: {recheck}"
);
assert!(
!recheck.contains("doesn't extend"),
"the repair must not misparent anything: {recheck}"
);
}
#[test]
fn test_archived_ids_are_not_reissued() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::create_dir_all(tmp.path().join("frame/archive")).unwrap();
fs::write(
tmp.path().join("frame/archive/main.md"),
"# Archive — main\n\n- [x] `M-050` archived task\n - resolved: 2025-06-01\n",
)
.unwrap();
let out = run_fr_ok(tmp.path(), &["add", "main", "after archiving"]);
assert_eq!(out.trim(), "M-051", "mint ignored the archive");
}
#[test]
fn test_deleted_ids_are_not_reissued() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let first = run_fr_ok(tmp.path(), &["add", "main", "doomed"]);
assert_eq!(first.trim(), "M-011");
run_fr_ok(tmp.path(), &["delete", "M-011", "--yes"]);
let second = run_fr_ok(tmp.path(), &["add", "main", "next"]);
assert_eq!(second.trim(), "M-012", "deleted number was reissued");
}
#[test]
fn test_check_flags_a_live_id_colliding_with_an_archived_one() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::create_dir_all(tmp.path().join("frame/archive")).unwrap();
fs::write(
tmp.path().join("frame/archive/main.md"),
"# Archive — main\n\n- [x] `M-001` archived work\n - resolved: 2025-06-01\n",
)
.unwrap();
let human = run_fr_ok(tmp.path(), &["check"]);
assert!(
human.contains("M-001 is live in main but is also archived in archive/main.md"),
"check should flag the reissue: {human}"
);
assert!(human.contains("the number was reissued"), "{human}");
let json = run_fr_ok(tmp.path(), &["check", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let warnings = parsed["warnings"].as_array().unwrap();
let reissue = warnings
.iter()
.find(|w| w["type"] == "id_reissued_after_archive")
.expect("id_reissued_after_archive warning");
assert_eq!(reissue["task_id"], "M-001");
assert_eq!(reissue["tracks"][0], "main");
assert_eq!(reissue["archives"][0], "archive/main.md");
assert_eq!(parsed["valid"], true);
}
#[test]
fn test_check_flags_a_duplicated_archive_entry() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::create_dir_all(tmp.path().join("frame/archive")).unwrap();
fs::write(
tmp.path().join("frame/archive/main.md"),
"# Archive — main\n\n- [x] `M-900` archived work\n - resolved: 2025-06-01\n- [x] `M-900` archived work\n - resolved: 2025-06-01\n",
)
.unwrap();
let human = run_fr_ok(tmp.path(), &["check"]);
assert!(
human.contains("M-900 appears 2 times in archive/main.md and in no live track"),
"check should flag duplicated history: {human}"
);
assert!(
human.contains("no number was reissued"),
"and must not claim a reissue: {human}"
);
let json = run_fr_ok(tmp.path(), &["check", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let warnings = parsed["warnings"].as_array().unwrap();
let duplicate = warnings
.iter()
.find(|w| w["type"] == "duplicate_archived_id")
.expect("duplicate_archived_id warning");
assert_eq!(duplicate["task_id"], "M-900");
assert_eq!(duplicate["total"], 2);
assert_eq!(duplicate["archives"].as_array().unwrap().len(), 1);
assert!(
!warnings
.iter()
.any(|w| w["type"] == "id_reissued_after_archive"),
"must not also report a reissue: {json}"
);
}
#[test]
fn test_check_flags_an_unreadable_id_frontier() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let store = tmp.path().join("frame/.ids.toml");
fs::write(&store, "not toml {{{").unwrap();
let human = run_fr_ok(tmp.path(), &["check"]);
assert!(
human.contains("is unreadable"),
"check should flag the store: {human}"
);
assert!(store.is_file(), "check must not reset the store");
run_fr_ok(tmp.path(), &["add", "main", "after reset"]);
let human = run_fr_ok(tmp.path(), &["check"]);
assert!(
human.contains("ID frontier was reset"),
"check should flag the leftover .bak: {human}"
);
}
fn project_with_open_fences(root: &Path) {
create_test_project(root);
fs::write(
root.join("frame/tracks/main.md"),
"\
# Main Track
## Backlog
- [ ] `M-001` Task with an open fence
- added: 2026-07-31
- note:
Example:
```rust
let x = 1;
## Done
",
)
.unwrap();
fs::write(
root.join("frame/inbox.md"),
"# Inbox\n\n- Item with an open body fence\n ```lace\n perform Ask()\n",
)
.unwrap();
}
#[test]
fn test_check_stays_read_only_without_fix() {
let tmp = tempfile::TempDir::new().unwrap();
project_with_open_fences(tmp.path());
let before = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let out = run_fr_ok(tmp.path(), &["check"]);
let after = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(out.contains("code fence open"), "should report it: {out}");
assert_eq!(before, after, "bare `fr check` must not write");
}
#[test]
fn test_check_fix_dry_run_writes_nothing() {
let tmp = tempfile::TempDir::new().unwrap();
project_with_open_fences(tmp.path());
let before = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let out = run_fr_ok(tmp.path(), &["check", "--fix", "--dry-run"]);
let after = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(out.contains("close note fence"), "should plan it: {out}");
assert!(out.contains("dry run"), "should say so: {out}");
assert_eq!(before, after, "--dry-run must not write");
}
#[test]
fn test_check_fix_closes_note_and_inbox_fences() {
let tmp = tempfile::TempDir::new().unwrap();
project_with_open_fences(tmp.path());
run_fr_ok(tmp.path(), &["check", "--fix"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
let inbox = fs::read_to_string(tmp.path().join("frame/inbox.md")).unwrap();
assert!(track.contains("let x = 1;"), "content preserved: {track}");
assert!(
track.matches("```").count() >= 2,
"note fence closed: {track}"
);
assert!(
inbox.matches("```").count() >= 2,
"inbox fence closed: {inbox}"
);
let recheck = run_fr_ok(tmp.path(), &["check"]);
assert!(
!recheck.contains("code fence open"),
"fences should be balanced now: {recheck}"
);
let again = run_fr_ok(tmp.path(), &["check", "--fix"]);
assert!(
again.contains("nothing to repair"),
"--fix must be idempotent: {again}"
);
}
const DUPLICATED_ARCHIVE: &str = "\
# Archive — main
- [x] `M-900` Archived twice
- resolved: 2026-01-01
- [x] `M-900` Archived twice
- resolved: 2026-01-01
- [x] `M-901` Archived once
- resolved: 2026-01-02
";
#[test]
fn test_check_fix_cancels_deleting_repair_without_yes() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::create_dir_all(tmp.path().join("frame/archive")).unwrap();
let archive = DUPLICATED_ARCHIVE;
fs::write(tmp.path().join("frame/archive/main.md"), archive).unwrap();
let (stdout, stderr, ok) = run_fr(tmp.path(), &["check", "--fix"]);
assert!(ok, "should exit cleanly: {stderr}");
assert!(
stdout.contains("duplicate archive") || stdout.contains("delete"),
"should describe the deleting repair: {stdout}"
);
assert!(stderr.contains("cancelled"), "should cancel: {stderr}");
let after = fs::read_to_string(tmp.path().join("frame/archive/main.md")).unwrap();
assert_eq!(after, archive, "archive must be untouched after cancelling");
}
#[test]
fn test_check_fix_yes_dedupes_archive_and_logs_recovery() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::create_dir_all(tmp.path().join("frame/archive")).unwrap();
fs::write(tmp.path().join("frame/archive/main.md"), DUPLICATED_ARCHIVE).unwrap();
run_fr_ok(tmp.path(), &["check", "--fix", "--yes"]);
let after = fs::read_to_string(tmp.path().join("frame/archive/main.md")).unwrap();
assert_eq!(
after.matches("`M-900`").count(),
1,
"one copy should remain: {after}"
);
assert!(after.contains("`M-901`"), "other tasks untouched: {after}");
assert!(
after.starts_with("# Archive — main"),
"the archive header is carried verbatim: {after}"
);
let log = run_fr_ok(tmp.path(), &["recovery"]);
assert!(
log.contains("M-900"),
"removed copy should be in the recovery log: {log}"
);
}
#[test]
fn test_check_fix_renumbers_a_subtask_that_escaped_its_parent() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::write(
tmp.path().join("frame/tracks/main.md"),
"\
# Main Track
## Backlog
- [ ] `M-003` Parent
- added: 2025-05-03
- [ ] `M-003.1` Sub one
- added: 2025-05-03
- [ ] `M-020` Escaped, with a child of its own
- added: 2025-05-03
- [ ] `M-020.1` Deep
- added: 2025-05-03
- [ ] `M-004` Waiting on the escapee
- added: 2025-05-03
- dep: M-020
## Done
",
)
.unwrap();
let reported = run_fr_ok(tmp.path(), &["check"]);
assert!(
reported.contains("M-020 is nested under M-003 but its id doesn't extend it"),
"should report it: {reported}"
);
let planned = run_fr_ok(tmp.path(), &["check", "--fix", "--dry-run"]);
assert!(
planned.contains("renumber under its parent M-003"),
"should plan it: {planned}"
);
run_fr_ok(tmp.path(), &["check", "--fix", "--yes"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains("`M-003.2` Escaped"),
"should take the next free child number: {track}"
);
assert!(
track.contains("`M-003.2.1` Deep"),
"descendants follow: {track}"
);
assert!(
track.contains("dep: M-003.2"),
"deps follow the rekey: {track}"
);
let recheck = run_fr_ok(tmp.path(), &["check"]);
assert!(
!recheck.contains("doesn't extend"),
"finding should be gone: {recheck}"
);
let again = run_fr_ok(tmp.path(), &["check", "--fix"]);
assert!(
again.contains("nothing to repair"),
"--fix must be idempotent: {again}"
);
}
#[test]
fn test_check_fix_adds_the_gitignore_pattern() {
let tmp = tempfile::TempDir::new().unwrap();
if !std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return; }
create_test_project(tmp.path());
fs::write(tmp.path().join(".gitignore"), "target/\n").unwrap();
run_fr_ok(tmp.path(), &["check", "--fix"]);
let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(
gitignore.contains("frame/.*"),
"the pattern should be added: {gitignore}"
);
assert_eq!(
gitignore.lines().filter(|l| l.trim() == "frame/.*").count(),
1,
"exactly once, however many files were reported: {gitignore}"
);
for name in frame::io::project_io::LOCAL_ONLY_FRAME_FILES {
let ok = std::process::Command::new("git")
.args(["check-ignore", "-q", &format!("frame/{name}")])
.current_dir(tmp.path())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "frame/{name} should be ignored by the pattern");
}
}
fn two_track_project(root: &Path) {
run_fr_ok(
root,
&[
"init", "--name", "p", "--track", "a", "A", "--track", "b", "B",
],
);
run_fr_ok(root, &["add", "a", "the task to move"]);
}
fn tracks_holding(root: &Path, needle: &str) -> Vec<String> {
let mut out = Vec::new();
let dir = root.join("frame/tracks");
let mut entries: Vec<_> = fs::read_dir(&dir)
.expect("tracks dir")
.filter_map(|e| e.ok())
.map(|e| e.path())
.collect();
entries.sort();
for path in entries {
if fs::read_to_string(&path)
.map(|c| c.contains(needle))
.unwrap_or(false)
{
out.push(path.file_name().unwrap().to_string_lossy().into_owned());
}
}
out
}
#[test]
fn test_cross_track_move_survives_target_write_failure() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
let (_, _, ok) = run_fr_env(
tmp.path(),
&["mv", "A-001", "--track", "b"],
&[("FRAME_FAIL_WRITE", "tracks/b.md")],
);
assert!(!ok, "the injected failure should fail the command");
assert_eq!(
tracks_holding(tmp.path(), "the task to move"),
vec!["a.md"],
"task must remain in the source track when the target write is cut"
);
}
#[test]
fn test_cross_track_move_survives_source_write_failure() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
let (_, _, ok) = run_fr_env(
tmp.path(),
&["mv", "A-001", "--track", "b"],
&[("FRAME_FAIL_WRITE", "tracks/a.md")],
);
assert!(!ok, "the injected failure should fail the command");
let holding = tracks_holding(tmp.path(), "the task to move");
assert!(
holding.contains(&"b.md".to_string()),
"target should hold the moved task: {holding:?}"
);
assert!(
!holding.is_empty(),
"the task must survive somewhere, whichever write is cut"
);
}
#[test]
fn test_track_archive_recovers_from_interrupted_file_move() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
let (_, _, ok) = run_fr_env(
tmp.path(),
&["track", "archive", "a"],
&[("FRAME_FAIL_WRITE", "tracks/a.md")],
);
assert!(!ok, "the injected failure should fail the command");
let config = fs::read_to_string(tmp.path().join("frame/project.toml")).unwrap();
assert!(config.contains("archived"), "config was written first");
assert!(
tmp.path().join("frame/tracks/a.md").exists(),
"the file move is what was cut"
);
run_fr_ok(tmp.path(), &["add", "b", "unrelated"]);
assert!(
!tmp.path().join("frame/tracks/a.md").exists(),
"recovery should move the file out of tracks/"
);
assert!(
tmp.path().join("frame/archive/_tracks/a.md").exists(),
"and into archive/_tracks/"
);
}
#[test]
fn test_check_fix_converges_after_a_partial_application() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
for (file, id) in [("main.md", "M-500"), ("side.md", "S-500")] {
fs::write(
tmp.path().join("frame/tracks").join(file),
format!(
"# T\n\n## Backlog\n\n- [ ] `{id}` Open fence\n - note:\n ```rust\n let x = 1;\n\n## Done\n"
),
)
.unwrap();
}
let (_, _, ok) = run_fr_env(
tmp.path(),
&["check", "--fix"],
&[("FRAME_FAIL_WRITE", "tracks/side.md")],
);
assert!(!ok, "the injected failure should fail the command");
run_fr_ok(tmp.path(), &["check", "--fix"]);
let recheck = run_fr_ok(tmp.path(), &["check"]);
assert!(
!recheck.contains("code fence open"),
"both fences should be closed after the re-run: {recheck}"
);
for file in ["main.md", "side.md"] {
let content = fs::read_to_string(tmp.path().join("frame/tracks").join(file)).unwrap();
assert_eq!(
content.matches("```").count(),
2,
"{file} should have one opener and one closer:\n{content}"
);
}
}
#[test]
fn test_fault_injection_is_off_by_default() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
run_fr_ok(tmp.path(), &["mv", "A-001", "--track", "b"]);
assert_eq!(
tracks_holding(tmp.path(), "the task to move"),
vec!["b.md"],
"an uninjected move should complete normally"
);
}
#[test]
fn test_actor_merge_converges_when_the_registry_write_is_cut() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "p", "--track", "a", "A"]);
run_fr_ok(tmp.path(), &["actor", "set", "x"]);
run_fr_ok(tmp.path(), &["add", "a", "from x"]);
run_fr_ok(tmp.path(), &["actor", "set", "y"]);
run_fr_ok(tmp.path(), &["add", "a", "from y"]);
let (_, _, ok) = run_fr_env(
tmp.path(),
&["actor", "merge", "x", "--into", "y"],
&[("FRAME_FAIL_WRITE", "actors.toml")],
);
assert!(!ok, "the injected failure should fail the command");
let track = fs::read_to_string(tmp.path().join("frame/tracks/a.md")).unwrap();
assert!(
!track.contains("`A-x"),
"no id should remain in the merged-away namespace: {track}"
);
let listing = run_fr_ok(tmp.path(), &["actor", "list"]);
assert!(
listing
.lines()
.any(|l| l.contains(" x ") && l.contains("active")),
"x should still be active after the cut: {listing}"
);
run_fr_ok(tmp.path(), &["actor", "merge", "x", "--into", "y"]);
let listing = run_fr_ok(tmp.path(), &["actor", "list"]);
assert!(
listing
.lines()
.any(|l| l.contains(" x ") && l.contains("retired")),
"x should be retired after the re-run: {listing}"
);
assert!(
run_fr_ok(tmp.path(), &["check"]).contains("valid"),
"project should be consistent after recovery"
);
}
#[test]
fn test_interrupted_cross_track_move_is_recovered_by_the_next_write() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
let (_, _, ok) = run_fr_env(
tmp.path(),
&["mv", "A-001", "--track", "b"],
&[("FRAME_FAIL_WRITE", "tracks/a.md")],
);
assert!(!ok);
assert_eq!(
tracks_holding(tmp.path(), "the task to move"),
vec!["a.md", "b.md"],
);
assert!(
tmp.path().join("frame/.inflight").exists(),
"marker written"
);
let checked = run_fr_ok(tmp.path(), &["check"]);
assert!(
checked.contains("did not finish"),
"check should report the interrupted operation: {checked}"
);
let (_, stderr, _) = run_fr(tmp.path(), &["add", "a", "something else"]);
assert!(
stderr.contains("recovered an interrupted"),
"recovery should be announced: {stderr}"
);
assert_eq!(
tracks_holding(tmp.path(), "the task to move"),
vec!["b.md"],
"the move should now be complete — target only"
);
assert!(
!tmp.path().join("frame/.inflight").exists(),
"marker cleared after recovery"
);
assert!(run_fr_ok(tmp.path(), &["check"]).contains("valid"));
}
#[test]
fn test_interrupted_triage_is_recovered() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
run_fr_ok(tmp.path(), &["inbox", "an idea worth keeping"]);
let (_, _, ok) = run_fr_env(
tmp.path(),
&["triage", "1", "--track", "b"],
&[("FRAME_FAIL_WRITE", "inbox.md")],
);
assert!(!ok);
let inbox = fs::read_to_string(tmp.path().join("frame/inbox.md")).unwrap();
assert!(
inbox.contains("an idea worth keeping"),
"still in the inbox"
);
run_fr_ok(tmp.path(), &["add", "a", "something else"]);
let inbox = fs::read_to_string(tmp.path().join("frame/inbox.md")).unwrap();
assert!(
!inbox.contains("an idea worth keeping"),
"recovery should remove the inbox copy: {inbox}"
);
assert!(
tracks_holding(tmp.path(), "an idea worth keeping") == vec!["b.md"],
"and the task should remain"
);
}
#[test]
fn test_interrupted_actor_merge_is_recovered() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "p", "--track", "a", "A"]);
run_fr_ok(tmp.path(), &["actor", "set", "x"]);
run_fr_ok(tmp.path(), &["add", "a", "from x"]);
run_fr_ok(tmp.path(), &["actor", "set", "y"]);
run_fr_ok(tmp.path(), &["add", "a", "from y"]);
let (_, _, ok) = run_fr_env(
tmp.path(),
&["actor", "merge", "x", "--into", "y"],
&[("FRAME_FAIL_WRITE", "actors.toml")],
);
assert!(!ok);
run_fr_ok(tmp.path(), &["add", "a", "something else"]);
let listing = run_fr_ok(tmp.path(), &["actor", "list"]);
assert!(
listing
.lines()
.any(|l| l.contains(" x ") && l.contains("retired")),
"recovery should retire the merged-away token: {listing}"
);
}
#[test]
fn test_a_completed_operation_leaves_no_marker() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
run_fr_ok(tmp.path(), &["mv", "A-001", "--track", "b"]);
assert!(
!tmp.path().join("frame/.inflight").exists(),
"marker should be cleared on success"
);
assert!(run_fr_ok(tmp.path(), &["check"]).contains("valid"));
}
#[test]
fn test_recovery_declines_when_a_precondition_fails() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
run_fr_ok(tmp.path(), &["inbox", "an orphan idea"]);
fs::write(
tmp.path().join("frame/.inflight"),
"command = \"fr triage 1 --track b\"\n\
started = \"2026-07-31T00:00:00Z\"\n\
kind = \"triage\"\n\
index = 1\n\
title = \"an orphan idea\"\n\
track_id = \"b\"\n",
)
.unwrap();
let (_, stderr, _) = run_fr(tmp.path(), &["add", "a", "something else"]);
assert!(
stderr.contains("could not be completed automatically"),
"should decline and say so: {stderr}"
);
let inbox = fs::read_to_string(tmp.path().join("frame/inbox.md")).unwrap();
assert!(
inbox.contains("an orphan idea"),
"the inbox item must not be dropped when the task never landed: {inbox}"
);
assert!(
tmp.path().join("frame/.inflight").exists(),
"marker kept so the warning stands"
);
run_fr_ok(tmp.path(), &["check", "--fix", "--yes"]);
assert!(
!tmp.path().join("frame/.inflight").exists(),
"--fix --yes should clear a marker recovery declined to act on"
);
}
#[test]
fn test_inflight_gitignore_entry_is_reported_even_when_absent() {
let tmp = tempfile::TempDir::new().unwrap();
if !std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return; }
create_test_project(tmp.path());
fs::write(
tmp.path().join(".gitignore"),
"frame/.state.json\nframe/.lock\nframe/.recovery.log\nframe/.actor\n\
frame/.ids.toml\nframe/.ids.lock\n",
)
.unwrap();
assert!(
!tmp.path().join("frame/.inflight").exists(),
"no operation is in flight — that is the point"
);
let checked = run_fr_ok(tmp.path(), &["check"]);
assert!(
checked.contains("frame/.inflight"),
"should be reported even though the file is absent: {checked}"
);
run_fr_ok(tmp.path(), &["check", "--fix"]);
let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(
gitignore.contains("frame/.*"),
"--fix should add the pattern, which covers it: {gitignore}"
);
fs::write(tmp.path().join(".gitignore"), "frame/.state.json\n").unwrap();
let checked = run_fr_ok(tmp.path(), &["check"]);
assert!(
!checked.contains("frame/.ids.toml"),
"an absent persistent file should stay unreported: {checked}"
);
}