1use anyhow::{bail, Context, Result};
11use serde_json::Value;
12
13pub const REOPEN_NOTE: &str = "reopened: ";
15pub const NOTE_WITHDRAWN: &str = "note-withdrawn: ";
17pub const FRESH_STATE: &str = "TODO";
20
21#[must_use]
23pub fn is_closed(state: &str) -> bool {
24 matches!(state.trim(), "DONE" | "CANCELLED")
25}
26
27#[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#[must_use]
44pub fn reopen_note(seat: &str, from: &str) -> String {
45 format!("{REOPEN_NOTE}{} from {}", seat.trim(), from.trim())
46}
47
48#[must_use]
50pub fn withdrawn_note(seat: &str, text: &str) -> String {
51 format!("{NOTE_WITHDRAWN}{} {}", seat.trim(), text.trim())
52}
53
54#[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
64const 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#[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#[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#[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
102pub 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#[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
145pub 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, ¬e])
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
183pub fn withdraw_ballot(issue: &str, who: &str) -> Result<String> {
191 let standing = crate::panel_gate::standing_ballot_issue(issue, who);
192 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(¬e("the parser keeps the hyphen")), None);
303 }
304}