1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
//! `bl update --edit` — the $EDITOR-sourced whole-buffer update (§9). The HUMAN
//! projection of `update`: render the stored `tasks/<id>.md` to a temp buffer,
//! block on the host editor, parse-validate the result as a [`Task`] (reusing
//! the one taskfile parse), and hand it back for the EXISTING update seal —
//! never a new verb, never an unvalidated commit. Agents keep the flag-driven
//! path: a non-tty stdin or an unset `$EDITOR` is an error, not a fallback.
//!
//! [`Editor`] is the host seam — env, tty-ness, and the prompt input are
//! captured ONCE at [`Editor::live`] so tests inject a scripted editor and a
//! canned answer stream instead of mutating process env (`env::set_var` races
//! parallel tests).
use std::env;
use std::fs;
use std::io::{self, BufRead, IsTerminal};
use std::path::Path;
use std::process::Command;
use super::other;
use crate::task::Task;
/// The `--edit` host seam: which editor, whether stdin is interactive, and the
/// stream the re-edit prompt reads answers from. Built from the live process at
/// [`Editor::live`]; tests construct it directly with a scripted editor.
pub(crate) struct Editor {
/// `$EDITOR` (else `$VISUAL`) at invocation; `None` when neither is set.
pub cmd: Option<String>,
/// Is stdin a terminal? `--edit` is interactive-only (§9): agents pass
/// field flags, so a non-tty invocation is refused outright.
pub tty: bool,
/// Where the re-edit prompt reads its answer — live stdin in production.
pub input: Box<dyn BufRead>,
}
impl Editor {
/// The live host seam — read env and stdin once, in [`super::run`], so the
/// dispatch below it stays injectable.
pub(crate) fn live() -> Self {
Editor {
cmd: env::var("EDITOR").or_else(|_| env::var("VISUAL")).ok(),
tty: io::stdin().is_terminal(),
input: Box::new(io::stdin().lock()),
}
}
/// Run the `--edit` interaction for `id`: write `before` (the stored ball,
/// rendered) to a temp buffer, block on the editor, and parse-validate the
/// result. `None` means the buffer parsed back EQUAL to `before` — the
/// idempotent no-op, announced on stderr, nothing to seal. The buffer is
/// removed on every exit path.
pub(super) fn edited(&mut self, before: &Task, id: &str) -> io::Result<Option<Task>> {
if !self.tty {
return Err(other("update --edit: stdin is not a tty — pass field flags instead"));
}
let Some(cmd) = self.cmd.clone() else {
return Err(other("update --edit: set $EDITOR (or $VISUAL) or pass field flags"));
};
let buffer = env::temp_dir().join(format!("bl-edit-{id}-{}.md", super::change_token()));
let staged = fs::write(&buffer, before.to_markdown());
let outcome = staged.and_then(|()| self.cycle(&cmd, &buffer, before, id));
let _ = fs::remove_file(&buffer);
outcome
}
/// The edit loop: editor → parse → accept, no-op, re-edit, or abort. A
/// buffer that does not parse back to a [`Task`] (bad TOML, a missing
/// required field, malformed `[[blockers]]`) is NEVER committed — the
/// re-edit pass reopens the SAME buffer, so a half-fixed edit is not lost.
fn cycle(&mut self, cmd: &str, buffer: &Path, before: &Task, id: &str) -> io::Result<Option<Task>> {
loop {
run_editor(cmd, buffer)?;
match Task::parse(&fs::read_to_string(buffer)?) {
Ok(after) if after == *before => {
eprintln!("update {id}: buffer unchanged — nothing to seal");
return Ok(None);
}
Ok(after) => return Ok(Some(after)),
Err(e) if self.reedit(&e.to_string())? => {}
Err(e) => return Err(other(format!("update --edit: rejected — {e}"))),
}
}
}
/// The rejected-buffer prompt: name the parse error, offer another editor
/// pass; anything but an explicit `y` (EOF included) aborts.
fn reedit(&mut self, err: &str) -> io::Result<bool> {
eprintln!("update --edit: {err}");
eprint!("re-edit? [y/N] ");
let mut line = String::new();
self.input.read_line(&mut line)?;
Ok(line.trim().eq_ignore_ascii_case("y"))
}
}
/// Block on the editor over the buffer. `cmd` is whitespace-split — `$EDITOR`
/// conventionally carries args (`code -w`) — with the buffer path appended; a
/// non-zero exit aborts the op (the editor bailed, so the buffer is suspect).
fn run_editor(cmd: &str, buffer: &Path) -> io::Result<()> {
let mut parts = cmd.split_whitespace();
let Some(program) = parts.next() else {
return Err(other("update --edit: $EDITOR is empty — set it or pass field flags"));
};
let status = Command::new(program).args(parts).arg(buffer).status()?;
if !status.success() {
return Err(other(format!("update --edit: editor '{cmd}' exited {status}")));
}
Ok(())
}
#[cfg(test)]
impl Editor {
/// A detached seam for the flag-driven tests: no tty, no editor, EOF input —
/// any `--edit` reaching it errors, everything else never touches it.
pub(crate) fn detached() -> Self {
Editor { cmd: None, tty: false, input: Box::new(io::empty()) }
}
}
#[cfg(test)]
#[path = "mutate_edit_tests.rs"]
mod tests;