use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
const TASK_IDS: &[&str] = &[
"M-000", "M-001", "M-002", "M-003", "M-003.1", "M-003.2", "M-004", "M-005", "M-010", "S-000",
"S-001", "S-002", "S-010", "H-001", "H-002", "H-003", "H-004", "H-005", "H-006", "H-007",
"M-900",
"H-999",
];
const TRACK_IDS: &[&str] = &["main", "side", "shelf"];
const TRACK_NAMES: &[&str] = &["Main Track", "Side Track", "Shelf Track"];
const INBOX_TITLES: &[&str] = &["Bug in parser", "Think about design", "Quick note"];
fn create_fixture(root: &Path) {
let frame = root.join("frame");
fs::create_dir_all(frame.join("tracks")).unwrap();
fs::write(frame.join(".actor"), "null\n").unwrap();
fs::write(
frame.join("project.toml"),
r#"[project]
name = "parity-fixture"
[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"
[[tracks]]
id = "shelf"
name = "Shelf Track"
state = "shelved"
file = "tracks/shelf.md"
[ids.prefixes]
main = "M"
side = "S"
shelf = "H"
"#,
)
.unwrap();
fs::write(
frame.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-004` Blocked task #cc
- added: 2025-05-04
- dep: M-001
- [ ] `M-003` Third task with subtasks #core
- added: 2025-05-03
- [ ] `M-003.1` Sub one #cc
- added: 2025-05-03
- [>] `M-003.2` Sub two
- added: 2025-05-03
## Parked
- [~] `M-010` Parked idea #core
- added: 2025-04-15
## Done
- [x] `M-000` Setup project #core
- added: 2025-04-20
- resolved: 2025-04-25
- [x] `M-005` Second done thing
- added: 2025-04-21
- resolved: 2025-04-26
",
)
.unwrap();
fs::write(
frame.join("tracks/side.md"),
"\
# Side Track
## Backlog
- [ ] `S-001` Side task one #cc
- added: 2025-05-01
- [~] `S-002` Side task two
- added: 2025-05-02
## Parked
- [~] `S-010` Side parked
- added: 2025-04-11
## Done
- [x] `S-000` Side done
- added: 2025-04-01
- resolved: 2025-04-02
",
)
.unwrap();
fs::write(
frame.join("tracks/shelf.md"),
"\
# Shelf Track
## Backlog
- [ ] `H-001` Shelved task #core
- added: 2025-03-01
- dep: H-002, H-003
- [ ] `H-002` Diamond left
- added: 2025-03-02
- dep: H-004
- [ ] `H-003` Diamond right
- added: 2025-03-03
- dep: H-004
- [ ] `H-004` Shared leaf
- added: 2025-03-04
- [ ] `H-005` Cycle a
- added: 2025-03-05
- dep: H-006
- [ ] `H-006` Cycle b
- added: 2025-03-06
- dep: H-005
- [ ] `H-007` Dangling
- added: 2025-03-07
- dep: H-999
## Done
",
)
.unwrap();
fs::create_dir_all(frame.join("archive")).unwrap();
fs::write(
frame.join("archive/main.md"),
"\
# Main Track — Archive
## Done
- [x] `M-900` Archived First task
- added: 2024-01-01
- resolved: 2024-01-02
",
)
.unwrap();
fs::write(
frame.join("inbox.md"),
"\
# Inbox
- Bug in parser #bug
Stack trace points to line 142.
- Think about design #design
- Quick note
",
)
.unwrap();
}
fn fr_bin() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("fr");
path
}
fn run_fr(dir: &Path, args: &[&str]) -> String {
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");
assert!(
output.status.success(),
"fr {:?} failed:\n{}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).to_string()
}
fn is_boundary(c: u8) -> bool {
!(c.is_ascii_alphanumeric() || c == b'-' || c == b'.' || c == b'_')
}
fn first_identifier(line: &str, universe: &[&str]) -> Option<String> {
let bytes = line.as_bytes();
let mut best: Option<(usize, &str)> = None;
for cand in universe {
let mut from = 0;
while let Some(rel) = line[from..].find(cand) {
let at = from + rel;
let end = at + cand.len();
let before_ok = at == 0 || is_boundary(bytes[at - 1]);
let after_ok = end >= bytes.len() || is_boundary(bytes[end]);
if before_ok && after_ok {
let better = match best {
None => true,
Some((p, b)) => at < p || (at == p && cand.len() > b.len()),
};
if better {
best = Some((at, cand));
}
break;
}
from = at + 1;
}
}
best.map(|(_, c)| c.to_string())
}
fn human_sequence(stdout: &str, universe: &[&str]) -> Vec<String> {
stdout
.lines()
.filter(|line| {
!line
.split_whitespace()
.next()
.is_some_and(|tok| tok.ends_with(':'))
})
.filter_map(|line| first_identifier(line, universe))
.collect()
}
#[derive(Clone, Copy, Debug)]
enum Projection {
TaskTree,
ListEntries,
Field(&'static str),
DepTree,
SearchIds,
SearchTitles,
ShowWithContext,
ShowTaskOnly,
}
fn collect_field(v: &Value, field: &str, out: &mut Vec<String>) {
match v {
Value::Array(items) => {
for item in items {
collect_field(item, field, out);
}
}
Value::Object(obj) => {
if let Some(Value::String(s)) = obj.get(field) {
out.push(s.clone());
}
for key in ["tasks", "subtasks", "tracks"] {
if let Some(nested) = obj.get(key) {
collect_field(nested, field, out);
}
}
}
_ => {}
}
}
fn collect_subtree(v: &Value, out: &mut Vec<String>) {
if let Some(id) = v.get("id").and_then(Value::as_str) {
out.push(id.to_string());
}
if let Some(Value::Array(subs)) = v.get("subtasks") {
for sub in subs {
collect_subtree(sub, out);
}
}
}
fn collect_dep_tree(v: &Value, out: &mut Vec<String>) {
if let Some(id) = v.get("id").and_then(Value::as_str) {
out.push(id.to_string());
}
if let Some(Value::Array(deps)) = v.get("deps") {
for dep in deps {
collect_dep_tree(dep, out);
}
}
}
fn json_sequence(v: &Value, projection: Projection) -> Vec<String> {
let mut out = Vec::new();
match projection {
Projection::TaskTree => collect_field(v, "id", &mut out),
Projection::Field(name) => collect_field(v, name, &mut out),
Projection::ListEntries => {
let entries = match v {
Value::Array(items) => items.clone(),
Value::Object(obj) => obj
.get("tasks")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default(),
_ => Vec::new(),
};
for entry in &entries {
if let Some(id) = entry.get("id").and_then(Value::as_str) {
out.push(id.to_string());
}
}
}
Projection::ShowWithContext => {
if let Some(Value::Array(ancestors)) = v.get("ancestors") {
for a in ancestors {
out.push(a["id"].as_str().unwrap_or_default().to_string());
}
}
collect_subtree(v, &mut out);
}
Projection::DepTree => collect_dep_tree(v, &mut out),
Projection::SearchIds => {
for key in ["tasks", "archived"] {
if let Some(Value::Array(items)) = v.get(key) {
for item in items {
if let Some(id) = item.get("id").and_then(Value::as_str) {
out.push(id.to_string());
}
}
}
}
}
Projection::SearchTitles => {
if let Some(Value::Array(items)) = v.get("inbox") {
for item in items {
if let Some(title) = item.get("title").and_then(Value::as_str) {
out.push(title.to_string());
}
}
}
}
Projection::ShowTaskOnly => collect_subtree(v, &mut out),
}
out
}
struct Row {
args: &'static [&'static str],
universe: &'static [&'static str],
projection: Projection,
expect_empty: bool,
}
const fn row(
args: &'static [&'static str],
universe: &'static [&'static str],
p: Projection,
) -> Row {
Row {
args,
universe,
projection: p,
expect_empty: false,
}
}
const ROWS: &[Row] = &[
row(&["list"], TASK_IDS, Projection::TaskTree),
row(&["list", "--all"], TASK_IDS, Projection::TaskTree),
row(&["list", "main"], TASK_IDS, Projection::TaskTree),
row(&["list", "side"], TASK_IDS, Projection::TaskTree),
row(&["list", "shelf"], TASK_IDS, Projection::TaskTree),
row(&["list", "--state", "todo"], TASK_IDS, Projection::TaskTree),
row(
&["list", "--state", "active"],
TASK_IDS,
Projection::TaskTree,
),
row(
&["list", "--state", "blocked"],
TASK_IDS,
Projection::TaskTree,
),
row(
&["list", "--state", "parked"],
TASK_IDS,
Projection::TaskTree,
),
row(&["list", "--state", "done"], TASK_IDS, Projection::TaskTree),
row(
&["list", "--state", "done", "--all"],
TASK_IDS,
Projection::TaskTree,
),
row(&["list", "--tag", "core"], TASK_IDS, Projection::TaskTree),
row(&["list", "--tag", "cc"], TASK_IDS, Projection::TaskTree),
row(
&["list", "--state", "todo", "--tag", "core"],
TASK_IDS,
Projection::TaskTree,
),
row(
&["list", "main", "--state", "done"],
TASK_IDS,
Projection::TaskTree,
),
row(&["show", "M-003"], TASK_IDS, Projection::ShowTaskOnly),
row(&["show", "M-003.1"], TASK_IDS, Projection::ShowTaskOnly),
row(
&["show", "M-003.1", "--context"],
TASK_IDS,
Projection::ShowWithContext,
),
row(&["ready"], TASK_IDS, Projection::ListEntries),
row(&["ready", "--cc"], TASK_IDS, Projection::ListEntries),
row(&["ready", "--tag", "cc"], TASK_IDS, Projection::ListEntries),
row(
&["ready", "--track", "side"],
TASK_IDS,
Projection::ListEntries,
),
row(&["blocked"], TASK_IDS, Projection::ListEntries),
row(&["recent"], TASK_IDS, Projection::ListEntries),
row(
&["recent", "--limit", "2"],
TASK_IDS,
Projection::ListEntries,
),
row(&["tracks"], TRACK_IDS, Projection::Field("id")),
row(&["stats"], TRACK_NAMES, Projection::Field("name")),
row(&["stats", "--all"], TRACK_NAMES, Projection::Field("name")),
row(&["search", "First task"], TASK_IDS, Projection::SearchIds),
row(
&["search", "--no-archive", "First task"],
TASK_IDS,
Projection::SearchIds,
),
row(&["search", "M-001"], TASK_IDS, Projection::SearchIds),
row(
&["search", "--track", "side", "Side"],
TASK_IDS,
Projection::SearchIds,
),
Row {
args: &["search", "Diamond"],
universe: TASK_IDS,
projection: Projection::SearchIds,
expect_empty: true,
},
row(
&["search", "design"],
INBOX_TITLES,
Projection::SearchTitles,
),
Row {
args: &["search", "zzz-no-such-thing"],
universe: TASK_IDS,
projection: Projection::SearchIds,
expect_empty: true,
},
row(&["deps", "H-001"], TASK_IDS, Projection::DepTree),
row(&["deps", "H-005"], TASK_IDS, Projection::DepTree),
row(&["deps", "H-007"], TASK_IDS, Projection::DepTree),
row(&["deps", "H-004"], TASK_IDS, Projection::DepTree),
row(&["inbox"], INBOX_TITLES, Projection::Field("title")),
];
#[test]
fn human_and_json_name_the_same_things() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
create_fixture(root);
let mut failures: Vec<String> = Vec::new();
for row in ROWS {
let human = human_sequence(&run_fr(root, row.args), row.universe);
let mut json_args = vec!["--json"];
json_args.extend_from_slice(row.args);
let raw = run_fr(root, &json_args);
let value: Value = serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("fr --json {:?} emitted invalid JSON: {e}", row.args));
let json = json_sequence(&value, row.projection);
if human != json {
failures.push(format!(
"fr {}\n human: {:?}\n json: {:?}",
row.args.join(" "),
human,
json
));
} else if human.is_empty() && !row.expect_empty {
failures.push(format!(
"fr {}\n both surfaces named nothing, and the row does not \
declare expect_empty — the fixture has stopped exercising this filter",
row.args.join(" ")
));
}
}
assert!(
failures.is_empty(),
"{} of {} parity rows failed:\n\n{}",
failures.len(),
ROWS.len(),
failures.join("\n\n")
);
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Class {
Covered,
Write,
#[allow(dead_code)]
JsonIgnored,
NotAListing,
Deferred(&'static str),
}
const CLASSIFICATION: &[(&str, Class)] = &[
("list", Class::Covered),
("show", Class::Covered),
("ready", Class::Covered),
("blocked", Class::Covered),
("tracks", Class::Covered),
("stats", Class::Covered),
("recent", Class::Covered),
("inbox", Class::Covered),
("search", Class::Covered),
("deps", Class::Covered),
("info", Class::NotAListing),
(
"check",
Class::Deferred(
"check's human and JSON surfaces are a real pair, but on a healthy \
fixture both are empty — covering it needs the damaged-fixture corpus",
),
),
(
"projects",
Class::Deferred("reads the global registry, not project content"),
),
(
"actor",
Class::Deferred("reads the actor registry, not project content"),
),
(
"recovery",
Class::Deferred("reads the recovery log, which is empty on a healthy fixture"),
),
("init", Class::Write),
("merge", Class::Write),
("git", Class::Write),
("add", Class::Write),
("push", Class::Write),
("sub", Class::Write),
("state", Class::Write),
("start", Class::Write),
("done", Class::Write),
("tag", Class::Write),
("dep", Class::Write),
("note", Class::Write),
("ref", Class::Write),
("spec", Class::Write),
("title", Class::Write),
("mv", Class::Write),
("triage", Class::Write),
("track", Class::Write),
("clean", Class::Write),
("import", Class::Write),
("delete", Class::Write),
];
fn class_of(name: &str) -> Option<Class> {
CLASSIFICATION
.iter()
.find(|(n, _)| *n == name)
.map(|(_, c)| *c)
}
#[test]
fn every_subcommand_is_classified() {
use clap::CommandFactory;
let cmd = frame::cli::commands::Cli::command();
let mut unclassified: Vec<&str> = Vec::new();
for sub in cmd.get_subcommands() {
if class_of(sub.get_name()).is_none() {
unclassified.push(sub.get_name());
}
}
assert!(
unclassified.is_empty(),
"new subcommand(s) {unclassified:?} are not classified in tests/parity.rs.\n\
Add each to CLASSIFICATION: `Covered` (and add matrix rows) if it is a read \
command with a --json surface, or one of the exempt classes with a reason."
);
}
#[test]
fn classification_names_only_real_subcommands() {
use clap::CommandFactory;
let cmd = frame::cli::commands::Cli::command();
let real: Vec<&str> = cmd.get_subcommands().map(|s| s.get_name()).collect();
let stale: Vec<&str> = CLASSIFICATION
.iter()
.map(|(n, _)| *n)
.filter(|n| !real.contains(n))
.collect();
assert!(
stale.is_empty(),
"CLASSIFICATION names subcommand(s) that no longer exist: {stale:?}"
);
}
#[test]
fn covered_subcommands_have_matrix_rows() {
let missing: Vec<&str> = CLASSIFICATION
.iter()
.filter(|(_, c)| *c == Class::Covered)
.map(|(n, _)| *n)
.filter(|name| !ROWS.iter().any(|r| r.args.first() == Some(name)))
.collect();
assert!(
missing.is_empty(),
"subcommand(s) {missing:?} are classified Covered but have no rows in ROWS"
);
}
#[test]
fn matrix_rows_are_all_covered_subcommands() {
let wrong: Vec<&str> = ROWS
.iter()
.filter_map(|r| r.args.first().copied())
.filter(|name| class_of(name) != Some(Class::Covered))
.collect();
assert!(
wrong.is_empty(),
"ROWS exercise subcommand(s) {wrong:?} that are not classified Covered"
);
}
#[test]
fn identifier_match_respects_token_boundaries() {
assert_eq!(
first_identifier(" [ ] M-003.1 Sub one", TASK_IDS).as_deref(),
Some("M-003.1")
);
assert_eq!(
first_identifier("[ ] M-003 Third task", TASK_IDS).as_deref(),
Some("M-003")
);
assert_eq!(
first_identifier(" Side Track side S tracks/side.md", TRACK_IDS).as_deref(),
Some("side")
);
assert_eq!(first_identifier("== Main Track (main) ==", TASK_IDS), None);
}
#[test]
fn human_sequence_ignores_dependency_mentions() {
let out = "[main] [-] M-004 Blocked task #cc (blocked by: M-001)\n";
assert_eq!(human_sequence(out, TASK_IDS), vec!["M-004"]);
let out = "[>] M-002 Second task\nadded: 2025-05-02\ndep: M-001\n";
assert_eq!(human_sequence(out, TASK_IDS), vec!["M-002"]);
}
#[test]
fn json_projections_read_the_documented_shapes() {
let v: Value = serde_json::from_str(
r#"{"tasks":[{"track":"main","id":"M-003","subtasks":[{"id":"M-003.2"}]},
{"track":"main","id":"M-003.1"}]}"#,
)
.unwrap();
assert_eq!(
json_sequence(&v, Projection::ListEntries),
vec!["M-003", "M-003.1"]
);
assert_eq!(
json_sequence(&v, Projection::TaskTree),
vec!["M-003", "M-003.2", "M-003.1"]
);
let v: Value =
serde_json::from_str(r#"{"id":"M-003.1","ancestors":[{"id":"M-003"}]}"#).unwrap();
assert_eq!(
json_sequence(&v, Projection::ShowWithContext),
vec!["M-003", "M-003.1"]
);
assert_eq!(json_sequence(&v, Projection::ShowTaskOnly), vec!["M-003.1"]);
}
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use frame::tui::app::{App, View};
use frame::tui::input::handle_key;
#[derive(Clone, Copy, Debug)]
enum Press {
Char(char),
Enter,
Space,
}
fn press(app: &mut App, stroke: Press) {
let event = match stroke {
Press::Char(c) if c.is_ascii_uppercase() => {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::SHIFT)
}
Press::Char(c) => KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
Press::Enter => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
Press::Space => KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE),
};
handle_key(app, event);
}
#[derive(Clone, Copy)]
enum Start {
Track(&'static str),
Detail(&'static str, &'static str),
Recent(usize),
}
struct SurfaceCase {
what: &'static str,
cli: &'static [&'static str],
start: Start,
keys: &'static [Press],
cli_refuses: bool,
known_divergence: Option<&'static str>,
}
const fn case(
what: &'static str,
cli: &'static [&'static str],
start: Start,
keys: &'static [Press],
) -> SurfaceCase {
SurfaceCase {
what,
cli,
start,
keys,
cli_refuses: false,
known_divergence: None,
}
}
use Press::{Char, Enter, Space};
const SURFACE_CASES: &[SurfaceCase] = &[
case(
"mark a Backlog task done",
&["state", "M-001", "done"],
Start::Track("M-001"),
&[Char('x')],
),
case(
"park a Backlog task",
&["state", "M-001", "parked"],
Start::Track("M-001"),
&[Char('~')],
),
case(
"block a Backlog task",
&["state", "M-001", "blocked"],
Start::Track("M-001"),
&[Char('b')],
),
case(
"mark a subtask done",
&["state", "M-003.1", "done"],
Start::Track("M-003.1"),
&[Char('x')],
),
case(
"mark a top-level Parked task done",
&["state", "M-010", "done"],
Start::Track("M-010"),
&[Char('x')],
),
case(
"unpark a top-level Parked task",
&["state", "M-010", "todo"],
Start::Track("M-010"),
&[Char('o')],
),
case(
"reopen a top-level Done task",
&["state", "M-000", "todo"],
Start::Recent(1),
&[Space],
),
case(
"park a top-level Done task",
&["state", "M-000", "parked"],
Start::Detail("main", "M-000"),
&[Char('~')],
),
case(
"reopen a top-level Done task from the detail view",
&["state", "M-000", "todo"],
Start::Detail("main", "M-000"),
&[Char('o')],
),
case(
"block a top-level Done task from the detail view",
&["state", "M-000", "blocked"],
Start::Detail("main", "M-000"),
&[Char('b')],
),
case(
"move a Backlog task to another track",
&["mv", "M-001", "--track", "side"],
Start::Track("M-001"),
&[Char('M'), Char('S'), Enter, Char('b')],
),
case(
"move a Parked task to another track",
&["mv", "M-010", "--track", "side"],
Start::Track("M-010"),
&[Char('M'), Char('S'), Enter, Char('b')],
),
case(
"move a Done task to another track",
&["mv", "M-000", "--track", "side"],
Start::Detail("main", "M-000"),
&[Char('M'), Char('S'), Enter, Char('b')],
),
SurfaceCase {
what: "move a task into a shelved track",
cli: &["mv", "M-001", "--track", "shelf"],
start: Start::Track("M-001"),
keys: &[Char('M'), Char('H'), Enter, Char('b')],
cli_refuses: true,
known_divergence: None,
},
SurfaceCase {
what: "add a ref that leaves the project",
cli: &["ref", "M-001", "add", "../outside.md"],
start: Start::Track("M-001"),
keys: &[
Enter,
Char('@'),
Char('.'),
Char('.'),
Char('/'),
Char('o'),
Char('u'),
Char('t'),
Char('s'),
Char('i'),
Char('d'),
Char('e'),
Char('.'),
Char('m'),
Char('d'),
Enter,
],
cli_refuses: true,
known_divergence: Some(
"the CLI refuses a ref that leaves the project and writes nothing; \
the TUI stores it and renders it in the error colour, and `fr check` \
reports `ref_outside_project`. If these ever agree, the TUI started \
refusing — take this note off.",
),
},
];
fn try_fr(dir: &Path, args: &[&str]) -> bool {
Command::new(fr_bin())
.args(args)
.current_dir(dir)
.env("XDG_CONFIG_HOME", dir.join(".xdg-config"))
.output()
.expect("failed to run fr")
.status
.success()
}
fn frame_tree(root: &Path) -> Vec<(String, String)> {
let frame = root.join("frame");
let mut out = Vec::new();
let mut stack = vec![frame.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let name = path.file_name().unwrap().to_string_lossy().to_string();
if frame::io::project_io::LOCAL_ONLY_FRAME_FILES.contains(&name.as_str()) {
continue;
}
let rel = path
.strip_prefix(&frame)
.unwrap()
.to_string_lossy()
.to_string();
out.push((rel, fs::read_to_string(&path).unwrap_or_default()));
}
}
out.sort();
out
}
fn drive_tui(root: &Path, case: &SurfaceCase) {
let project = frame::io::project_io::load_project(root).expect("load project");
let mut app = App::new(project);
match case.start {
Start::Track(task_id) => assert!(
app.jump_to_task(task_id),
"{}: no cursor position for {task_id} in the track view",
case.what
),
Start::Detail(track_id, task_id) => {
app.view = View::Detail {
track_id: track_id.to_string(),
task_id: task_id.to_string(),
};
}
Start::Recent(index) => {
app.view = View::Recent;
app.recent_cursor = index;
}
}
for stroke in case.keys {
press(&mut app, *stroke);
}
for track_id in app.flush_all_pending_moves() {
app.save_track_logged(&track_id);
}
}
#[test]
fn cli_and_tui_leave_the_same_files() {
let dir = tempfile::tempdir().unwrap();
let mut failures: Vec<String> = Vec::new();
for (i, case) in SURFACE_CASES.iter().enumerate() {
let via_cli = dir.path().join(format!("case{i}-cli"));
let via_tui = dir.path().join(format!("case{i}-tui"));
create_fixture(&via_cli);
create_fixture(&via_tui);
let ok = try_fr(&via_cli, case.cli);
if ok == case.cli_refuses {
failures.push(format!(
"{} — `fr {}` {} but the case says it should {}",
case.what,
case.cli.join(" "),
if ok { "succeeded" } else { "failed" },
if case.cli_refuses {
"refuse"
} else {
"succeed"
},
));
continue;
}
drive_tui(&via_tui, case);
let cli_tree = frame_tree(&via_cli);
let tui_tree = frame_tree(&via_tui);
let agree = cli_tree == tui_tree;
match (agree, case.known_divergence) {
(true, None) => {}
(false, Some(_)) => {}
(true, Some(note)) => failures.push(format!(
"{} — the surfaces now agree, so the known-divergence note is stale \
and should come off:\n {note}",
case.what,
)),
(false, None) => {
let keys: Vec<String> = case.keys.iter().map(|k| format!("{k:?}")).collect();
failures.push(format!(
"{} — `fr {}` vs [{}]\n{}",
case.what,
case.cli.join(" "),
keys.join(", "),
describe_tree_diff(&cli_tree, &tui_tree),
));
}
}
}
assert!(
failures.is_empty(),
"{} of {} CLI/TUI cases disagreed:\n\n{}",
failures.len(),
SURFACE_CASES.len(),
failures.join("\n\n")
);
}
fn describe_tree_diff(cli: &[(String, String)], tui: &[(String, String)]) -> String {
for (path, cli_body) in cli {
match tui.iter().find(|(p, _)| p == path) {
Some((_, tui_body)) if tui_body == cli_body => continue,
Some((_, tui_body)) => {
return format!(
" {path} differs.\n --- via CLI ---\n{}\n --- via TUI ---\n{}",
indent(cli_body),
indent(tui_body)
);
}
None => return format!(" {path} exists only in the CLI copy"),
}
}
for (path, _) in tui {
if !cli.iter().any(|(p, _)| p == path) {
return format!(" {path} exists only in the TUI copy");
}
}
" trees differ, but no single differing file was found".to_string()
}
fn indent(body: &str) -> String {
body.lines()
.map(|l| format!(" | {l}"))
.collect::<Vec<_>>()
.join("\n")
}