Skip to main content

ljos_cli/
undo.rs

1//! Undo for a tracker mistake: put a closed ticket back to work, and
2//! take back this seat's own ballot or note.
3//!
4//! Nothing is erased. A ticket that was finished by hand, by a program or
5//! by another seat can be reopened, and the reopen is a dated note beside
6//! the close it undoes. A note taken back stays on the logbook with a
7//! withdrawal under it, so a reader sees both what was said and that it
8//! was taken back.
9
10use anyhow::{bail, Context, Result};
11use serde_json::Value;
12
13/// The note a reopen leaves.
14pub const REOPEN_NOTE: &str = "reopened: ";
15/// The note a withdrawn logbook note leaves.
16pub const NOTE_WITHDRAWN: &str = "note-withdrawn: ";
17/// The state a reopen puts a ticket back to when the tracker does not say
18/// which one it held.
19pub const FRESH_STATE: &str = "TODO";
20
21/// Whether `state` is a ticket that is finished or cancelled.
22#[must_use]
23pub fn is_closed(state: &str) -> bool {
24    matches!(state.trim(), "DONE" | "CANCELLED")
25}
26
27/// The state the ticket held before it was closed, from the newest state
28/// line that landed on `closed`. `None` when the logbook does not say.
29#[must_use]
30pub fn prior_state(v: &Value, closed: &str) -> Option<String> {
31    v["logbook"]
32        .as_array()
33        .into_iter()
34        .flatten()
35        .filter(|e| e["to"].as_str() == Some(closed.trim()))
36        .find_map(|e| e["from"].as_str())
37        .map(str::trim)
38        .filter(|from| !from.is_empty() && !is_closed(from))
39        .map(str::to_string)
40}
41
42/// `reopened: SEAT from DONE`.
43#[must_use]
44pub fn reopen_note(seat: &str, from: &str) -> String {
45    format!("{REOPEN_NOTE}{} from {}", seat.trim(), from.trim())
46}
47
48/// `note-withdrawn: SEAT TEXT`.
49#[must_use]
50pub fn withdrawn_note(seat: &str, text: &str) -> String {
51    format!("{NOTE_WITHDRAWN}{} {}", seat.trim(), text.trim())
52}
53
54/// The seat and the text of a withdrawal note.
55#[must_use]
56pub fn parse_withdrawn_note(line: &str) -> Option<(String, String)> {
57    let rest = line.trim().strip_prefix(NOTE_WITHDRAWN)?;
58    let (seat, text) = rest.split_once(' ')?;
59    let seat = seat.trim();
60    let text = text.trim();
61    (!seat.is_empty() && !text.is_empty()).then(|| (seat.to_string(), text.to_string()))
62}
63
64/// The note prefixes ljos keeps for itself. A seat takes back what it
65/// wrote in its own words, not a signature or a quorum deadline, which
66/// their own verbs maintain.
67const MACHINERY: &[&str] = &[
68    NOTE_WITHDRAWN,
69    REOPEN_NOTE,
70    "ballot-sig: ",
71    "ballot-withdrawn: ",
72    "candidate-sig: ",
73    "quorum: ",
74    "quorum timed out: ",
75    "playbook: ",
76    "workflow: ",
77];
78
79/// Whether `note` is one of ljos's own bookkeeping lines.
80#[must_use]
81pub fn machinery(note: &str) -> bool {
82    let note = note.trim();
83    MACHINERY.iter().any(|prefix| note.starts_with(prefix))
84}
85
86/// The seat a logbook entry names as its writer, from the org line the
87/// tracker kept. `None` when the line carries no attribution.
88#[must_use]
89pub fn author_of(entry: &Value) -> Option<String> {
90    let raw = entry["raw"].as_str()?;
91    crate::parse_logbook_act(raw).map(|(_, seat)| seat)
92}
93
94/// Whether two names are the same seat, as the tracker spells it.
95#[must_use]
96pub fn one_seat(a: &str, b: &str) -> bool {
97    let a = crate::canonical_seat_name(a);
98    !a.is_empty() && a == crate::canonical_seat_name(b)
99}
100
101/// The one note on the logbook that `which` names, whole or in part.
102///
103/// # Errors
104///
105/// No note matches, or more than one does and the text does not say which.
106pub fn pick_note<'a>(logbook: &'a [Value], which: &str) -> Result<&'a Value> {
107    let which = which.trim();
108    if which.is_empty() {
109        bail!("withdraw: name the note to take back; nothing was written");
110    }
111    let notes: Vec<&Value> = logbook
112        .iter()
113        .filter(|e| {
114            e["note"]
115                .as_str()
116                .is_some_and(|note| !note.trim().is_empty() && !machinery(note))
117        })
118        .collect();
119    let text = |e: &Value| e["note"].as_str().unwrap_or_default().trim().to_string();
120    if let Some(exact) = notes.iter().find(|e| text(e) == which) {
121        return Ok(exact);
122    }
123    let hits: Vec<&&Value> = notes.iter().filter(|e| text(e).contains(which)).collect();
124    match hits.as_slice() {
125        [one] => Ok(one),
126        [] => bail!("withdraw: no note on this ticket reads {which:?}; nothing was written"),
127        many => bail!(
128            "withdraw: {} notes read {which:?}; name one of them whole; nothing was written",
129            many.len()
130        ),
131    }
132}
133
134/// Whether the logbook already took `text` back.
135#[must_use]
136pub fn already_withdrawn(logbook: &[Value], text: &str) -> bool {
137    logbook
138        .iter()
139        .filter_map(|e| e["note"].as_str())
140        .filter_map(parse_withdrawn_note)
141        .any(|(_, said)| said == text.trim())
142}
143
144/// Take back this seat's own note on `issue`, as a note beside it.
145///
146/// # Errors
147///
148/// The tracker has no such ticket, the text names no note or more than
149/// one, another seat wrote the note, or the tracker refused the write.
150pub fn withdraw_note(issue: &str, seat: &str, which: &str) -> Result<String> {
151    let issue = issue.trim();
152    if issue.is_empty() {
153        bail!("withdraw: an issue id is required; nothing was written");
154    }
155    let v = crate::issue_record(issue)?;
156    let logbook: Vec<Value> = v["logbook"].as_array().cloned().unwrap_or_default();
157    let entry = pick_note(&logbook, which)?;
158    let text = entry["note"]
159        .as_str()
160        .unwrap_or_default()
161        .trim()
162        .to_string();
163    if let Some(wrote) = author_of(entry) {
164        if !one_seat(seat, &wrote) {
165            bail!(
166                "withdraw: {wrote} wrote that note, and this seat is {seat}; nothing was written"
167            );
168        }
169    }
170    if already_withdrawn(&logbook, &text) {
171        return Ok(format!("{issue}: that note was taken back already\n"));
172    }
173    let note = withdrawn_note(seat, &text);
174    crate::run_captured("vissue", &["note", issue, &note])
175        .with_context(|| format!("withdraw: the note on {issue} was not written"))?;
176    let mut out = format!("{issue}: took back {text:?}\n");
177    out.push_str("the note stays on the logbook, with the withdrawal under it\n");
178    out.push_str(&crate::persist_tracker(issue, "note withdrawn"));
179    Ok(out)
180}
181
182/// Take back `who`'s ballot on `issue`, and the forecast it carried.
183/// A ballot already taken back still leaves its forecast to take back,
184/// so a second withdrawal finishes the first.
185///
186/// # Errors
187///
188/// The tracker refused the withdrawal, or the forecast was not withdrawn.
189pub fn withdraw_ballot(issue: &str, who: &str) -> Result<String> {
190    let standing = crate::panel_gate::standing_ballot_issue(issue, who);
191    // The ballot and the forecast are two writes. The first is said as
192    // soon as it lands, so a reader of a failure in the second still
193    // sees which half went through.
194    match crate::run_captured_as("vissue", &["vote", issue, "--withdraw"], Some(who)) {
195        Ok(said) => print!("{}", said.stdout),
196        Err(e) if format!("{e:#}").contains("holds no ballot") => {
197            println!("{issue}: {who} holds no ballot; withdrawing the forecast");
198        }
199        Err(e) => return Err(e),
200    }
201    if let Some(standing) = standing.as_deref() {
202        if let Err(e) = crate::ballot_sig::note_withdrawn_on(standing, who, issue) {
203            eprintln!("ljos withdraw: {e:#}");
204        }
205    }
206    let n = crate::withdraw_prediction(issue, who)
207        .with_context(|| format!("ballot withdrawn; the forecast for {who} was not"))?;
208    let mut out = format!("{n} forecast(s) withdrawn for {who}\n");
209    out.push_str(&crate::persist_tracker(issue, "ballot withdrawn"));
210    Ok(out)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde_json::json;
217
218    fn note(text: &str) -> Value {
219        json!({"timestamp": "[2026-10-20 Tue 09:00]", "note": text})
220    }
221
222    #[test]
223    fn a_finished_or_cancelled_ticket_is_closed() {
224        assert!(is_closed("DONE"));
225        assert!(is_closed(" CANCELLED "));
226        assert!(!is_closed("TODO"));
227        assert!(!is_closed("STARTED"));
228        assert!(!is_closed(""));
229    }
230
231    #[test]
232    fn a_reopen_goes_back_to_the_state_the_close_left() {
233        let v = json!({"logbook": [
234            {"to": "DONE", "from": "STARTED", "timestamp": "[2026-10-20 Tue 09:00]"},
235            {"to": "STARTED", "from": "TODO", "timestamp": "[2026-10-19 Mon 09:00]"},
236        ]});
237        assert_eq!(prior_state(&v, "DONE").as_deref(), Some("STARTED"));
238        assert_eq!(prior_state(&v, "CANCELLED"), None);
239        let hand = json!({"logbook": []});
240        assert_eq!(prior_state(&hand, "DONE"), None);
241        let looped = json!({"logbook": [{"to": "DONE", "from": "CANCELLED"}]});
242        assert_eq!(prior_state(&looped, "DONE"), None);
243    }
244
245    #[test]
246    fn an_undo_says_who_undid_it_and_what() {
247        assert_eq!(reopen_note("saddle", "DONE"), "reopened: saddle from DONE");
248        let line = withdrawn_note("saddle", "the parser keeps the hyphen");
249        assert_eq!(line, "note-withdrawn: saddle the parser keeps the hyphen");
250        let (seat, text) = parse_withdrawn_note(&line).unwrap();
251        assert_eq!(seat, "saddle");
252        assert_eq!(text, "the parser keeps the hyphen");
253        assert!(parse_withdrawn_note("note-withdrawn: saddle").is_none());
254        assert!(parse_withdrawn_note("ballot-sig: saddle ship").is_none());
255    }
256
257    #[test]
258    fn ljos_bookkeeping_is_not_a_note_a_seat_takes_back() {
259        assert!(machinery("ballot-sig: saddle ship a1"));
260        assert!(machinery("quorum: 3 by 2026-10-30T00:00:00Z"));
261        assert!(machinery("note-withdrawn: saddle a note"));
262        assert!(!machinery("the parser keeps the hyphen"));
263    }
264
265    #[test]
266    fn a_text_names_one_note_or_says_it_cannot() {
267        let logbook = vec![
268            note("the parser keeps the hyphen"),
269            note("the parser drops the dash"),
270            note("ballot-sig: saddle ship a1"),
271        ];
272        let picked = pick_note(&logbook, "keeps the hyphen").unwrap();
273        assert_eq!(picked["note"], "the parser keeps the hyphen");
274        let whole = pick_note(&logbook, "the parser drops the dash").unwrap();
275        assert_eq!(whole["note"], "the parser drops the dash");
276        let many = pick_note(&logbook, "the parser").unwrap_err().to_string();
277        assert!(many.contains("2 notes read"), "{many}");
278        let none = pick_note(&logbook, "the lexer").unwrap_err().to_string();
279        assert!(none.contains("no note on this ticket"), "{none}");
280        let sig = pick_note(&logbook, "ballot-sig").unwrap_err().to_string();
281        assert!(sig.contains("no note on this ticket"), "{sig}");
282    }
283
284    #[test]
285    fn a_note_already_taken_back_is_not_taken_back_twice() {
286        let logbook = vec![
287            note("note-withdrawn: saddle the parser keeps the hyphen"),
288            note("the parser keeps the hyphen"),
289        ];
290        assert!(already_withdrawn(&logbook, "the parser keeps the hyphen"));
291        assert!(!already_withdrawn(&logbook, "the parser drops the dash"));
292    }
293
294    #[test]
295    fn a_logbook_line_names_the_seat_that_wrote_it() {
296        let attributed = json!({
297            "note": "the parser keeps the hyphen",
298            "raw": "- Noted by saddle [2026-10-16 Fri 10:35]",
299        });
300        assert_eq!(author_of(&attributed).as_deref(), Some("saddle"));
301        assert_eq!(author_of(&note("the parser keeps the hyphen")), None);
302    }
303}