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(r#"cc_focus = """#));
let config: frame::model::ProjectConfig = toml::from_str(&config_text).unwrap();
assert!(config.agent.cc_focus.is_none());
let _out = run_fr_ok(tmp.path(), &["ready", "--cc"]);
}
#[test]
fn test_track_mv_keeps_comments_and_unmodelled_keys() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let path = tmp.path().join("frame/project.toml");
let annotated = format!(
"# The project's own notes\n{}\n[experimental]\nnot_in_the_struct = true\n",
fs::read_to_string(&path).unwrap()
);
fs::write(&path, &annotated).unwrap();
run_fr_ok(tmp.path(), &["track", "mv", "side", "0"]);
let after = fs::read_to_string(&path).unwrap();
assert!(
after.contains("# The project's own notes"),
"the comment was erased by the reorder:\n{after}"
);
assert!(
after.contains("not_in_the_struct = true"),
"a key the struct does not model was erased by the reorder:\n{after}"
);
let config: frame::model::ProjectConfig = toml::from_str(&after).unwrap();
let ids: Vec<&str> = config.tracks.iter().map(|t| t.id.as_str()).collect();
assert_eq!(
ids,
vec!["side", "main"],
"the reorder itself must still work"
);
}
#[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."));
}
fn create_ref_targets(root: &Path) {
fs::create_dir_all(root.join("doc")).unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("doc/design.md"), "# Design\n").unwrap();
fs::write(root.join("doc/spec.md"), "# Spec\n").unwrap();
fs::write(root.join("src/parser.rs"), "fn main() {}\n").unwrap();
}
const PATH_FIELDS: [&str; 2] = ["ref", "spec"];
#[test]
fn test_path_field_add() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
run_fr_ok(tmp.path(), &[field, "M-001", "add", "src/parser.rs"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: doc/design.md, src/parser.rs", field)),
"{} add did not append:\n{}",
field,
track
);
}
}
#[test]
fn test_path_field_add_is_idempotent() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
let out = run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
assert!(out.contains("unchanged"), "{}: {}", field, out);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert_eq!(
track.matches("doc/design.md").count(),
1,
"{} duplicated a path",
field
);
}
}
#[test]
fn test_path_field_set_replaces_the_list() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
run_fr_ok(
tmp.path(),
&[
field,
"M-001",
"set",
"doc/spec.md#section",
"src/parser.rs",
],
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains(&format!("{}: doc/spec.md#section, src/parser.rs", field)));
assert!(
!track.contains("doc/design.md"),
"{} set did not replace",
field
);
}
}
#[test]
fn test_path_field_rm() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(
tmp.path(),
&[field, "M-001", "add", "doc/design.md", "src/parser.rs"],
);
run_fr_ok(tmp.path(), &[field, "M-001", "rm", "doc/design.md"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains(&format!("{}: src/parser.rs", field)));
assert!(!track.contains("doc/design.md"));
}
}
#[test]
fn test_path_field_rm_last_removes_the_line() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
run_fr_ok(tmp.path(), &[field, "M-001", "rm", "doc/design.md"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
!track.contains(&format!("{}:", field)),
"{} left an empty line:\n{}",
field,
track
);
}
}
#[test]
fn test_path_field_rm_works_after_the_file_is_deleted() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
fs::remove_file(tmp.path().join("doc/design.md")).unwrap();
run_fr_ok(tmp.path(), &[field, "M-001", "rm", "doc/design.md"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!track.contains("doc/design.md"));
}
}
#[test]
fn test_path_field_rm_of_an_absent_path_says_so() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
let out = run_fr_ok(tmp.path(), &[field, "M-001", "rm", "doc/design.md"]);
assert!(out.contains("unchanged"), "{}: {}", field, out);
}
}
fn store_raw_path_value(root: &Path, field: &str, value: &str) {
let path = root.join("frame/tracks/main.md");
let track = fs::read_to_string(&path).unwrap();
let updated = track.replace(
"- [ ] `M-001` First task #core\n - added: 2025-05-01\n",
&format!(
"- [ ] `M-001` First task #core\n - added: 2025-05-01\n - {}: {}\n",
field, value
),
);
assert_ne!(
track, updated,
"fixture shape changed; nothing was injected"
);
fs::write(&path, updated).unwrap();
}
#[test]
fn test_path_field_stores_the_normal_form() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
let out = run_fr_ok(
tmp.path(),
&[field, "M-001", "add", "./doc/../doc/design.md"],
);
assert!(out.contains("doc/design.md"), "{}: {}", field, out);
assert!(
!out.contains(".."),
"{} echoed the raw spelling: {}",
field,
out
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: doc/design.md", field)),
"{} stored an unfolded path:\n{}",
field,
track
);
}
}
#[test]
fn test_path_field_normalizes_without_disturbing_the_suffix() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(
tmp.path(),
&[
field,
"M-001",
"add",
"./doc/../doc/design.md#rationale",
"./src/../src/parser.rs:807",
],
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!(
"{}: doc/design.md#rationale, src/parser.rs:807",
field
)),
"{}:\n{}",
field,
track
);
}
}
#[test]
fn test_path_field_rm_matches_an_equivalent_spelling() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
let out = run_fr_ok(
tmp.path(),
&[field, "M-001", "rm", "./doc/../doc/design.md"],
);
assert!(out.contains("removed"), "{}: {}", field, out);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!track.contains("design.md"), "{}:\n{}", field, track);
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
store_raw_path_value(tmp.path(), field, "./doc/../doc/design.md");
let out = run_fr_ok(tmp.path(), &[field, "M-001", "rm", "doc/design.md"]);
assert!(out.contains("removed"), "{}: {}", field, out);
assert!(
out.contains("./doc/../doc/design.md"),
"{} reported the argument rather than the stored value: {}",
field,
out
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!track.contains("design.md"), "{}:\n{}", field, track);
}
}
#[test]
fn test_path_field_add_does_not_duplicate_an_equivalent_spelling() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
store_raw_path_value(tmp.path(), field, "./doc/../doc/design.md");
let out = run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
assert!(out.contains("unchanged"), "{}: {}", field, out);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert_eq!(
track.matches("design.md").count(),
1,
"{} stored one file twice:\n{}",
field,
track
);
}
}
#[test]
fn test_path_field_rm_keeps_a_line_reference_apart() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(
tmp.path(),
&[field, "M-001", "add", "src/parser.rs", "src/parser.rs:807"],
);
run_fr_ok(tmp.path(), &[field, "M-001", "rm", "src/parser.rs"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: src/parser.rs:807", field)),
"{} removed the line reference too:\n{}",
field,
track
);
}
}
#[test]
fn test_path_field_set_dedupes_equivalent_spellings() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
let out = run_fr_ok(
tmp.path(),
&[
field,
"M-001",
"set",
"doc/design.md",
"./doc/../doc/design.md",
"src/parser.rs",
],
);
assert!(
out.contains("set: doc/design.md, src/parser.rs"),
"{} reported what it was handed rather than what it stored: {}",
field,
out
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: doc/design.md, src/parser.rs", field)),
"{}:\n{}",
field,
track
);
}
}
#[test]
fn test_path_field_unknown_action_is_refused() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
let (_, stderr, ok) = run_fr(tmp.path(), &[field, "M-001", "append", "doc/design.md"]);
assert!(!ok, "{} accepted a bogus action", field);
assert!(stderr.contains("add, rm, set"), "{}", stderr);
}
}
#[test]
fn test_path_field_accepts_an_anchor_or_a_line_reference() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(
tmp.path(),
&[
field,
"M-001",
"add",
"doc/design.md#rationale",
"src/parser.rs:807",
"src/parser.rs:807-820",
],
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains(&format!(
"{}: doc/design.md#rationale, src/parser.rs:807, src/parser.rs:807-820",
field
)));
let (stdout, _, ok) = run_fr(tmp.path(), &["check"]);
assert!(ok, "check rejected a valid {}: {}", field, stdout);
}
}
#[test]
fn test_path_field_refuses_a_missing_path() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
let (_, stderr, ok) = run_fr(tmp.path(), &[field, "M-001", "add", "doc/typo.md"]);
assert!(!ok, "a missing path should exit non-zero");
assert!(stderr.contains("no such file: doc/typo.md"), "{}", stderr);
assert!(stderr.contains("--force"), "{}", stderr);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
!track.contains(&format!("{}:", field)),
"nothing should have been written"
);
}
}
#[test]
fn test_path_field_names_every_missing_path_and_writes_none() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
let (_, stderr, ok) = run_fr(
tmp.path(),
&[field, "M-001", "add", "doc/design.md", "a.md", "b.md"],
);
assert!(!ok);
assert!(
stderr.contains("a.md") && stderr.contains("b.md"),
"{}",
stderr
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!track.contains(&format!("{}:", field)));
}
}
#[test]
fn test_path_field_force_accepts_a_missing_path() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
run_fr_ok(
tmp.path(),
&[field, "M-001", "add", "doc/not-yet.md", "--force"],
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(track.contains(&format!("{}: doc/not-yet.md", field)));
}
}
#[test]
fn test_path_field_refuses_a_path_outside_the_project() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("project");
fs::create_dir_all(&root).unwrap();
create_test_project(&root);
create_ref_targets(&root);
fs::write(tmp.path().join("outside.md"), "outside\n").unwrap();
let inside_absolute = root.join("doc/design.md").to_string_lossy().into_owned();
for (path, expected) in [
("../outside.md", "leaves the project root"),
("/etc/hosts", "is absolute"),
(inside_absolute.as_str(), "is absolute"),
] {
let (_, stderr, ok) = run_fr(&root, &[field, "M-001", "add", path]);
assert!(!ok, "{} accepted {}", field, path);
assert!(
stderr.contains(path) && stderr.contains(expected),
"{} on {}: {}",
field,
path,
stderr
);
}
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
assert!(
!track.contains(&format!("{}:", field)),
"nothing should have been written:\n{}",
track
);
}
}
#[test]
fn test_path_field_refuses_an_escape_that_only_appears_after_folding() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("project");
fs::create_dir_all(&root).unwrap();
create_test_project(&root);
create_ref_targets(&root);
fs::write(tmp.path().join("outside.md"), "outside\n").unwrap();
let (_, stderr, ok) = run_fr(&root, &[field, "M-001", "add", "doc/../../outside.md"]);
assert!(!ok, "{} accepted a folded escape", field);
assert!(stderr.contains("leaves the project root"), "{}", stderr);
run_fr_ok(&root, &[field, "M-001", "add", "doc/../src/parser.rs"]);
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: src/parser.rs", field)),
"{}",
track
);
}
}
#[test]
fn test_path_field_names_every_uncontained_path() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("project");
fs::create_dir_all(&root).unwrap();
create_test_project(&root);
create_ref_targets(&root);
fs::write(tmp.path().join("outside.md"), "outside\n").unwrap();
let (_, stderr, ok) = run_fr(
&root,
&[
field,
"M-001",
"add",
"doc/design.md",
"../outside.md",
"/etc/hosts",
],
);
assert!(!ok);
assert!(
stderr.contains("../outside.md") && stderr.contains("/etc/hosts"),
"{}",
stderr
);
assert!(stderr.contains("--force"), "{}", stderr);
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
assert!(!track.contains(&format!("{}:", field)));
}
}
#[test]
fn test_path_field_force_accepts_an_uncontained_path() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().join("project");
fs::create_dir_all(&root).unwrap();
create_test_project(&root);
create_ref_targets(&root);
fs::write(tmp.path().join("outside.md"), "outside\n").unwrap();
run_fr_ok(&root, &[field, "M-001", "add", "../outside.md", "--force"]);
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: ../outside.md", field)),
"{}",
track
);
}
}
fn project_ignoring_scratch(root: &Path) -> Option<()> {
create_test_project(root);
create_ref_targets(root);
fs::create_dir_all(root.join("scratch")).unwrap();
fs::write(root.join("scratch/notes.md"), "notes\n").unwrap();
fs::write(root.join("doc/draft.tmp"), "draft\n").unwrap();
fs::write(root.join(".gitignore"), "scratch/\n*.tmp\nframe/.*\n").unwrap();
Command::new("git")
.current_dir(root)
.args(["init", "-q"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
.then_some(())
}
#[test]
fn test_path_field_refuses_a_gitignored_path() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
if project_ignoring_scratch(tmp.path()).is_none() {
return; }
for path in ["scratch/notes.md", "doc/draft.tmp"] {
let (_, stderr, ok) = run_fr(tmp.path(), &[field, "M-001", "add", path]);
assert!(!ok, "{} accepted {}", field, path);
assert!(
stderr.contains(path) && stderr.contains("ignored by git"),
"{} on {}: {}",
field,
path,
stderr
);
}
run_fr_ok(tmp.path(), &[field, "M-001", "add", "doc/design.md"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: doc/design.md", field)),
"{}",
track
);
}
}
#[test]
fn test_path_field_gitignore_check_ignores_the_suffix() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
if project_ignoring_scratch(tmp.path()).is_none() {
return;
}
let (_, stderr, ok) = run_fr(tmp.path(), &[field, "M-001", "add", "doc/draft.tmp:12"]);
assert!(!ok, "{} accepted a suffixed gitignored path", field);
assert!(stderr.contains("ignored by git"), "{}", stderr);
}
}
#[test]
fn test_path_field_accepts_a_gitignored_path_that_is_tracked() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
if project_ignoring_scratch(tmp.path()).is_none() {
return;
}
assert!(git(tmp.path(), &["add", "-f", "scratch/notes.md"]));
run_fr_ok(tmp.path(), &[field, "M-001", "add", "scratch/notes.md"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: scratch/notes.md", field)),
"{}",
track
);
}
}
#[test]
fn test_path_field_force_accepts_a_gitignored_path() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
if project_ignoring_scratch(tmp.path()).is_none() {
return;
}
run_fr_ok(
tmp.path(),
&[field, "M-001", "add", "scratch/notes.md", "--force"],
);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: scratch/notes.md", field)),
"{}",
track
);
}
}
#[test]
fn test_path_field_gitignore_check_is_a_no_op_outside_a_repo() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
fs::create_dir_all(tmp.path().join("scratch")).unwrap();
fs::write(tmp.path().join("scratch/notes.md"), "notes\n").unwrap();
fs::write(tmp.path().join(".gitignore"), "scratch/\n").unwrap();
run_fr_ok(tmp.path(), &[field, "M-001", "add", "scratch/notes.md"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(
track.contains(&format!("{}: scratch/notes.md", field)),
"{}",
track
);
}
}
#[test]
fn test_check_warns_about_refs_that_will_not_travel_without_failing() {
let tmp = tempfile::TempDir::new().unwrap();
if project_ignoring_scratch(tmp.path()).is_none() {
return; }
let absolute = tmp
.path()
.join("doc/design.md")
.to_string_lossy()
.into_owned();
for path in [absolute.as_str(), "scratch/notes.md"] {
run_fr_ok(tmp.path(), &["ref", "M-001", "add", path, "--force"]);
}
let (stdout, _, ok) = run_fr(tmp.path(), &["check"]);
assert!(ok, "warnings must not fail the check: {}", stdout);
assert!(stdout.contains("is absolute"), "{}", stdout);
assert!(stdout.contains("ignored by git"), "{}", stdout);
let (json, _, ok) = run_fr(tmp.path(), &["check", "--json"]);
assert!(ok);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let tags: Vec<&str> = value["warnings"]
.as_array()
.unwrap()
.iter()
.filter_map(|w| w["type"].as_str())
.collect();
assert!(
tags.contains(&"ref_outside_project") && tags.contains(&"ref_gitignored"),
"{:?}",
tags
);
}
#[test]
fn test_check_is_silent_about_an_ordinary_ref() {
let tmp = tempfile::TempDir::new().unwrap();
if project_ignoring_scratch(tmp.path()).is_none() {
return;
}
run_fr_ok(tmp.path(), &["ref", "M-001", "add", "doc/design.md"]);
let (stdout, _, ok) = run_fr(tmp.path(), &["check"]);
assert!(ok, "{}", stdout);
assert!(
!stdout.contains("is absolute") && !stdout.contains("ignored by git"),
"{}",
stdout
);
}
#[test]
fn test_path_field_rm_removes_an_uncontained_path() {
for field in PATH_FIELDS {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
create_ref_targets(tmp.path());
store_raw_path_value(tmp.path(), field, "../outside.md, /etc/hosts");
run_fr_ok(tmp.path(), &[field, "M-001", "rm", "../outside.md"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!track.contains("outside.md"), "{}", track);
assert!(track.contains("/etc/hosts"), "{}", track);
run_fr_ok(tmp.path(), &[field, "M-001", "rm", "/etc/hosts"]);
let track = fs::read_to_string(tmp.path().join("frame/tracks/main.md")).unwrap();
assert!(!track.contains(&format!("{}:", field)), "{}", track);
}
}
#[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_reaches_the_archive() {
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/side.md"),
"# Archive — side\n\n- [x] `S-050` archived work\n - resolved: 2025-06-01\n",
)
.unwrap();
let out = run_fr_ok(
tmp.path(),
&["track", "rename", "side", "--prefix", "AUX", "--yes"],
);
assert!(
out.contains("1 archived task ID"),
"the archived id should be counted and reported: {out}"
);
let archive = fs::read_to_string(tmp.path().join("frame/archive/side.md")).unwrap();
assert!(
archive.contains("`AUX-050`") && !archive.contains("`S-050`"),
"the archived id keeps the old prefix: {archive}"
);
assert!(
archive.starts_with("# Archive — side"),
"and the header is still the archive's own: {archive}"
);
}
#[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_configures_git() {
let tmp = tempfile::TempDir::new().unwrap();
if !git_ok(tmp.path(), &["init", "-q"]) {
return; }
let out = run_fr_ok(tmp.path(), &["init", "--name", "Git Project"]);
assert!(
out.contains("configured git"),
"summary should say so: {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}"
);
let attrs = fs::read_to_string(tmp.path().join(".gitattributes")).unwrap();
assert!(
attrs.contains("frame/tracks/*.md merge=frame"),
"track files should route to the driver: {attrs}"
);
let checked = run_fr_ok(tmp.path(), &["check"]);
assert!(
!checked.contains("merge driver"),
"a project init just configured should not warn about the driver: {checked}"
);
}
#[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_collapses_an_enumerated_gitignore() {
let tmp = tempfile::TempDir::new().unwrap();
if !git_ok(tmp.path(), &["init", "-q"]) {
return; }
fs::write(
tmp.path().join(".gitignore"),
"*.log\nframe/.lock\nframe/.actor\n",
)
.unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "Partial"]);
let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(gitignore.contains("frame/.*"), "{gitignore}");
assert!(
!gitignore.lines().any(|l| l.trim() == "frame/.lock"),
"the pattern covers it, so the line should be gone: {gitignore}"
);
assert!(
!gitignore.lines().any(|l| l.trim() == "frame/.actor"),
"same: {gitignore}"
);
assert!(
gitignore.lines().any(|l| l.trim() == "*.log"),
"unrelated entries must survive: {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"));
}
fn write_numbered_log(root: &Path, count: usize) {
let mut content = String::from(
"\
<!-- 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. -->
---
",
);
for i in 0..count {
content.push_str(&format!(
"## 2026-02-10T{:02}:00:00Z — delete: task M-{} deleted\n\nTrack: main\n\n---\n",
i % 24,
i
));
}
fs::write(root.join("frame/.recovery.log"), content).unwrap();
}
fn write_conflict_sides(dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
fs::create_dir_all(dir).unwrap();
let shell = |task: &str| format!("# Main Track\n\n## Backlog\n\n{task}\n## Done\n");
let base = dir.join("base.md");
let ours = dir.join("ours.md");
let theirs = dir.join("theirs.md");
fs::write(&base, shell("- [ ] `M-001` Original\n")).unwrap();
fs::write(&ours, shell("- [ ] `M-001` Our edit\n")).unwrap();
fs::write(&theirs, shell("- [ ] `M-001` Their edit\n")).unwrap();
(base, ours, theirs)
}
fn run_merge(cwd: &Path, base: &Path, ours: &Path, theirs: &Path) -> (String, String, bool) {
run_fr(
cwd,
&[
"merge",
"--kind",
"track",
"--base",
base.to_str().unwrap(),
"--ours",
ours.to_str().unwrap(),
"--theirs",
theirs.to_str().unwrap(),
],
)
}
#[test]
fn merge_names_the_recovery_log_it_wrote_to_by_absolute_path() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (base, ours, theirs) = write_conflict_sides(&tmp.path().join("work"));
let (_, stderr, ok) = run_merge(tmp.path(), &base, &ours, &theirs);
assert!(!ok, "a conflict exits non-zero");
assert!(
stderr.contains("is in the recovery log"),
"it did log, so it should say so:\n{stderr}"
);
assert!(
stderr.contains("fr recovery --for M-001"),
"and name the lookup that retrieves it:\n{stderr}"
);
let named = stderr
.lines()
.map(str::trim)
.find(|l| l.ends_with(".recovery.log") || l.ends_with("frame-recovery.log"))
.unwrap_or_else(|| panic!("no log path in:\n{stderr}"));
assert!(
Path::new(named).is_absolute(),
"the reader may be anywhere: {named}"
);
assert!(Path::new(named).exists(), "and it is really there: {named}");
}
#[test]
fn merge_declines_to_log_into_a_project_that_does_not_hold_the_merged_file() {
let bystander = tempfile::TempDir::new().unwrap();
create_test_project(bystander.path());
let elsewhere = tempfile::TempDir::new().unwrap();
let (base, ours, theirs) = write_conflict_sides(elsewhere.path());
let (_, stderr, ok) = run_merge(bystander.path(), &base, &ours, &theirs);
assert!(!ok);
assert!(
stderr.contains("NOT recorded"),
"it must say the other side went nowhere:\n{stderr}"
);
assert!(
stderr.contains("recover theirs from version control"),
"and where to look instead:\n{stderr}"
);
assert!(
!bystander.path().join("frame/.recovery.log").exists(),
"an unrelated project's log must not be written to"
);
let listed = run_fr_ok(bystander.path(), &["recovery"]);
assert!(
!listed.contains("M-001"),
"nor its listing polluted:\n{listed}"
);
}
#[test]
fn merge_logs_into_the_project_holding_the_merged_file() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let (base, ours, theirs) = write_conflict_sides(&tmp.path().join("work"));
let outside = tempfile::TempDir::new().unwrap();
let (_, stderr, _) = run_merge(outside.path(), &base, &ours, &theirs);
assert!(stderr.contains("is in the recovery log"), "{stderr}");
let listed = run_fr_ok(tmp.path(), &["recovery", "--for", "M-001"]);
assert!(
listed.contains("Their edit"),
"their version should be retrievable from the project that owns the file:\n{listed}"
);
}
#[test]
fn check_reports_rescue_copies_nobody_has_dealt_with() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let rescue = tmp.path().join("frame/.rescue");
fs::create_dir_all(&rescue).unwrap();
fs::write(rescue.join("main.md"), "# Main Track\n").unwrap();
let (out, _, ok) = run_fr(tmp.path(), &["check"]);
assert!(
ok,
"a waiting rescue copy is a warning, not a broken project:\n{out}"
);
assert!(out.contains("rescue"), "{out}");
assert!(
out.contains("main.md"),
"it should say what is waiting:\n{out}"
);
let named = out
.lines()
.map(str::trim)
.find(|l| l.ends_with("frame/.rescue"))
.unwrap_or_else(|| panic!("check should name the directory:\n{out}"));
assert!(Path::new(named).is_absolute(), "{named}");
fs::remove_dir_all(&rescue).unwrap();
let (after, _, _) = run_fr(tmp.path(), &["check"]);
assert!(!after.contains("rescue"), "{after}");
}
#[test]
fn check_names_the_recovery_log_it_summarises() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
run_fr_ok(tmp.path(), &["delete", "M-001", "--yes"]);
let (out, _, _) = run_fr(tmp.path(), &["check"]);
assert!(out.contains("Recovery log:"), "{out}");
let named = out
.lines()
.map(str::trim)
.find(|l| l.ends_with(".recovery.log") || l.ends_with("frame-recovery.log"))
.unwrap_or_else(|| panic!("check should name the log it counted:\n{out}"));
assert!(Path::new(named).is_absolute(), "{named}");
}
fn repo_with_worktree(root: &Path) -> bool {
if !git_ok(root, &["init", "-q"]) {
return false;
}
git_ok(root, &["config", "user.email", "test@example.com"]);
git_ok(root, &["config", "user.name", "Test"]);
create_test_project(root);
fs::write(root.join(".gitignore"), "frame/.*\n.xdg-config/\n").unwrap();
git_ok(root, &["add", "-A"]);
git_ok(root, &["commit", "-qm", "base"]);
git_ok(root, &["worktree", "add", "-q", "../wt"])
}
#[test]
fn a_linked_worktree_reads_the_entries_the_main_tree_wrote() {
let tmp = tempfile::TempDir::new().unwrap();
let main = tmp.path().join("main");
fs::create_dir_all(&main).unwrap();
if !repo_with_worktree(&main) {
return; }
let wt = tmp.path().join("wt");
run_fr_ok(&main, &["delete", "M-001", "--yes"]);
let listed = run_fr_ok(&wt, &["recovery"]);
assert!(
listed.contains("M-001"),
"an entry written next door must be visible here:\n{listed}"
);
assert!(
listed.contains("from 2 working trees") || listed.contains("Origin:"),
"and it must say where it came from:\n{listed}"
);
run_fr_ok(&wt, &["add", "main", "From the worktree"]);
run_fr_ok(&wt, &["delete", "M-002", "--yes"]);
let from_main = run_fr_ok(&main, &["recovery"]);
assert!(
from_main.contains("M-002"),
"and the main tree sees the worktree's entries:\n{from_main}"
);
}
#[test]
fn here_narrows_the_listing_to_this_working_tree() {
let tmp = tempfile::TempDir::new().unwrap();
let main = tmp.path().join("main");
fs::create_dir_all(&main).unwrap();
if !repo_with_worktree(&main) {
return;
}
let wt = tmp.path().join("wt");
run_fr_ok(&main, &["delete", "M-001", "--yes"]);
run_fr_ok(&wt, &["add", "main", "Worktree task"]);
run_fr_ok(&wt, &["delete", "M-002", "--yes"]);
let all = run_fr_ok(&wt, &["recovery"]);
assert!(
all.contains("task M-001 deleted") && all.contains("task M-002 deleted"),
"{all}"
);
let here = run_fr_ok(&wt, &["recovery", "--here"]);
assert!(
here.contains("task M-002 deleted"),
"its own entry stays:\n{here}"
);
assert!(
!here.contains("task M-001 deleted"),
"the other tree's entry is filtered out:\n{here}"
);
}
#[test]
fn the_log_survives_the_removal_of_the_worktree_that_wrote_it() {
let tmp = tempfile::TempDir::new().unwrap();
let main = tmp.path().join("main");
fs::create_dir_all(&main).unwrap();
if !repo_with_worktree(&main) {
return;
}
let wt = tmp.path().join("wt");
run_fr_ok(&wt, &["delete", "M-001", "--yes"]);
assert!(run_fr_ok(&wt, &["recovery"]).contains("M-001"));
assert!(
!wt.join("frame/.recovery.log").exists(),
"the log should not be living in the worktree at all"
);
assert!(
git_ok(&main, &["worktree", "remove", "--force", "../wt"]),
"git worktree remove should succeed"
);
assert!(!wt.exists(), "the worktree is gone");
let survived = run_fr_ok(&main, &["recovery"]);
assert!(
survived.contains("M-001"),
"the only copy of a deleted task must outlive the worktree that deleted it:\n{survived}"
);
}
#[test]
fn a_per_worktree_log_from_an_older_frame_is_absorbed() {
let tmp = tempfile::TempDir::new().unwrap();
let main = tmp.path().join("main");
fs::create_dir_all(&main).unwrap();
if !repo_with_worktree(&main) {
return;
}
write_numbered_log(&main, 3);
let legacy = main.join("frame/.recovery.log");
assert!(legacy.exists());
let before = run_fr_ok(&main, &["recovery"]);
assert!(before.contains("task M-2 deleted"), "{before}");
assert!(legacy.exists(), "a read must not move it");
run_fr_ok(&main, &["delete", "M-001", "--yes"]);
assert!(!legacy.exists(), "absorbed");
let after = run_fr_ok(&main, &["recovery"]);
for expected in ["task M-0 deleted", "task M-2 deleted", "M-001"] {
assert!(after.contains(expected), "{expected} missing:\n{after}");
}
run_fr_ok(&main, &["add", "main", "Another"]);
run_fr_ok(&main, &["delete", "M-003", "--yes"]);
let again = run_fr_ok(&main, &["recovery", "--limit", "50"]);
assert_eq!(
again.matches("task M-0 deleted").count(),
1,
"absorbed entries must not duplicate:\n{again}"
);
}
fn set_recovery_config(root: &Path, body: &str) {
let path = root.join("frame/project.toml");
let mut text = fs::read_to_string(&path).unwrap();
text.push_str(&format!("\n[recovery]\n{body}"));
fs::write(&path, text).unwrap();
}
#[test]
fn recovery_path_reports_the_default_location() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let out = run_fr_ok(tmp.path(), &["recovery", "path"]);
assert!(
out.trim().ends_with("frame/.recovery.log"),
"unexpected default: {out}"
);
assert!(
Path::new(out.trim()).is_absolute(),
"the path must be absolute — it is quoted to people standing elsewhere: {out}"
);
}
#[test]
fn a_configured_relative_path_moves_the_log_and_recovery_path_says_so() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
set_recovery_config(tmp.path(), "path = \"logs/frame.log\"\n");
let reported = run_fr_ok(tmp.path(), &["recovery", "path"]);
assert!(
reported.trim().ends_with("/logs/frame.log"),
"a relative path resolves against the project root: {reported}"
);
run_fr_ok(tmp.path(), &["delete", "M-001", "--yes"]);
assert!(tmp.path().join("logs/frame.log").exists());
assert!(!tmp.path().join("frame/.recovery.log").exists());
let listed = run_fr_ok(tmp.path(), &["recovery"]);
assert!(listed.contains("M-001"), "{listed}");
}
#[test]
fn the_environment_overrides_the_configured_path() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
set_recovery_config(tmp.path(), "path = \"logs/from-config.log\"\n");
let elsewhere = tmp.path().join("elsewhere/from-env.log");
let env = [(
"FRAME_RECOVERY_LOG",
elsewhere.to_str().expect("utf-8 temp path"),
)];
let (reported, _, ok) = run_fr_env(tmp.path(), &["recovery", "path"], &env);
assert!(ok);
assert_eq!(Path::new(reported.trim()), elsewhere);
let (_, _, ok) = run_fr_env(tmp.path(), &["delete", "M-001", "--yes"], &env);
assert!(ok);
assert!(elsewhere.exists(), "the entry went where the env said");
assert!(!tmp.path().join("logs/from-config.log").exists());
}
#[test]
fn a_configured_size_and_age_are_accepted_by_the_real_binary() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
set_recovery_config(tmp.path(), "max_size = \"64KB\"\nprune_age_days = 7\n");
run_fr_ok(tmp.path(), &["check"]);
run_fr_ok(tmp.path(), &["recovery"]);
}
#[test]
fn an_unparseable_size_is_reported_rather_than_ignored() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
set_recovery_config(tmp.path(), "max_size = \"5 megabytes\"\n");
let (_, stderr, ok) = run_fr(tmp.path(), &["check"]);
assert!(!ok, "a malformed size must not pass silently");
assert!(
stderr.contains("size") || stderr.contains("max_size"),
"the error should name the setting: {stderr}"
);
}
fn mark_conflicted(root: &Path) {
let path = root.join("frame/tracks/main.md");
let before = fs::read_to_string(&path).unwrap();
let after = before.replace(
"- [ ] `M-001` First task #core\n",
"- [ ] `M-001` First task #core\n - conflict: both-edited 2026-08-06T06:18:30Z\n",
);
assert_ne!(before, after, "fixture shape changed");
fs::write(&path, after).unwrap();
}
fn write_matching_conflict_entry(root: &Path) {
fs::write(
root.join("frame/.recovery.log"),
"\
<!-- frame recovery log — append-only error recovery data
Safe to delete if empty or stale. -->
---
## 2026-08-06T06:18:30Z — conflict: merge conflict on #M-001 in tracks/main.md — kept our version
Reason: both sides edited it differently; kept ours
```text
- [ ] `M-001` Their version of the first task
```
---
",
)
.unwrap();
}
#[test]
fn check_points_at_the_recovery_entry_when_it_is_actually_here() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
mark_conflicted(tmp.path());
write_matching_conflict_entry(tmp.path());
let (out, _, ok) = run_fr(tmp.path(), &["check"]);
assert!(!ok, "an unresolved conflict is still an error");
assert!(
out.contains("their version is in the recovery log (`fr recovery --for M-001`)"),
"the pointer should name the lookup that finds it:\n{out}"
);
}
#[test]
fn check_says_so_when_the_recovery_entry_is_not_here() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
mark_conflicted(tmp.path());
let (out, _, ok) = run_fr(tmp.path(), &["check"]);
assert!(!ok, "an unresolved conflict is still an error");
assert!(
out.contains("NOT in this working copy's recovery log"),
"a pointer frame cannot honour must not be printed as fact:\n{out}"
);
assert!(
out.contains("recover it from version control"),
"and the reader needs somewhere else to go:\n{out}"
);
assert!(
!out.contains("their version is in the recovery log"),
"the two messages must not both appear:\n{out}"
);
}
#[test]
fn check_stops_pointing_at_the_log_once_the_entry_is_pruned() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
mark_conflicted(tmp.path());
write_matching_conflict_entry(tmp.path());
let (before, _, _) = run_fr(tmp.path(), &["check"]);
assert!(
before.contains("their version is in the recovery log"),
"{before}"
);
run_fr_ok(tmp.path(), &["recovery", "prune", "--all"]);
let (after, _, _) = run_fr(tmp.path(), &["check"]);
assert!(
after.contains("NOT in this working copy's recovery log"),
"after a prune the entry is gone and the message must say so:\n{after}"
);
}
#[test]
fn check_json_reports_whether_the_conflict_evidence_is_here() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
mark_conflicted(tmp.path());
let (out, _, _) = run_fr(tmp.path(), &["check", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let conflict = parsed["errors"]
.as_array()
.expect("errors array")
.iter()
.find(|e| e["type"] == "unresolved_merge_conflict")
.expect("the conflict error");
assert_eq!(conflict["evidence"], serde_json::json!(false));
write_matching_conflict_entry(tmp.path());
let (out, _, _) = run_fr(tmp.path(), &["check", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let conflict = parsed["errors"]
.as_array()
.expect("errors array")
.iter()
.find(|e| e["type"] == "unresolved_merge_conflict")
.expect("the conflict error");
assert_eq!(conflict["evidence"], serde_json::json!(true));
}
#[test]
fn check_finds_the_evidence_by_task_id_when_the_timestamp_does_not_match() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
mark_conflicted(tmp.path());
fs::write(
tmp.path().join("frame/.recovery.log"),
"\
<!-- frame recovery log -->
---
## 2026-08-06T09:99:99Z — conflict: merge conflict on #M-001 in tracks/main.md — kept our version
Reason: both sides edited it differently; kept ours
---
"
.replace("09:99:99", "09:41:02"),
)
.unwrap();
let (out, _, _) = run_fr(tmp.path(), &["check"]);
assert!(
out.contains("their version is in the recovery log"),
"an entry naming the task counts even when the stamp moved:\n{out}"
);
}
#[test]
fn check_does_not_let_one_conflict_vouch_for_its_merge_siblings() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let path = tmp.path().join("frame/tracks/main.md");
let before = fs::read_to_string(&path).unwrap();
let after = before
.replace(
"- [ ] `M-001` First task #core\n",
"- [ ] `M-001` First task #core\n - conflict: both-edited 2026-08-06T06:18:30Z\n",
)
.replace(
"- [>] `M-002` Second task #core #cc\n",
"- [>] `M-002` Second task #core #cc\n - conflict: both-edited 2026-08-06T06:18:30Z\n",
);
assert_ne!(before, after, "fixture shape changed");
fs::write(&path, after).unwrap();
write_matching_conflict_entry(tmp.path());
let (out, _, _) = run_fr(tmp.path(), &["check"]);
let line = |id: &str| {
out.lines()
.find(|l| l.contains(&format!("{id} has an unresolved merge conflict")))
.unwrap_or_else(|| panic!("no conflict line for {id}:\n{out}"))
.to_string()
};
assert!(
line("M-001").contains("their version is in the recovery log"),
"the logged task still has its pointer:\n{}",
line("M-001")
);
assert!(
line("M-002").contains("NOT in this working copy's recovery log"),
"a shared stamp is not evidence for a task the log never names:\n{}",
line("M-002")
);
}
#[test]
fn recovery_says_how_many_entries_it_is_hiding() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_numbered_log(tmp.path(), 18);
let out = run_fr_ok(tmp.path(), &["recovery"]);
assert!(
out.contains("showing 10 of 18"),
"a truncated listing must say so:\n{out}"
);
assert!(
out.contains("--limit 18"),
"and must say how to see the rest:\n{out}"
);
}
#[test]
fn recovery_says_nothing_about_truncation_when_nothing_is_truncated() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_numbered_log(tmp.path(), 8);
let out = run_fr_ok(tmp.path(), &["recovery"]);
assert!(
!out.contains("showing"),
"a complete listing must not imply there is more:\n{out}"
);
}
#[test]
fn recovery_for_an_id_finds_the_entry_the_default_limit_hides() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_numbered_log(tmp.path(), 18);
let listed = run_fr_ok(tmp.path(), &["recovery"]);
assert!(!listed.contains("task M-0 deleted"), "{listed}");
let found = run_fr_ok(tmp.path(), &["recovery", "--for", "M-0"]);
assert!(
found.contains("task M-0 deleted"),
"--for must reach past the default limit:\n{found}"
);
assert!(
!found.contains("task M-10 deleted"),
"--for must not match a longer id that starts the same:\n{found}"
);
}
#[test]
fn recovery_for_a_missing_id_says_so_by_name() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_numbered_log(tmp.path(), 3);
let out = run_fr_ok(tmp.path(), &["recovery", "--for", "M-999"]);
assert!(
out.contains("No recovery log entries for M-999"),
"an empty result must name what was looked for:\n{out}"
);
}
#[test]
fn recovery_for_a_conflict_marker_timestamp_selects_that_entry() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_numbered_log(tmp.path(), 3);
let out = run_fr_ok(tmp.path(), &["recovery", "--for", "2026-02-10T01:00:00Z"]);
assert!(out.contains("task M-1 deleted"), "{out}");
assert!(!out.contains("task M-2 deleted"), "{out}");
}
#[test]
fn recovery_json_carries_the_entries_and_not_the_notice() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
write_numbered_log(tmp.path(), 18);
let out = run_fr_ok(tmp.path(), &["recovery", "--json"]);
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(
parsed.as_array().map(|a| a.len()),
Some(10),
"the array must hold exactly what the notice counts as shown"
);
assert!(
!out.contains("showing 10 of 18"),
"the notice is human-only; it must not corrupt the JSON payload:\n{out}"
);
}
#[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}");
}
fn create_subtree_dep_project(root: &Path) {
create_dep_project(root);
let frame_dir = root.join("frame");
fs::write(
frame_dir.join("tracks/alpha.md"),
"\
# Alpha
## Backlog
- [ ] `A-001` Same-track dependent
- added: 2025-05-01
- dep: A-005.1
- [ ] `A-005` Movable parent
- added: 2025-05-02
- [ ] `A-005.1` Movable child
- added: 2025-05-02
- [ ] `A-005.2` Movable sibling
- added: 2025-05-02
- dep: A-005.1
## Done
",
)
.unwrap();
fs::write(
frame_dir.join("tracks/beta.md"),
"\
# Beta
## Backlog
- [ ] `B-001` Other-track dependent
- added: 2025-05-01
- dep: A-005.1
## Done
",
)
.unwrap();
}
#[test]
fn test_mv_cross_track_rewrites_deps_on_moved_descendants() {
let tmp = tempfile::TempDir::new().unwrap();
create_subtree_dep_project(tmp.path());
let (before, _, ok) = run_fr(tmp.path(), &["check"]);
assert!(ok, "fixture starts clean: {before}");
assert!(!before.contains("dangling dep"), "fixture: {before}");
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.1"), "beta: {beta}");
let alpha = fs::read_to_string(tmp.path().join("frame/tracks/alpha.md")).unwrap();
assert!(alpha.contains("dep: C-001.1"), "alpha: {alpha}");
let gamma = fs::read_to_string(tmp.path().join("frame/tracks/gamma.md")).unwrap();
assert!(gamma.contains("`C-001.1`"), "gamma: {gamma}");
assert!(gamma.contains("dep: C-001.1"), "gamma: {gamma}");
assert!(
!alpha.contains("A-005") && !beta.contains("A-005") && !gamma.contains("A-005"),
"a retired id survived somewhere:\nalpha: {alpha}\nbeta: {beta}\ngamma: {gamma}"
);
let (after, _, ok) = run_fr(tmp.path(), &["check"]);
assert!(ok, "check should pass after the move: {after}");
assert!(!after.contains("dangling dep"), "after: {after}");
}
#[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_a_link_in_the_archive_header_does_not_hide_its_ids() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
assert!(
!tmp.path().join("frame/.ids.toml").exists(),
"fixture must have no frontier store, or the archive scan is not what is under test"
);
fs::create_dir_all(tmp.path().join("frame/archive")).unwrap();
fs::write(
tmp.path().join("frame/archive/main.md"),
"# Archive — main\n- [context](notes.md)\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",
"an archived number was reissued because a link bullet hid the archive"
);
let archive = fs::read_to_string(tmp.path().join("frame/archive/main.md")).unwrap();
assert!(archive.contains("- [context](notes.md)"), "{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
<!-- kept by hand -->
";
#[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}"
);
assert!(
after.contains("<!-- kept by hand -->"),
"content below the last task was dropped: {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_refuses_a_colliding_archived_prefix_rename() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
fs::create_dir_all(tmp.path().join("frame/archive")).unwrap();
let archive = "# Archive — main\n\n- [x] `OLD-001` archived under a dead prefix\n - resolved: 2025-12-01\n";
fs::write(tmp.path().join("frame/archive/main.md"), archive).unwrap();
let out = run_fr_ok(tmp.path(), &["check", "--fix", "--yes"]);
assert!(
out.contains("skipped") && out.contains("already exists"),
"the repair should refuse and say why: {out}"
);
assert_eq!(
fs::read_to_string(tmp.path().join("frame/archive/main.md")).unwrap(),
archive,
"a refused repair must not half-rename the file"
);
assert!(
run_fr_ok(tmp.path(), &["check"]).contains("still use the prefix OLD-"),
"and the warning stays"
);
}
#[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_git_setup_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 untouched = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(
!untouched.contains("frame/"),
"--fix must leave git readiness to `fr git setup`: {untouched}"
);
run_fr_ok(tmp.path(), &["git", "setup"]);
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_actor_merge_keeps_every_section_of_an_archived_track() {
let tmp = tempfile::TempDir::new().unwrap();
run_fr_ok(tmp.path(), &["init", "--name", "p", "--track", "a", "A"]);
run_fr_ok(tmp.path(), &["track", "new", "side", "Side"]);
run_fr_ok(tmp.path(), &["actor", "set", "x"]);
let open = run_fr_ok(tmp.path(), &["add", "side", "still open"])
.trim()
.to_string();
let done = run_fr_ok(tmp.path(), &["add", "side", "already finished"])
.trim()
.to_string();
run_fr_ok(tmp.path(), &["state", &done, "done"]);
run_fr_ok(tmp.path(), &["track", "archive", "side"]);
let archived = tmp.path().join("frame/archive/_tracks/side.md");
let before = fs::read_to_string(&archived).unwrap();
assert!(
before.contains("## Done") && before.contains("already finished"),
"fixture should have a populated Done section below the Backlog: {before}"
);
run_fr_ok(tmp.path(), &["actor", "set", "y"]);
run_fr_ok(tmp.path(), &["actor", "merge", "x", "--into", "y"]);
let after = fs::read_to_string(&archived).unwrap();
assert!(
after.contains("## Done") && after.contains("already finished"),
"the Done section and its tasks must survive a merge: {after}"
);
assert!(
after.contains("## Parked"),
"so must the empty section between them: {after}"
);
assert!(
!after.contains(&open) && !after.contains(&done),
"every id should have left the merged-away namespace: {after}"
);
}
#[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(), &["git", "setup"]);
let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(
gitignore.contains("frame/.*"),
"`fr git setup` 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}"
);
}
fn merge_repo(root: &Path) -> bool {
if !git_ok(root, &["init", "-q"]) {
return false;
}
git_ok(root, &["config", "user.email", "test@example.com"]);
git_ok(root, &["config", "user.name", "Test"]);
create_test_project(root);
let driver = format!(
"{} merge --base %O --ours %A --theirs %B --path %P",
fr_bin().display()
);
git_ok(root, &["config", "merge.frame.driver", &driver]);
git_ok(root, &["config", "merge.frame.recursive", "binary"]);
fs::write(
root.join(".gitattributes"),
"frame/tracks/*.md merge=frame\nframe/inbox.md merge=frame\n",
)
.unwrap();
fs::write(root.join(".gitignore"), "frame/.*\n.xdg-config/\n").unwrap();
if !(git_ok(root, &["add", "-A"]) && git_ok(root, &["commit", "-qm", "base"])) {
return false;
}
git_must(root, &["branch", "-M", "main"]);
true
}
fn git_ok(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 git_must(dir: &Path, args: &[&str]) {
assert!(git_ok(dir, args), "git {args:?} failed");
}
fn git_out(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("git runs");
String::from_utf8_lossy(&out.stdout).to_string()
}
#[test]
fn test_merge_driver_handles_a_relocation_and_an_append() {
let tmp = tempfile::TempDir::new().unwrap();
if !merge_repo(tmp.path()) {
return; }
let root = tmp.path();
git_must(root, &["checkout", "-q", "-b", "theirs"]);
run_fr_ok(root, &["add", "main", "Third thing"]);
git_must(root, &["add", "-A"]);
git_must(root, &["commit", "-qm", "append"]);
git_must(root, &["checkout", "-q", "main"]);
run_fr_ok(root, &["state", "M-001", "done"]);
git_must(root, &["add", "-A"]);
git_must(root, &["commit", "-qm", "done"]);
let merged = Command::new("git")
.current_dir(root)
.args(["merge", "theirs"])
.output()
.expect("git merge runs");
assert!(
merged.status.success(),
"the driver should merge this cleanly:\n{}\n{}",
String::from_utf8_lossy(&merged.stdout),
String::from_utf8_lossy(&merged.stderr)
);
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
assert!(!track.contains("<<<<<<<"), "no markers: {track}");
assert_eq!(
track.matches("`M-001`").count(),
1,
"the relocated task must not be duplicated: {track}"
);
let (_, done) = track.split_once("## Done").unwrap();
assert!(
done.contains("[x] `M-001`"),
"M-001 belongs in Done, done: {track}"
);
assert!(track.contains("Third thing"), "theirs was dropped: {track}");
for header in ["## Backlog", "## Parked", "## Done"] {
assert!(track.contains(header), "{header} was lost: {track}");
}
let checked = run_fr_ok(root, &["check"]);
assert!(
checked.contains("valid"),
"the merged project should be valid: {checked}"
);
}
#[test]
fn test_merge_driver_conflict_leaves_a_valid_file_and_a_marker() {
let tmp = tempfile::TempDir::new().unwrap();
if !merge_repo(tmp.path()) {
return; }
let root = tmp.path();
git_must(root, &["checkout", "-q", "-b", "theirs"]);
run_fr_ok(root, &["note", "M-001", "note from them"]);
git_must(root, &["add", "-A"]);
git_must(root, &["commit", "-qm", "their note"]);
git_must(root, &["checkout", "-q", "main"]);
run_fr_ok(root, &["note", "M-001", "note from us"]);
git_must(root, &["add", "-A"]);
git_must(root, &["commit", "-qm", "our note"]);
let merged = Command::new("git")
.current_dir(root)
.args(["merge", "theirs"])
.output()
.expect("git merge runs");
assert!(
!merged.status.success(),
"an undecidable merge must stop the operation"
);
let unmerged = git_out(root, &["ls-files", "-u", "frame/tracks/main.md"]);
assert!(
!unmerged.trim().is_empty(),
"the path should be staged as conflicted"
);
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
assert!(
!track.contains("<<<<<<<") && !track.contains(">>>>>>>"),
"conflict markers would make the file unreadable to every frame tool: {track}"
);
assert!(track.contains("note from us"), "ours is kept: {track}");
assert!(
track.contains("- conflict: both-edited"),
"the file has to record that a decision is outstanding: {track}"
);
let recovery = run_fr_ok(root, &["recovery"]);
assert!(
recovery.contains("note from them"),
"their version should be in the recovery log: {recovery}"
);
let (checked, _, ok) = run_fr(root, &["check"]);
assert!(
checked.contains("unresolved merge conflict"),
"check should report it: {checked}"
);
assert!(
!checked.contains("project is valid"),
"an unresolved conflict is not a valid project: {checked}"
);
assert!(
!ok,
"and the status should say so, so a hook or CI step can key off it"
);
run_fr_ok(root, &["merge", "--resolve", "M-001"]);
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
assert!(
!track.contains("conflict:"),
"marker should be gone: {track}"
);
assert!(track.contains("note from us"), "content untouched: {track}");
let checked = run_fr_ok(root, &["check"]);
assert!(checked.contains("valid"), "and check is happy: {checked}");
}
#[test]
fn test_merge_declines_a_file_it_does_not_understand() {
let tmp = tempfile::TempDir::new().unwrap();
create_test_project(tmp.path());
let dir = tmp.path();
fs::write(dir.join("base.toml"), "a = 1\n").unwrap();
fs::write(dir.join("ours.toml"), "a = 2\n").unwrap();
fs::write(dir.join("theirs.toml"), "a = 3\n").unwrap();
let (_, stderr, ok) = run_fr(
dir,
&[
"merge",
"--base",
"base.toml",
"--ours",
"ours.toml",
"--theirs",
"theirs.toml",
"--path",
"frame/project.toml",
],
);
assert!(!ok, "declining is a non-zero status");
assert!(
stderr.contains("declining"),
"it should say so plainly: {stderr}"
);
assert_eq!(
fs::read_to_string(dir.join("ours.toml")).unwrap(),
"a = 2\n"
);
}
fn write_while_the_lock_is_held(root: &Path, args: &[&str], concurrent: impl FnOnce()) {
let lock = frame::io::lock::FileLock::acquire_default(&root.join("frame"))
.expect("test could not take the project lock");
let child = Command::new(fr_bin())
.args(args)
.current_dir(root)
.env("XDG_CONFIG_HOME", root.join(".xdg-config"))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn fr");
std::thread::sleep(std::time::Duration::from_millis(500));
concurrent();
drop(lock);
let out = child.wait_with_output().expect("failed to wait for fr");
assert!(
out.status.success(),
"fr {:?} failed:\nstdout: {}\nstderr: {}",
args,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
#[test]
fn a_concurrent_track_write_survives_a_command_that_waited_for_the_lock() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let track = root.join("frame/tracks/main.md");
write_while_the_lock_is_held(root, &["add", "main", "Added while blocked"], || {
let before = fs::read_to_string(&track).unwrap();
let after = before.replace(
"- [ ] `M-001` First task #core\n",
"- [ ] `M-001` First task #core\n\
- [ ] `M-900` Landed while the lock was held\n - added: 2025-05-04\n",
);
assert_ne!(before, after, "fixture shape changed");
fs::write(&track, after).unwrap();
});
let body = fs::read_to_string(&track).unwrap();
assert!(
body.contains("Added while blocked"),
"the command's own write is missing:\n{body}"
);
assert!(
body.contains("M-900"),
"the concurrent write was erased:\n{body}"
);
}
#[test]
fn a_concurrent_inbox_capture_survives_a_command_that_waited_for_the_lock() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let inbox = root.join("frame/inbox.md");
write_while_the_lock_is_held(root, &["inbox", "Captured while blocked"], || {
let before = fs::read_to_string(&inbox).unwrap();
fs::write(&inbox, format!("{before}\n- Captured elsewhere first\n")).unwrap();
});
let body = fs::read_to_string(&inbox).unwrap();
assert!(
body.contains("Captured while blocked"),
"the command's own capture is missing:\n{body}"
);
assert!(
body.contains("Captured elsewhere first"),
"the concurrent capture was erased:\n{body}"
);
}
fn seed_recovery_log(root: &Path, count: usize, body: &str) {
const TS: &str = "2020-01-01T00:00:00Z";
let mut content = String::from(
"<!-- 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",
);
for i in 0..count {
content.push_str(&format!(
"## {TS} — write: seeded {i}\n\nSource: tracks/main.md\n\n```text\n{body}\n```\n\n---\n"
));
}
fs::write(root.join("frame/.recovery.log"), content).unwrap();
}
#[test]
fn a_failed_prune_leaves_the_recovery_log_whole() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
seed_recovery_log(root, 3, "content that exists nowhere else");
let log = root.join("frame/.recovery.log");
let before = fs::read_to_string(&log).unwrap();
let (_, stderr, ok) = run_fr_env(
root,
&["recovery", "prune", "--all"],
&[("FRAME_FAIL_WRITE", ".recovery.log")],
);
assert!(!ok, "the injected failure must surface: {stderr}");
assert_eq!(
fs::read_to_string(&log).unwrap(),
before,
"a prune that could not write must not have removed anything"
);
}
#[test]
fn a_failed_dated_prune_leaves_the_recovery_log_whole() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
seed_recovery_log(root, 2, "content that exists nowhere else");
let log = root.join("frame/.recovery.log");
let before = fs::read_to_string(&log).unwrap();
let (_, stderr, ok) = run_fr_env(
root,
&["recovery", "prune"],
&[("FRAME_FAIL_WRITE", ".recovery.log")],
);
assert!(!ok, "the injected failure must surface: {stderr}");
assert_eq!(fs::read_to_string(&log).unwrap(), before);
}
#[test]
fn a_failed_inline_trim_keeps_the_log_and_still_appends() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let filler = "x".repeat(2048);
seed_recovery_log(root, 600, &filler);
let log = root.join("frame/.recovery.log");
assert!(fs::metadata(&log).unwrap().len() > 1_048_576);
let before = fs::read_to_string(&log).unwrap();
let (_, stderr, ok) = run_fr_env(
root,
&["delete", "M-001", "--yes"],
&[("FRAME_FAIL_WRITE", ".recovery.log")],
);
assert!(ok, "the delete itself must still succeed: {stderr}");
let after = fs::read_to_string(&log).unwrap();
assert!(
after.starts_with(&before),
"the trim failed, so nothing should have been removed"
);
assert!(
after.len() > before.len(),
"and the entry that mattered still had to land"
);
assert!(after.contains("M-001"), "with the deleted task in it");
}
#[test]
fn test_track_rename_recovers_from_an_interrupted_file_move() {
let tmp = tempfile::TempDir::new().unwrap();
two_track_project(tmp.path());
let root = tmp.path();
let (_, _, ok) = run_fr_env(
root,
&["track", "rename", "a", "--new-id", "alpha"],
&[("FRAME_FAIL_WRITE", "tracks/a.md")],
);
assert!(!ok, "the injected failure should fail the command");
assert!(root.join("frame/tracks/a.md").exists());
assert!(
root.join("frame/.inflight").exists(),
"the intent is recorded"
);
run_fr_ok(root, &["add", "b", "unrelated"]);
assert!(
root.join("frame/tracks/alpha.md").exists(),
"recovery should finish the file move"
);
assert!(!root.join("frame/tracks/a.md").exists());
let config = fs::read_to_string(root.join("frame/project.toml")).unwrap();
assert!(
config.contains("id = \"alpha\""),
"and the config entry with it: {config}"
);
assert!(
!root.join("frame/.inflight").exists(),
"the marker is cleared once the operation is complete"
);
let out = run_fr_ok(root, &["list"]);
assert!(
out.contains("the task to move"),
"tasks are back in view: {out}"
);
let check = run_fr_ok(root, &["check"]);
assert!(check.contains("valid"), "and the project is clean: {check}");
}
#[test]
fn a_track_file_renamed_out_from_under_config_is_reported() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
fs::rename(
root.join("frame/tracks/main.md"),
root.join("frame/tracks/renamed.md"),
)
.unwrap();
let (stdout, _, ok) = run_fr(root, &["check"]);
assert!(
!ok && stdout.contains("project has errors"),
"a track nobody can see is not a clean bill: {stdout}"
);
assert!(
stdout.contains("track file is missing") && stdout.contains("tracks/main.md"),
"the dangling config entry: {stdout}"
);
assert!(
stdout.contains("not listed in project.toml") && stdout.contains("tracks/renamed.md"),
"and the file nothing points at: {stdout}"
);
}
#[test]
fn an_archived_track_is_not_reported_as_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
two_track_project(root);
run_fr_ok(root, &["track", "archive", "a"]);
let (stdout, _, _) = run_fr(root, &["check"]);
assert!(
stdout.contains("valid"),
"archiving is not damage: {stdout}"
);
assert!(!stdout.contains("track file is missing"), "{stdout}");
}
fn clean_ready_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"),
"[project]\nname = \"clean-test\"\n\n\
[clean]\ndone_threshold = 1\ndone_retain = 0\n\n\
[[tracks]]\nid = \"main\"\nname = \"Main\"\nstate = \"active\"\n\
file = \"tracks/main.md\"\n\n[ids.prefixes]\nmain = \"M\"\n",
)
.unwrap();
fs::write(
frame_dir.join("tracks/main.md"),
"# Main\n\n## Backlog\n\n\
- [ ] `M-005` Still open\n - added: 2026-01-01\n\n\
## Done\n\n\
- [x] `M-001` Archive me\n - added: 2026-01-01\n - resolved: 2026-01-02\n\
- [x] `M-002` Archive me too\n - added: 2026-01-01\n - resolved: 2026-01-02\n",
)
.unwrap();
fs::write(frame_dir.join("inbox.md"), "# Inbox\n").unwrap();
}
#[test]
fn test_clean_keeps_the_task_when_the_archive_write_is_cut() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
clean_ready_project(root);
let (_, stderr, ok) = run_fr_env(root, &["clean"], &[("FRAME_FAIL_WRITE", "archive/main.md")]);
assert!(
ok,
"clean should skip the track, not fail outright: {stderr}"
);
assert!(
stderr.contains("could not write archive"),
"and it should say which track it skipped: {stderr}"
);
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
assert!(
track.contains("M-001") && track.contains("M-002"),
"no task may leave the track before its archive copy lands: {track}"
);
run_fr_ok(root, &["clean"]);
let track = fs::read_to_string(root.join("frame/tracks/main.md")).unwrap();
let archive = fs::read_to_string(root.join("frame/archive/main.md")).unwrap();
assert!(!track.contains("M-001"), "now removed from the track");
assert_eq!(
archive.matches("`M-001`").count(),
1,
"and archived exactly once: {archive}"
);
assert_eq!(archive.matches("`M-002`").count(), 1, "{archive}");
}
#[test]
fn check_exits_zero_on_a_clean_project() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let (stdout, _, ok) = run_fr(root, &["check"]);
assert!(ok, "a clean project must not report failure: {stdout}");
}
#[test]
fn check_exits_non_zero_on_errors() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let track = root.join("frame/tracks/main.md");
let body = fs::read_to_string(&track)
.unwrap()
.replace("- dep: M-001", "- dep: M-999");
fs::write(&track, body).unwrap();
let (stdout, _, ok) = run_fr(root, &["check"]);
assert!(!ok, "errors must set the status: {stdout}");
assert!(stdout.contains("project has errors"), "{stdout}");
}
#[test]
fn check_exits_zero_when_there_are_only_warnings() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let track = root.join("frame/tracks/main.md");
let body = fs::read_to_string(&track)
.unwrap()
.replace("## Parked", "- [ ] No ID at all\n\n## Parked");
fs::write(&track, body).unwrap();
let (stdout, _, ok) = run_fr(root, &["check"]);
assert!(
stdout.contains("Warnings:"),
"the fixture should warn: {stdout}"
);
assert!(!stdout.contains("Errors:"), "and only warn: {stdout}");
assert!(ok, "a warning is not a failure: {stdout}");
}
#[test]
fn check_json_exits_non_zero_on_errors_too() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let track = root.join("frame/tracks/main.md");
let body = fs::read_to_string(&track)
.unwrap()
.replace("- dep: M-001", "- dep: M-999");
fs::write(&track, body).unwrap();
let (stdout, _, ok) = run_fr(root, &["check", "--json"]);
assert!(!ok, "{stdout}");
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
assert_eq!(v["valid"], serde_json::Value::Bool(false));
}
#[test]
fn check_fix_exits_non_zero_when_errors_remain() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let track = root.join("frame/tracks/main.md");
let body = fs::read_to_string(&track)
.unwrap()
.replace("- dep: M-001", "- dep: M-999");
fs::write(&track, body).unwrap();
let (stdout, _, ok) = run_fr(root, &["check", "--fix", "--yes"]);
assert!(
!ok,
"a dangling dep has no repair, so the project is still unsound: {stdout}"
);
}
#[test]
fn activating_an_archived_track_brings_its_file_back() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
two_track_project(root);
run_fr_ok(root, &["track", "archive", "a"]);
assert!(
root.join("frame/archive/_tracks/a.md").exists(),
"archive moved the file"
);
assert!(!root.join("frame/tracks/a.md").exists());
run_fr_ok(root, &["track", "activate", "a"]);
assert!(
root.join("frame/tracks/a.md").exists(),
"activate must bring it back"
);
assert!(
!root.join("frame/archive/_tracks/a.md").exists(),
"and not leave a second copy behind"
);
let out = run_fr_ok(root, &["list"]);
assert!(
out.contains("the task to move"),
"the tasks are visible again: {out}"
);
let (check, _, ok) = run_fr(root, &["check"]);
assert!(ok, "and the project is sound: {check}");
}
#[test]
fn activating_a_shelved_track_touches_no_files() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
two_track_project(root);
run_fr_ok(root, &["track", "shelve", "a"]);
let before = fs::read_to_string(root.join("frame/tracks/a.md")).unwrap();
run_fr_ok(root, &["track", "activate", "a"]);
assert_eq!(
fs::read_to_string(root.join("frame/tracks/a.md")).unwrap(),
before,
"a shelved track's file stays exactly where it was"
);
let (check, _, ok) = run_fr(root, &["check"]);
assert!(ok, "{check}");
}
#[test]
fn activating_an_active_track_is_harmless() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
two_track_project(root);
run_fr_ok(root, &["track", "activate", "a"]);
assert!(root.join("frame/tracks/a.md").exists());
let (check, _, ok) = run_fr(root, &["check"]);
assert!(ok, "{check}");
}
#[test]
fn a_cut_unarchive_is_completed_by_the_next_write_command() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
two_track_project(root);
run_fr_ok(root, &["track", "archive", "a"]);
let (_, stderr, ok) = run_fr_env(
root,
&["track", "activate", "a"],
&[("FRAME_FAIL_WRITE", "_tracks/a.md")],
);
assert!(
!ok,
"the injected failure should fail the command: {stderr}"
);
assert!(
root.join("frame/archive/_tracks/a.md").exists(),
"the file must still be somewhere — the move is what was cut"
);
let archived = fs::read_to_string(root.join("frame/archive/_tracks/a.md")).unwrap();
assert!(archived.contains("the task to move"), "intact: {archived}");
assert!(
root.join("frame/.inflight").exists(),
"the intent is recorded"
);
let (check, _, _) = run_fr(root, &["check"]);
assert!(
check.contains("track file is missing"),
"and check says so meanwhile: {check}"
);
run_fr_ok(root, &["add", "b", "unrelated"]);
assert!(
root.join("frame/tracks/a.md").exists(),
"recovery should finish the move back"
);
assert!(!root.join("frame/archive/_tracks/a.md").exists());
assert!(
!root.join("frame/.inflight").exists(),
"and clear the marker"
);
let out = run_fr_ok(root, &["list"]);
assert!(out.contains("the task to move"), "tasks are back: {out}");
let (check, _, ok) = run_fr(root, &["check"]);
assert!(ok, "and the project is sound again: {check}");
}
fn tui_session(root: &Path) -> frame::tui::app::App {
let project = frame::io::project_io::load_project(root).expect("project loads");
frame::tui::app::App::new(project)
}
#[test]
fn a_tui_save_does_not_erase_a_task_a_real_fr_just_added() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let track = root.join("frame/tracks/main.md");
let mut app = tui_session(root);
let out = Command::new(fr_bin())
.args(["add", "main", "Added by a real fr"])
.current_dir(root)
.env("XDG_CONFIG_HOME", root.join(".xdg-config"))
.output()
.expect("failed to run fr");
assert!(out.status.success(), "fr add failed: {out:?}");
let tasks = app
.find_track_mut("main")
.unwrap()
.section_tasks_mut(frame::model::SectionKind::Backlog)
.unwrap();
tasks[0].title = "Edited in the TUI".into();
tasks[0].dirty = true;
app.save_track_logged("main");
let body = fs::read_to_string(&track).unwrap();
assert!(
body.contains("Added by a real fr"),
"the other process's task was erased by the TUI's save:\n{body}"
);
assert!(
body.contains("Edited in the TUI"),
"the TUI's own edit did not land:\n{body}"
);
}
#[test]
fn a_tui_save_does_not_erase_a_capture_a_real_fr_just_made() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
create_test_project(root);
let inbox = root.join("frame/inbox.md");
let mut app = tui_session(root);
let out = Command::new(fr_bin())
.args(["inbox", "Captured by a real fr"])
.current_dir(root)
.env("XDG_CONFIG_HOME", root.join(".xdg-config"))
.output()
.expect("failed to run fr");
assert!(out.status.success(), "fr inbox failed: {out:?}");
frame::ops::inbox_ops::add_inbox_item(
app.project.inbox.as_mut().unwrap(),
"Captured in the TUI".into(),
Vec::new(),
None,
);
app.save_inbox_logged();
let body = fs::read_to_string(&inbox).unwrap();
assert!(
body.contains("Captured by a real fr"),
"the other process's capture was erased by the TUI's save:\n{body}"
);
assert!(
body.contains("Captured in the TUI"),
"the TUI's own capture did not land:\n{body}"
);
}
fn archived_track_project(root: &Path) {
two_track_project(root);
run_fr_ok(root, &["add", "a", "a task that finished"]);
run_fr_ok(root, &["state", "A-002", "done"]);
let config = root.join("frame/project.toml");
let text = fs::read_to_string(&config).unwrap();
fs::write(
&config,
text.replace("done_threshold = 100", "done_threshold = 0")
.replace("done_retain = 10", "done_retain = 0"),
)
.unwrap();
run_fr_ok(root, &["clean"]);
assert!(
root.join("frame/archive/a.md").exists(),
"the done-task archive is part of the fixture"
);
run_fr_ok(root, &["track", "archive", "a"]);
}
#[test]
fn renaming_an_archived_track_refuses_on_every_flag() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
archived_track_project(root);
let before = fs::read_to_string(root.join("frame/project.toml")).unwrap();
let file_before = fs::read_to_string(root.join("frame/archive/_tracks/a.md")).unwrap();
let archive_before = fs::read_to_string(root.join("frame/archive/a.md")).unwrap();
for flags in [
vec!["--name", "Renamed"],
vec!["--new-id", "b2"],
vec!["--prefix", "QQ", "--yes"],
] {
let mut args = vec!["track", "rename", "a"];
args.extend(flags.iter().copied());
let (_, stderr, ok) = run_fr(root, &args);
assert!(!ok, "{flags:?} must refuse an archived track");
assert!(
stderr.contains("archived") && stderr.contains("fr track activate a"),
"the message must say why and how to proceed, not `track not found`: {stderr}"
);
}
assert_eq!(
fs::read_to_string(root.join("frame/project.toml")).unwrap(),
before,
"a refused rename writes no config"
);
assert_eq!(
fs::read_to_string(root.join("frame/archive/_tracks/a.md")).unwrap(),
file_before,
"nor the archived track file"
);
assert_eq!(
fs::read_to_string(root.join("frame/archive/a.md")).unwrap(),
archive_before,
"nor the done-task archive — which `--prefix` used to rewrite before failing"
);
let (check, _, ok) = run_fr(root, &["check"]);
assert!(ok, "and the project stays sound: {check}");
}
#[test]
fn deleting_an_archived_track_says_it_is_archived() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
archived_track_project(root);
let (_, stderr, ok) = run_fr(root, &["track", "delete", "a"]);
assert!(!ok);
assert!(
stderr.contains("archived") && stderr.contains("fr track activate a"),
"an archived track exists; deleting it is what is not on offer: {stderr}"
);
}
#[test]
fn renaming_a_missing_track_still_says_not_found() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
two_track_project(root);
for flags in [
vec!["--name", "X"],
vec!["--new-id", "y"],
vec!["--prefix", "QQ", "--yes"],
] {
let mut args = vec!["track", "rename", "ghost"];
args.extend(flags.iter().copied());
let (_, stderr, ok) = run_fr(root, &args);
assert!(!ok, "{flags:?}");
assert!(
stderr.contains("track not found: ghost"),
"{flags:?} should say the track is missing: {stderr}"
);
}
}
#[test]
fn unarchiving_to_rename_and_re_archiving_lands_sound() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
archived_track_project(root);
run_fr_ok(root, &["track", "activate", "a"]);
run_fr_ok(root, &["track", "rename", "a", "--new-id", "a2"]);
run_fr_ok(root, &["track", "rename", "a2", "--prefix", "AA", "--yes"]);
run_fr_ok(root, &["track", "archive", "a2"]);
assert!(
root.join("frame/archive/_tracks/a2.md").exists(),
"the track file followed the id"
);
assert!(!root.join("frame/archive/_tracks/a.md").exists());
let archive = fs::read_to_string(root.join("frame/archive/a2.md")).unwrap();
assert!(
archive.contains("AA-002"),
"and so did the archived task ids: {archive}"
);
let (check, _, ok) = run_fr(root, &["check"]);
assert!(ok, "{check}");
}
#[test]
fn an_unclaimed_archived_track_file_is_reported_as_a_warning() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
archived_track_project(root);
let config = root.join("frame/project.toml");
let text = fs::read_to_string(&config).unwrap();
fs::write(&config, text.replace("id = \"a\"", "id = \"a9\"")).unwrap();
let (out, _, ok) = run_fr(root, &["check"]);
assert!(
!ok,
"the missing file the row now names is still an error: {out}"
);
assert!(
out.contains("archive/_tracks/a9.md"),
"the row points nowhere: {out}"
);
assert!(
out.contains("archive/_tracks/a.md"),
"and the file it left behind is named too, which is the pair that says \
what to move: {out}"
);
}