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