#[allow(clippy::wildcard_imports)]
use super::super::*;
#[allow(clippy::wildcard_imports)]
use super::helpers::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn editor_return_warns_on_invalid_issue() {
let (_tmp, mut app) = make_app();
let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
if let Mode::Form = &app.mode {
for c in "Lint Warn".chars() {
let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
}
let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
}
let issue = app
.board
.get_selected_card()
.cloned()
.expect("selected issue");
let path = app.store.issues_dir.join(&issue.filename);
let mut text = std::fs::read_to_string(&path).unwrap();
let before = text.clone();
text = text
.lines()
.map(|l| {
if l.starts_with("priority:") {
"priority: strange"
} else {
l
}
})
.collect::<Vec<_>>()
.join("\n");
assert_ne!(
text, before,
"the fixture must actually change the priority"
);
std::fs::write(&path, text).unwrap();
app.after_editor(&issue.filename);
assert!(matches!(app.mode, Mode::EditRepair { .. }));
assert!(
matches!(&app.mode, Mode::EditRepair { key: None, .. }),
"an invalid priority is not an undeclared key: {:?}",
app.mode
);
}
#[test]
fn editor_return_preserves_an_unknown_key_and_warns_with_it() {
let (_tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue_with_custom_key(&dir, "2026-01-01-a.md", "build_id");
app.after_editor("2026-01-01-a.md");
let on_disk = std::fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap();
assert!(
on_disk.contains("build_id: \"ci-4471\""),
"the edit warning must not discard the custom key it warns about:\n{on_disk}"
);
match &app.mode {
Mode::EditRepair { key, .. } => assert_eq!(
key.as_deref(),
Some("build_id"),
"the warning carries the key `a` can declare"
),
other => panic!("expected the edit warning, got {other:?}"),
}
}
#[cfg(unix)]
mod edit_conflict {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::os::unix::fs::PermissionsExt;
const USER_EDIT: &str = r#"sed -i 's/priority: medium/priority: high/' "$1""#;
pub(super) fn script(
app: &mut App,
tmp: &TempDir,
name: &str,
body: &str,
) -> std::path::PathBuf {
use std::io::Write as _;
let path = tmp.path().join(name);
let full = format!("#!/bin/sh\n{body}\n");
let mut writer = std::process::Command::new("sh")
.args(["-c", "cat > \"$1\"", "sh"])
.arg(&path)
.stdin(std::process::Stdio::piped())
.spawn()
.expect("spawn the script writer");
writer
.stdin
.take()
.expect("the writer's stdin is piped")
.write_all(full.as_bytes())
.expect("write the script body");
assert!(
writer.wait().expect("wait for the script writer").success(),
"the script writer failed"
);
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
app.store.config.editor = Some(path.to_string_lossy().to_string());
path
}
pub(super) fn temp_for(number: u32) -> std::path::PathBuf {
let path =
std::env::temp_dir().join(format!("tix-edit-{}-{number}.md", std::process::id()));
let _ = fs::remove_file(&path);
path
}
fn raced_round_trip(number: u32, name: &str) -> (TempDir, App, std::path::PathBuf) {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, name, number, "Raced", "todo", None);
let disk = dir.join(name);
script(
&mut app,
&tmp,
"editor.sh",
&format!(
"{USER_EDIT}\nsed -i 's/priority: \"medium\"/priority: \"low\"/' '{}'",
disk.display()
),
);
let temp = temp_for(number);
app.open_editor_round_trip(name).unwrap();
assert!(
matches!(app.mode, Mode::EditConflict { .. }),
"the fixture must actually produce a conflict, got {:?}",
app.mode
);
(tmp, app, temp)
}
#[test]
fn an_unchanged_file_writes_normally_and_never_enters_the_conflict_prompt() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
script(&mut app, &tmp, "editor.sh", USER_EDIT);
let temp = temp_for(1);
app.open_editor_round_trip("2026-01-01-a.md").unwrap();
assert!(
matches!(app.mode, Mode::Board),
"an unchanged file is not a conflict: {:?}",
app.mode
);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("Edited")),
"got {:?}",
app.status_msg
);
let on_disk = fs::read_to_string(dir.join("2026-01-01-a.md")).unwrap();
assert!(
on_disk.contains("priority: high"),
"the edit is on disk:\n{on_disk}"
);
assert!(!temp.exists(), "the temp copy is cleaned up");
assert!(app.pending_edit.is_none(), "the round trip is finished");
}
#[test]
fn a_concurrent_write_enters_the_conflict_prompt_and_writes_nothing() {
let (_tmp, app, temp) = raced_round_trip(2, "2026-01-01-b.md");
let disk = app.store.issues_dir.join("2026-01-01-b.md");
match &app.mode {
Mode::EditConflict {
filename,
temp: kept,
disk_path,
} => {
assert_eq!(filename, "2026-01-01-b.md");
assert_eq!(kept, &temp);
assert_eq!(disk_path, &disk);
}
other => panic!("expected the conflict prompt, got {other:?}"),
}
assert!(
temp.is_file(),
"the editor's version must survive for the prompt to decide about"
);
let on_disk = fs::read_to_string(&disk).unwrap();
assert!(
on_disk.contains(r#"priority: "low""#),
"the concurrent write must not be clobbered:\n{on_disk}"
);
assert!(
!on_disk.contains(r#"priority: "high""#),
"nothing of the editor's version may be written:\n{on_disk}"
);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("changed on disk")),
"got {:?}",
app.status_msg
);
let _ = fs::remove_file(&temp);
}
#[test]
fn overriding_writes_the_editors_version_and_removes_the_temp_copy() {
let (_tmp, mut app, temp) = raced_round_trip(3, "2026-01-01-c.md");
let disk = app.store.issues_dir.join("2026-01-01-c.md");
let action = app.handle_key(press(KeyCode::Char('o'))).unwrap();
assert_eq!(action, Action::None);
assert!(
matches!(app.mode, Mode::Board),
"override returns to the board: {:?}",
app.mode
);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("Overrode")),
"the status says what happened: {:?}",
app.status_msg
);
let on_disk = fs::read_to_string(&disk).unwrap();
assert!(
on_disk.contains("priority: high"),
"the editor's version wins:\n{on_disk}"
);
assert!(!temp.exists(), "the temp copy is consumed by the write");
}
#[test]
fn abandoning_keeps_the_on_disk_version_and_removes_the_temp_copy() {
for (number, key) in [(4u32, KeyCode::Esc), (5, KeyCode::Char('a'))] {
let name = format!("2026-01-01-{number}.md");
let (_tmp, mut app, temp) = raced_round_trip(number, &name);
let disk = app.store.issues_dir.join(&name);
let action = app.handle_key(press(key)).unwrap();
assert_eq!(action, Action::None, "{key:?}");
assert!(matches!(app.mode, Mode::Board), "{key:?}: {:?}", app.mode);
let on_disk = fs::read_to_string(&disk).unwrap();
assert!(
on_disk.contains(r#"priority: "low""#),
"{key:?} keeps the concurrent write:\n{on_disk}"
);
assert!(!temp.exists(), "{key:?}: the temp copy is discarded");
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("kept the version")),
"{key:?}: got {:?}",
app.status_msg
);
}
}
#[test]
fn comparing_reopens_the_editor_on_the_temp_copy_and_keeps_it() {
let (tmp, mut app, temp) = raced_round_trip(6, "2026-01-01-f.md");
let runs = tmp.path().join("runs.txt");
script(
&mut app,
&tmp,
"compare.sh",
&format!(
"printf 'run\\n' >> '{}'\nprintf 'compared\\n' >> \"$1\"",
runs.display()
),
);
let action = app.handle_key(press(KeyCode::Char('c'))).unwrap();
assert_eq!(
action,
Action::CompareEdit,
"the editor needs the terminal, so the key hands it to the event loop"
);
app.compare_conflicting_edit().unwrap();
assert!(
matches!(app.mode, Mode::EditConflict { .. }),
"compare returns to the same prompt: {:?}",
app.mode
);
assert!(temp.is_file(), "the temp copy survives a look");
let temp_text = fs::read_to_string(&temp).unwrap();
assert!(
temp_text.contains("compared"),
"the editor ran on the temp copy:\n{temp_text}"
);
assert_eq!(
fs::read_to_string(&runs).unwrap().lines().count(),
1,
"and it ran exactly once"
);
let _ = fs::remove_file(&temp);
}
#[test]
fn a_second_change_while_comparing_is_detected_too() {
let (tmp, mut app, temp) = raced_round_trip(7, "2026-01-01-g.md");
let disk = app.store.issues_dir.join("2026-01-01-g.md");
script(
&mut app,
&tmp,
"compare.sh",
&format!(
"sed -i 's/priority: \"low\"/priority: \"medium\"/' '{}'",
disk.display()
),
);
app.compare_conflicting_edit().unwrap();
assert!(matches!(app.mode, Mode::EditConflict { .. }));
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("again")),
"the second change must be named: {:?}",
app.status_msg
);
let _ = fs::remove_file(&temp);
}
fn flattened(text: &str) -> String {
text.replace(['│', '─', '┌', '┐', '└', '┘'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn the_conflict_prompt_names_its_three_choices_and_both_files() {
let (_tmp, mut app) = make_app();
let temp = std::path::PathBuf::from("/tmp/tix-edit-1-42.md");
let disk_path = std::path::PathBuf::from("/tmp/store/issues/2026-01-01-prompt.md");
app.mode = Mode::EditConflict {
filename: "2026-01-01-prompt.md".to_string(),
temp: temp.clone(),
disk_path: disk_path.clone(),
};
let hints = [
"overwrite the file with this edit",
"keep the on-disk version",
"compare in the editor",
];
let actions = [TuiAction::Confirm, TuiAction::Ignore, TuiAction::Reedit];
let text = flattened(&cells_to_text(&draw_board(&app, 100, 30), 100, 30));
assert!(text.contains("Edit Conflict"), "{text}");
assert!(text.contains("2026-01-01-prompt.md"), "{text}");
assert!(
text.contains(&temp.display().to_string()),
"the editor's copy must be named where the user can open it:\n{text}"
);
assert!(
text.contains(&disk_path.display().to_string()),
"and so must the newer file:\n{text}"
);
for (action, hint) in actions.iter().zip(hints) {
let chord = app.bindings.label(InputContext::EditConflict, *action);
assert!(
text.contains(&chord),
"the prompt names the effective chord `{chord}`:\n{text}"
);
assert!(text.contains(hint), "`{chord}` says what it does:\n{text}");
}
let text = flattened(&cells_to_text(&draw_board(&app, 80, 24), 80, 24));
assert!(text.contains("2026-01-01-prompt.md"), "{text}");
assert!(text.contains(&temp.display().to_string()), "{text}");
for hint in hints {
assert!(
text.contains(hint),
"`{hint}` must survive an 80x24 terminal:\n{text}"
);
}
}
}
#[cfg(unix)]
mod edit_repair {
use super::edit_conflict::{script, temp_for};
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::edit_problems::EditProblemKind;
const BREAK: &str = r#"sed -i 's/priority: medium/priority: [broken/' "$1""#;
fn write_issue_with_misplaced_history(dir: &std::path::Path, name: &str, number: u32) {
let contents = format!(
"---\nnumber: {number}\ntitle: \"Hist\"\nstatus: \"todo\"\nlabels: []\n\
priority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\n\
Some prose.\n\n## History\n\n\
- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} created \u{2014} did it\n\
- 2026-01-02T00:00:00Z \u{2014} bob \u{2014} edited \u{2014} then this\n"
);
fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn an_editor_return_adopts_a_tix_toml_that_gained_custom_attributes() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-t.md", 31, "T", "todo", None);
let config_path = app.store.config_path().unwrap();
let mut text = fs::read_to_string(&config_path).unwrap();
text.push_str("\n[custom_attributes]\nkeys = [\"build_id\"]\n");
fs::write(&config_path, text).unwrap();
script(
&mut app,
&tmp,
"add-type.sh",
r#"cat > "$1" <<'ISSUE'
---
number: 31
title: "T"
status: todo
build_id: story
labels: []
priority: medium
created: 2026-01-01
updated: 2026-01-01
---
ISSUE"#,
);
app.open_editor_round_trip("2026-01-01-t.md").unwrap();
assert!(
!matches!(app.mode, Mode::EditRepair { .. }),
"a declared key must not raise the repair prompt, got {:?}",
app.mode
);
let written = fs::read_to_string(dir.join("2026-01-01-t.md")).unwrap();
assert!(written.contains("build_id: story"), "{written}");
assert!(
app.store
.config
.custom_attributes()
.contains(&"build_id".to_string()),
"the reloaded configuration declares the key"
);
assert!(app.config_snapshot.is_some(), "the snapshot advanced");
}
#[test]
fn a_broken_tix_toml_is_reported_and_the_previous_config_is_kept() {
let (_tmp, mut app) = make_app();
let config_path = app.store.config_path().unwrap();
let before = app.store.config.clone();
let startup_bytes = app.config_snapshot.clone();
fs::write(&config_path, "this is not = = toml\n").unwrap();
assert!(
!app.reload_config_if_changed(),
"a broken file is not adopted"
);
assert_eq!(
app.store.config, before,
"the previous configuration stands"
);
assert!(
app.status_msg
.as_deref()
.unwrap_or_default()
.contains("tix.toml"),
"and it says so: {:?}",
app.status_msg
);
assert_eq!(
app.config_snapshot, startup_bytes,
"the snapshot did not advance, so a fixed file is picked up later"
);
}
#[test]
fn an_unparsable_edit_enters_repair_with_the_real_diagnostic_and_writes_nothing() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-h.md", 21, "H", "todo", None);
script(&mut app, &tmp, "break.sh", BREAK);
let temp = temp_for(21);
let before = fs::read_to_string(dir.join("2026-01-01-h.md")).unwrap();
app.open_editor_round_trip("2026-01-01-h.md").unwrap();
let problems = match &app.mode {
Mode::EditRepair {
problems,
temp: kept,
written,
..
} => {
assert_eq!(
kept.as_deref(),
Some(temp.as_path()),
"the copy is retained"
);
assert!(!written, "nothing has been written yet");
problems.clone()
}
other => panic!("expected the repair prompt, got {other:?}"),
};
assert!(temp.is_file(), "the editor's copy must survive");
assert!(
problems
.iter()
.any(|p| p.kind == EditProblemKind::Unparsable),
"{problems:?}"
);
let summary: String = problems
.iter()
.map(|p| p.message.clone())
.collect::<Vec<_>>()
.join(" | ");
assert!(
!summary.contains("file not valid after edit"),
"the generic message is the bug: {summary}"
);
assert!(
summary.contains("priority") && summary.contains("invalid type"),
"the real parse diagnostic must reach the user: {summary}"
);
assert_eq!(
fs::read_to_string(dir.join("2026-01-01-h.md")).unwrap(),
before,
"an unparsable edit must write nothing"
);
assert!(
app.pending_edit.is_some(),
"the round trip stays in flight until a decision is made"
);
let _ = fs::remove_file(&temp);
}
#[test]
fn escape_acknowledges_and_keeps_the_retained_copy() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-e.md", 22, "E", "todo", None);
script(&mut app, &tmp, "break.sh", BREAK);
let temp = temp_for(22);
app.open_editor_round_trip("2026-01-01-e.md").unwrap();
let action = app.handle_key(press(KeyCode::Esc)).unwrap();
assert_eq!(action, Action::None);
assert!(matches!(app.mode, Mode::Board), "{:?}", app.mode);
assert!(
temp.is_file(),
"Esc acknowledges; it never removes the retained copy"
);
let _ = fs::remove_file(&temp);
}
#[test]
fn discard_requires_a_second_press_and_is_the_only_removal() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-d.md", 23, "D", "todo", None);
script(&mut app, &tmp, "break.sh", BREAK);
let temp = temp_for(23);
app.open_editor_round_trip("2026-01-01-d.md").unwrap();
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert!(
matches!(
app.mode,
Mode::EditRepair {
discard_armed: true,
..
}
),
"{:?}",
app.mode
);
assert!(temp.is_file(), "the first press only arms the discard");
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("again")),
"got {:?}",
app.status_msg
);
let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
assert!(matches!(app.mode, Mode::Board), "{:?}", app.mode);
assert!(!temp.exists(), "the confirmed discard removes the copy");
}
#[test]
fn the_history_fix_moves_every_entry_to_the_front_and_the_file_parses() {
let (_tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue_with_misplaced_history(&dir, "2026-01-01-hist.md", 24);
app.after_editor("2026-01-01-hist.md");
assert!(
matches!(app.mode, Mode::EditRepair { .. }),
"{:?}",
app.mode
);
let action = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert_eq!(action, Action::None);
assert!(matches!(app.mode, Mode::Board), "{:?}", app.mode);
let on_disk = fs::read_to_string(dir.join("2026-01-01-hist.md")).unwrap();
let issue = Issue::parse(&on_disk).unwrap();
assert_eq!(
issue.history.entries.len(),
2,
"both entries are recognised after the move:\n{on_disk}"
);
for entry in ["did it", "then this"] {
assert!(
on_disk.contains(entry),
"`{entry}` must survive:\n{on_disk}"
);
}
assert!(
!on_disk.contains("Some prose.\n\n## History"),
"the section is no longer body text:\n{on_disk}"
);
assert!(on_disk.contains("Some prose"), "{on_disk}");
}
#[test]
fn reedit_reopens_the_editor_on_the_retained_copy_and_commits_a_valid_file() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-r.md", 29, "R", "todo", None);
script(&mut app, &tmp, "break.sh", BREAK);
let temp = temp_for(29);
app.open_editor_round_trip("2026-01-01-r.md").unwrap();
script(
&mut app,
&tmp,
"fix.sh",
r#"sed -i 's/priority: \[broken/priority: high/' "$1""#,
);
assert_eq!(
app.handle_key(press(KeyCode::Char('r'))).unwrap(),
Action::ReeditRepair
);
app.reedit_repair().unwrap();
assert!(matches!(app.mode, Mode::Board), "{:?}", app.mode);
let on_disk = fs::read_to_string(dir.join("2026-01-01-r.md")).unwrap();
let issue = Issue::parse(&on_disk).unwrap();
assert_eq!(issue.priority, crate::model::Priority::High);
assert!(!temp.exists(), "the copy is consumed once the edit lands");
assert!(app.pending_edit.is_none());
}
#[test]
fn the_missing_title_fix_restores_the_pre_edit_title() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-t.md", 28, "Original", "todo", None);
script(
&mut app,
&tmp,
"untitle.sh",
r#"sed -i 's/^title: .*/title: ""/' "$1""#,
);
let temp = temp_for(28);
app.open_editor_round_trip("2026-01-01-t.md").unwrap();
match &app.mode {
Mode::EditRepair { problems, .. } => assert!(
problems
.iter()
.any(|p| p.kind == EditProblemKind::MissingTitle),
"{problems:?}"
),
other => panic!("expected the repair prompt, got {other:?}"),
}
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(matches!(app.mode, Mode::Board), "{:?}", app.mode);
let on_disk = fs::read_to_string(dir.join("2026-01-01-t.md")).unwrap();
assert_eq!(Issue::parse(&on_disk).unwrap().title, "Original");
assert!(
!temp.exists(),
"a successful fix consumes the retained copy"
);
}
#[test]
fn the_duplicate_key_fix_keeps_the_first_value() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-dup.md", 25, "Kept", "todo", None);
script(
&mut app,
&tmp,
"dup.sh",
r#"sed -i 's/^title: .*/&\ntitle: "Second"/' "$1""#,
);
let temp = temp_for(25);
app.open_editor_round_trip("2026-01-01-dup.md").unwrap();
match &app.mode {
Mode::EditRepair { problems, .. } => assert!(
problems
.iter()
.any(|p| p.kind == EditProblemKind::DuplicateKey),
"{problems:?}"
),
other => panic!("expected the repair prompt, got {other:?}"),
}
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(matches!(app.mode, Mode::Board), "{:?}", app.mode);
let on_disk = fs::read_to_string(dir.join("2026-01-01-dup.md")).unwrap();
assert_eq!(Issue::parse(&on_disk).unwrap().title, "Kept");
assert!(
!temp.exists(),
"a successful fix consumes the retained copy"
);
}
#[test]
fn a_fix_that_still_does_not_parse_leaves_the_file_untouched() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-broken.md", 27, "Broken", "todo", None);
script(
&mut app,
&tmp,
"triple.sh",
r#"sed -i 's/^title: .*/&\ntitle: "B"\ntitle: "C"/' "$1""#,
);
let temp = temp_for(27);
let disk_before = fs::read_to_string(dir.join("2026-01-01-broken.md")).unwrap();
app.open_editor_round_trip("2026-01-01-broken.md").unwrap();
assert!(
matches!(app.mode, Mode::EditRepair { .. }),
"{:?}",
app.mode
);
let temp_before = fs::read_to_string(&temp).unwrap();
let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
assert!(
matches!(app.mode, Mode::EditRepair { .. }),
"the prompt stays up: {:?}",
app.mode
);
assert_eq!(
fs::read_to_string(dir.join("2026-01-01-broken.md")).unwrap(),
disk_before,
"nothing is written when the fix does not repair the file"
);
assert_eq!(
fs::read_to_string(&temp).unwrap(),
temp_before,
"the retained copy is untouched"
);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains("did not make the file valid")),
"got {:?}",
app.status_msg
);
let _ = fs::remove_file(&temp);
}
#[test]
fn an_overwrite_stores_a_recoverable_copy_of_the_previous_version() {
let (tmp, mut app) = make_app();
let dir = app.store.issues_dir.clone();
write_issue(&dir, "2026-01-01-b.md", 26, "B", "todo", None);
script(
&mut app,
&tmp,
"edit.sh",
r#"sed -i 's/priority: medium/priority: high/' "$1""#,
);
let _ = temp_for(26);
app.open_editor_round_trip("2026-01-01-b.md").unwrap();
assert!(matches!(app.mode, Mode::Board), "{:?}", app.mode);
let backups = crate::backup::list(&dir);
assert_eq!(backups.len(), 1, "one overwrite, one recoverable copy");
let backup = &backups[0];
assert!(backup.is_file(), "the previous version must be recoverable");
assert!(
!dir.join("2026-01-01-b.md.tixbak").exists(),
"the copy is centralized, not beside the issue"
);
let previous = fs::read_to_string(backup).unwrap();
assert!(
previous.contains("priority: \"medium\""),
"the backup holds the pre-edit bytes:\n{previous}"
);
assert!(
app.status_msg
.as_deref()
.is_some_and(|m| m.contains(&backup.display().to_string())),
"the status names the backup: {:?}",
app.status_msg
);
}
}