1use anyhow::{Context, Result};
19use serde::Deserialize;
20use serde_json::Value;
21use std::collections::{BTreeMap, BTreeSet};
22use std::path::PathBuf;
23
24pub const BELOW_CHILD: &[&str] = &["nit", "wording", "style"];
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Severity {
32 Nit,
33 Wording,
34 Style,
35 Blocking,
36}
37
38impl Severity {
39 #[must_use]
40 pub fn parse(word: &str) -> Option<Self> {
41 match word.trim().to_ascii_lowercase().as_str() {
42 "nit" => Some(Self::Nit),
43 "wording" => Some(Self::Wording),
44 "style" => Some(Self::Style),
45 "blocking" => Some(Self::Blocking),
46 _ => None,
47 }
48 }
49
50 #[must_use]
51 pub fn word(self) -> &'static str {
52 match self {
53 Self::Nit => "nit",
54 Self::Wording => "wording",
55 Self::Style => "style",
56 Self::Blocking => "blocking",
57 }
58 }
59
60 #[must_use]
62 pub fn is_note(self) -> bool {
63 !matches!(self, Self::Blocking)
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Objection {
70 pub persona: String,
71 pub severity: Severity,
72 pub reason: String,
75 pub text: String,
76 pub target: String,
77 pub held: bool,
80}
81
82impl Objection {
83 #[must_use]
84 pub fn nit(persona: &str, text: &str, target: &str) -> Self {
85 Self::noted(persona, Severity::Nit, "", text, target)
86 }
87
88 #[must_use]
89 pub fn wording(persona: &str, text: &str, target: &str) -> Self {
90 Self::noted(persona, Severity::Wording, "", text, target)
91 }
92
93 #[must_use]
94 pub fn style(persona: &str, text: &str, target: &str) -> Self {
95 Self::noted(persona, Severity::Style, "", text, target)
96 }
97
98 #[must_use]
99 pub fn blocking(persona: &str, reason: &str, text: &str, target: &str) -> Self {
100 Self::noted(persona, Severity::Blocking, reason, text, target)
101 }
102
103 fn noted(persona: &str, severity: Severity, reason: &str, text: &str, target: &str) -> Self {
104 let reason = reason.trim().to_string();
105 let severity = if severity == Severity::Blocking && reason.is_empty() {
106 Severity::Nit
107 } else {
108 severity
109 };
110 Self {
111 persona: persona.trim().to_string(),
112 severity,
113 reason,
114 text: text.trim().to_string(),
115 target: target.trim().to_string(),
116 held: false,
117 }
118 }
119
120 #[must_use]
122 pub fn files_as_child(&self) -> bool {
123 self.severity == Severity::Blocking && !self.reason.trim().is_empty()
124 }
125
126 #[must_use]
129 pub fn may_file(&self, passed: bool, depth: usize) -> bool {
130 if self.files_as_child() {
131 return true;
132 }
133 self.held && !passed && depth == 0
134 }
135
136 #[must_use]
139 pub fn key(&self) -> (String, String) {
140 (normalize(&self.text), normalize(&self.target))
141 }
142}
143
144#[must_use]
147pub fn normalize(text: &str) -> String {
148 text.chars()
149 .map(|c| {
150 if c.is_ascii_alphanumeric() {
151 c.to_ascii_lowercase()
152 } else {
153 ' '
154 }
155 })
156 .collect::<String>()
157 .split_whitespace()
158 .collect::<Vec<_>>()
159 .join(" ")
160}
161
162#[must_use]
167pub fn parse_objection(line: &str, default_target: &str) -> Option<Objection> {
168 let line = line.trim().trim_start_matches('-').trim();
169 let rest = line
170 .strip_prefix("finding:")
171 .or_else(|| line.strip_prefix("objection:"))
172 .map(str::trim)
173 .filter(|s| !s.is_empty())?;
174 let (persona, rest) = rest.split_once(':')?;
175 let persona = persona.trim();
176 if persona.is_empty() {
177 return None;
178 }
179 let rest = rest.trim();
180 let (severity, rest) = match rest.split_once(':') {
181 Some((word, after)) => match Severity::parse(word) {
182 Some(severity) => (severity, after.trim()),
183 None => (Severity::Nit, rest),
184 },
185 None => (Severity::Nit, rest),
186 };
187 let (reason, rest) = if severity == Severity::Blocking {
188 match rest.split_once("::") {
189 Some((reason, text)) => (reason.trim().to_string(), text.trim()),
190 None => (String::new(), rest),
191 }
192 } else {
193 (String::new(), rest)
194 };
195 let (text, target) = match rest.rsplit_once(" @ ") {
196 Some((text, target)) if !target.trim().is_empty() => (text.trim(), target.trim()),
197 _ => (rest, default_target),
198 };
199 if text.is_empty() {
200 return None;
201 }
202 Some(Objection::noted(persona, severity, &reason, text, target))
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ScopeBudget {
209 pub max_rounds: usize,
210 pub max_children: usize,
211 pub max_descendants: usize,
212 pub max_wall_secs: Option<u64>,
213 pub max_tokens: Option<u64>,
214}
215
216impl ScopeBudget {
217 #[must_use]
219 pub fn panel() -> Self {
220 Self {
221 max_rounds: 3,
222 max_children: 8,
223 max_descendants: 12,
224 max_wall_secs: None,
225 max_tokens: None,
226 }
227 }
228
229 #[must_use]
231 pub fn workflow() -> Self {
232 Self {
233 max_rounds: 3,
234 max_children: 24,
235 max_descendants: 48,
236 max_wall_secs: None,
237 max_tokens: None,
238 }
239 }
240
241 fn overlay(&mut self, file: &ScopeFile) {
242 if let Some(n) = file.max_rounds {
243 self.max_rounds = n;
244 }
245 if let Some(n) = file.max_children {
246 self.max_children = n;
247 }
248 if let Some(n) = file.max_descendants {
249 self.max_descendants = n;
250 }
251 if file.max_wall_secs.is_some() {
252 self.max_wall_secs = file.max_wall_secs;
253 }
254 if file.max_tokens.is_some() {
255 self.max_tokens = file.max_tokens;
256 }
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct PanelBudget {
264 pub panel: ScopeBudget,
265 pub workflow: ScopeBudget,
266}
267
268impl Default for PanelBudget {
269 fn default() -> Self {
270 Self {
271 panel: ScopeBudget::panel(),
272 workflow: ScopeBudget::workflow(),
273 }
274 }
275}
276
277#[derive(Debug, Clone, Deserialize, Default)]
278struct BudgetFile {
279 #[serde(default)]
280 panel: ScopeFile,
281 #[serde(default)]
282 workflow: ScopeFile,
283}
284
285#[derive(Debug, Clone, Deserialize, Default)]
286struct ScopeFile {
287 max_rounds: Option<usize>,
288 max_children: Option<usize>,
289 max_descendants: Option<usize>,
290 max_wall_secs: Option<u64>,
291 max_tokens: Option<u64>,
292}
293
294#[must_use]
297pub fn budget_from_toml(text: &str) -> PanelBudget {
298 let mut budget = PanelBudget::default();
299 let Ok(file) = toml::from_str::<BudgetFile>(text) else {
300 return budget;
301 };
302 budget.panel.overlay(&file.panel);
303 budget.workflow.overlay(&file.workflow);
304 budget
305}
306
307#[must_use]
311pub fn load_budget() -> PanelBudget {
312 let mut budget = PanelBudget::default();
313 for path in budget_paths() {
314 let Ok(text) = std::fs::read_to_string(&path) else {
315 continue;
316 };
317 let Ok(file) = toml::from_str::<BudgetFile>(&text) else {
318 continue;
319 };
320 budget.panel.overlay(&file.panel);
321 budget.workflow.overlay(&file.workflow);
322 }
323 budget
324}
325
326fn budget_paths() -> Vec<PathBuf> {
327 let mut paths = Vec::new();
328 if let Some(base) = config_base() {
329 paths.push(base.join("vissue").join("config.toml"));
330 paths.push(base.join("ljos").join("ljos.toml"));
331 }
332 paths.push(PathBuf::from("vissue.toml"));
333 paths.push(PathBuf::from("ljos.toml"));
334 if let Some(path) = std::env::var_os("VISSUE_CONFIG").filter(|p| !p.is_empty()) {
335 paths.push(PathBuf::from(path));
336 }
337 if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|p| !p.is_empty()) {
338 paths.push(PathBuf::from(path));
339 }
340 paths
341}
342
343fn config_base() -> Option<PathBuf> {
344 std::env::var_os("XDG_CONFIG_HOME")
345 .map(PathBuf::from)
346 .filter(|p| p.is_absolute())
347 .or_else(|| {
348 std::env::var_os("HOME")
349 .map(|h| PathBuf::from(h).join(".config"))
350 .filter(|p| p.is_absolute())
351 })
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct PanelState {
357 pub round: usize,
358 pub passed: bool,
359 pub dissent_depth: usize,
361 pub children_filed: usize,
362 pub open_children: usize,
363 pub awaiting_ballots: bool,
366 pub seen: Vec<(String, String)>,
367 pub wall_secs: Option<u64>,
368 pub tokens: Option<u64>,
369 pub objections: Vec<Objection>,
370}
371
372impl PanelState {
373 #[must_use]
374 pub fn at_round(round: usize) -> Self {
375 Self {
376 round,
377 passed: false,
378 dissent_depth: 0,
379 children_filed: 0,
380 open_children: 0,
381 awaiting_ballots: false,
382 seen: Vec::new(),
383 wall_secs: None,
384 tokens: None,
385 objections: Vec::new(),
386 }
387 }
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
392pub enum Fate {
393 Note(Objection),
394 File(Objection),
395 Duplicate(Objection),
396 Listed(Objection),
398}
399
400#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct Disposal {
403 pub fates: Vec<Fate>,
404 pub close: bool,
405 pub summary: String,
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
411pub enum RoundPlan {
412 OpenRound { n: usize },
413 FileBlocking { objections: Vec<Objection> },
414 Settle { summary: String },
415 Hold { why: String },
416 Passed,
417}
418
419fn budget_why(state: &PanelState, limits: &ScopeBudget) -> Option<String> {
420 if state.round > 0 && state.round >= limits.max_rounds {
421 return Some(format!(
422 "round {} reached the limit of {}",
423 state.round, limits.max_rounds
424 ));
425 }
426 if state.children_filed >= limits.max_children {
427 return Some(format!(
428 "{} children filed, at the limit of {}",
429 state.children_filed, limits.max_children
430 ));
431 }
432 if let (Some(used), Some(max)) = (state.wall_secs, limits.max_wall_secs) {
433 if used >= max {
434 return Some(format!("wall time {used}s reached the limit of {max}s"));
435 }
436 }
437 if let (Some(used), Some(max)) = (state.tokens, limits.max_tokens) {
438 if used >= max {
439 return Some(format!("token estimate {used} reached the limit of {max}"));
440 }
441 }
442 None
443}
444
445fn fresh(state: &PanelState) -> Vec<Objection> {
446 let mut batch = state.seen.iter().cloned().collect::<BTreeSet<_>>();
447 let mut out = Vec::new();
448 for objection in &state.objections {
449 let key = objection.key();
450 if batch.contains(&key) {
451 continue;
452 }
453 batch.insert(key);
454 out.push(objection.clone());
455 }
456 out
457}
458
459fn summary_of(why: &str, listed: &[Objection]) -> String {
460 let mut out = format!("settled-with-notes: {why}");
461 if listed.is_empty() {
462 return out;
463 }
464 out.push_str(". remaining:");
465 for (i, objection) in listed.iter().take(12).enumerate() {
466 out.push_str(&format!(
467 " ({}) {} {}: {}",
468 i + 1,
469 objection.persona,
470 objection.severity.word(),
471 clip(&objection.text, 80)
472 ));
473 }
474 if listed.len() > 12 {
475 out.push_str(&format!(" and {} more", listed.len() - 12));
476 }
477 out
478}
479
480fn clip(text: &str, n: usize) -> String {
481 let mut out: String = text.chars().take(n).collect();
482 if text.chars().count() > n {
483 out.push('…');
484 }
485 out
486}
487
488#[must_use]
491pub fn dispose(state: &PanelState, limits: &ScopeBudget) -> Disposal {
492 let mut fates = Vec::new();
493 let mut batch = state.seen.iter().cloned().collect::<BTreeSet<_>>();
494 let mut fileable = Vec::new();
495 for objection in &state.objections {
496 let key = objection.key();
497 if batch.contains(&key) {
498 fates.push(Fate::Duplicate(objection.clone()));
499 continue;
500 }
501 batch.insert(key);
502 if objection.may_file(state.passed, state.dissent_depth) {
503 fileable.push(objection.clone());
504 } else {
505 fates.push(Fate::Note(objection.clone()));
506 }
507 }
508 if let Some(why) = budget_why(state, limits) {
509 for objection in &fileable {
510 fates.push(Fate::Listed(objection.clone()));
511 }
512 return Disposal {
513 fates,
514 close: true,
515 summary: summary_of(&why, &fileable),
516 };
517 }
518 let room = limits.max_children.saturating_sub(state.children_filed);
519 let mut listed = Vec::new();
520 for (i, objection) in fileable.into_iter().enumerate() {
521 if i < room {
522 fates.push(Fate::File(objection));
523 } else {
524 listed.push(objection);
525 }
526 }
527 let close = !listed.is_empty();
528 let summary = if close {
529 summary_of(
530 &format!(
531 "{} children filed, at the limit of {}",
532 state.children_filed + room,
533 limits.max_children
534 ),
535 &listed,
536 )
537 } else {
538 String::new()
539 };
540 for objection in listed {
541 fates.push(Fate::Listed(objection));
542 }
543 Disposal {
544 fates,
545 close,
546 summary,
547 }
548}
549
550#[must_use]
557pub fn plan_round(state: &PanelState, limits: &ScopeBudget) -> RoundPlan {
558 if state.open_children > 0 {
559 return RoundPlan::Hold {
560 why: format!(
561 "{} {} still open",
562 state.open_children,
563 if state.open_children == 1 {
564 "child is"
565 } else {
566 "children are"
567 }
568 ),
569 };
570 }
571 if state.awaiting_ballots {
572 return RoundPlan::Hold {
573 why: "the panel has not all voted".into(),
574 };
575 }
576 let mut fileable = Vec::new();
577 let mut seen = state.seen.iter().cloned().collect::<BTreeSet<_>>();
578 let mut addressed = 0;
579 for objection in &state.objections {
580 let key = objection.key();
581 if seen.contains(&key) {
582 if objection.files_as_child() {
583 addressed += 1;
584 }
585 continue;
586 }
587 seen.insert(key);
588 if objection.may_file(state.passed, state.dissent_depth) {
589 fileable.push(objection.clone());
590 }
591 }
592 if let Some(why) = budget_why(state, limits) {
593 if state.passed && fileable.is_empty() {
594 return RoundPlan::Passed;
595 }
596 return RoundPlan::Settle {
597 summary: summary_of(&why, &fresh(state)),
598 };
599 }
600 if !fileable.is_empty() {
601 let room = limits.max_children.saturating_sub(state.children_filed);
602 if room == 0 {
603 return RoundPlan::Settle {
604 summary: summary_of(
605 &format!(
606 "{} children filed, at the limit of {}",
607 state.children_filed, limits.max_children
608 ),
609 &fileable,
610 ),
611 };
612 }
613 fileable.truncate(room);
614 return RoundPlan::FileBlocking {
615 objections: fileable,
616 };
617 }
618 if state.passed {
619 return RoundPlan::Passed;
620 }
621 if state.round == 0 {
622 return RoundPlan::OpenRound { n: 1 };
623 }
624 if addressed > 0 && state.round < limits.max_rounds {
625 return RoundPlan::OpenRound { n: state.round + 1 };
626 }
627 let listed = fresh(state);
628 let why = if listed.is_empty() {
629 "no blocking objection remains".to_string()
630 } else {
631 "the objections are below blocking".to_string()
632 };
633 RoundPlan::Settle {
634 summary: summary_of(&why, &listed),
635 }
636}
637
638#[must_use]
641pub fn substance_passed(choices: &[(String, String)]) -> bool {
642 if choices.is_empty() {
643 return false;
644 }
645 let norm = |s: &str| s.trim().to_ascii_lowercase();
646 let pass = choices.iter().filter(|(_, c)| norm(c) == "pass").count();
647 let revise = choices.iter().filter(|(_, c)| norm(c) == "revise").count();
648 if pass + revise == choices.len() {
649 return pass > revise && pass * 2 > choices.len();
650 }
651 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
652 for (_, choice) in choices {
653 *counts.entry(normalize(choice)).or_default() += 1;
654 }
655 let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
656 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
657 ranked.len() == 1 || (ranked[0].1 > ranked[1].1 && ranked[0].1 * 2 > choices.len())
658}
659
660fn ballot_tie(choices: &[(String, String)]) -> bool {
663 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
664 for (_, choice) in choices {
665 *counts.entry(normalize(choice)).or_default() += 1;
666 }
667 let Some(max) = counts.values().copied().max() else {
668 return false;
669 };
670 counts.values().filter(|n| **n == max).count() > 1
671}
672
673#[must_use]
675pub fn objections_in(v: &Value, default_target: &str) -> Vec<Objection> {
676 let mut out = Vec::new();
677 let mut push = |text: &str| {
678 if let Some(objection) = parse_objection(text, default_target) {
679 out.push(objection);
680 }
681 };
682 for entry in v["logbook"].as_array().into_iter().flatten() {
683 if let Some(note) = entry["note"].as_str() {
684 push(note);
685 }
686 if let Some(raw) = entry["raw"].as_str() {
687 push(raw);
688 }
689 }
690 if let Some(body) = v["body"].as_str() {
691 for line in body.lines() {
692 push(line);
693 }
694 }
695 out
696}
697
698#[must_use]
701pub fn dissent_depth(v: &Value) -> usize {
702 for entry in v["logbook"].as_array().into_iter().flatten() {
703 let Some(note) = entry["note"].as_str() else {
704 continue;
705 };
706 if let Some(rest) = note.trim().strip_prefix("dissent-depth:") {
707 if let Ok(n) = rest.trim().parse::<usize>() {
708 return n;
709 }
710 }
711 }
712 let title = v["title"].as_str().unwrap_or("");
713 if title.trim_start().starts_with("Dissent holds") {
714 1
715 } else {
716 0
717 }
718}
719
720#[must_use]
722pub fn filed_key(title: &str, target: &str) -> Option<(String, String)> {
723 let title = title.trim();
724 let text = title
725 .strip_prefix("Dissent holds ")
726 .or_else(|| title.strip_prefix("fix: "))
727 .or_else(|| title.strip_prefix("fix:"))?;
728 let text = text.trim();
729 if text.is_empty() {
730 return None;
731 }
732 Some((normalize(text), normalize(target)))
733}
734
735fn project_of(v: &Value, id: &str) -> String {
736 v["project"]
737 .as_str()
738 .filter(|s| !s.trim().is_empty())
739 .map(str::to_string)
740 .or_else(|| id.rsplit_once('-').map(|(p, _)| p.to_string()))
741 .unwrap_or_default()
742}
743
744fn minority_options(choices: &[(String, String)]) -> Vec<String> {
748 let mut counts: BTreeMap<String, (String, usize)> = BTreeMap::new();
749 for (_, choice) in choices {
750 let shown = choice.trim();
751 let key = normalize(shown);
752 if key.is_empty() {
753 continue;
754 }
755 let row = counts.entry(key).or_insert_with(|| (shown.to_string(), 0));
756 row.1 += 1;
757 }
758 let Some(max) = counts.values().map(|(_, n)| *n).max() else {
759 return Vec::new();
760 };
761 if counts.values().filter(|(_, n)| *n == max).count() != 1 {
762 return Vec::new();
763 }
764 let mut out: Vec<String> = counts
765 .into_values()
766 .filter(|(_, n)| *n < max)
767 .map(|(shown, _)| shown)
768 .collect();
769 out.sort();
770 out
771}
772
773fn file_settled_minority(
776 issue: &str,
777 project: &str,
778 choices: &[(String, String)],
779 children: &[String],
780 depth: usize,
781) -> Vec<String> {
782 let mut lines = Vec::new();
783 for option in minority_options(choices) {
784 let title = format!("Dissent holds {option}");
785 if children.iter().any(|t| normalize(t) == normalize(&title)) {
786 lines.push(format!("{issue}: dissent {option} already filed"));
787 continue;
788 }
789 match file_child(issue, project, &title, depth) {
790 Ok(id) => lines.push(format!("filed {id} TODO [#C] {title}")),
791 Err(e) => lines.push(format!("dissent: file refused: {e:#}")),
792 }
793 }
794 lines
795}
796
797pub const ROUND_NOTE: &str = "panel-round:";
799
800#[must_use]
802pub fn recorded_round(v: &Value) -> usize {
803 v["logbook"]
804 .as_array()
805 .into_iter()
806 .flatten()
807 .filter_map(|e| e["note"].as_str())
808 .filter_map(|n| {
809 n.trim()
810 .strip_prefix(ROUND_NOTE)?
811 .trim()
812 .parse::<usize>()
813 .ok()
814 })
815 .max()
816 .unwrap_or(0)
817}
818
819fn is_closed(v: &Value) -> bool {
820 matches!(v["state"].as_str(), Some("DONE" | "CANCELLED"))
821}
822
823#[must_use]
828pub fn sitting_round(v: &Value) -> (usize, bool) {
829 if let Some(n) = crate::panel_round_number(v["title"].as_str().unwrap_or("")) {
830 return (n, false);
831 }
832 let last = recorded_round(v);
833 if is_closed(v) {
834 return (last, false);
835 }
836 (last + 1, true)
837}
838
839#[must_use]
847pub fn record_dissents(issue: &str) -> Vec<String> {
848 let Ok(v) = crate::issue_record(issue) else {
849 return Vec::new();
850 };
851 let closed = is_closed(&v);
852 let (round, note_round) = sitting_round(&v);
853 let mut round_lines = Vec::new();
854 if note_round {
855 if let Err(e) = note_issue(issue, &format!("{ROUND_NOTE} {round}")) {
856 round_lines.push(format!("dissent: round note refused: {e:#}"));
857 }
858 }
859 let choices: Vec<(String, String)> = crate::ballot_rows(issue)
860 .into_iter()
861 .filter_map(|row| {
862 let agent = row.get("agent")?.as_str()?.to_string();
863 let choice = row.get("choice")?.as_str()?.to_string();
864 Some((agent, choice))
865 })
866 .collect();
867 let passed = substance_passed(&choices);
868 let tied = ballot_tie(&choices);
871 let mut objections = objections_in(&v, issue);
872 let noted: BTreeSet<(String, String)> = objections.iter().map(Objection::key).collect();
873 if !passed && !tied {
874 for (persona, choice) in &choices {
875 if normalize(choice) == "pass" {
876 continue;
877 }
878 let mut held = Objection::nit(persona, &format!("holds {choice}"), issue);
879 held.held = true;
880 if !noted.contains(&held.key())
881 && !objections
882 .iter()
883 .any(|o| o.persona == *persona && o.files_as_child())
884 {
885 objections.push(held);
886 }
887 }
888 }
889 let children = child_titles(issue);
890 let mut seen = Vec::new();
891 let mut children_filed = 0;
892 for title in &children {
893 if let Some(key) = filed_key(title, issue) {
894 seen.push(key);
895 children_filed += 1;
896 }
897 }
898 let state = PanelState {
899 round,
900 passed,
901 dissent_depth: dissent_depth(&v),
902 children_filed,
903 open_children: 0,
904 awaiting_ballots: false,
905 seen,
906 wall_secs: None,
907 tokens: None,
908 objections,
909 };
910 let limits = load_budget().panel;
911 let disposal = dispose(&state, &limits);
912 let mut lines = round_lines;
913 let project = project_of(&v, issue);
914 let depth = state.dissent_depth;
915 for fate in disposal.fates {
916 match fate {
917 Fate::Duplicate(_) => {}
918 Fate::Note(objection) if noted.contains(&objection.key()) => {}
919 Fate::Note(objection) => match note_issue(issue, &format_note(&objection)) {
920 Ok(()) => lines.push(format!(
921 "noted {} from {} on {}: {}",
922 objection.severity.word(),
923 objection.persona,
924 objection.target,
925 objection.text
926 )),
927 Err(e) => lines.push(format!("dissent: note refused: {e:#}")),
928 },
929 Fate::File(objection) => {
930 let title = dissent_title(&objection);
931 match file_child(issue, &project, &title, depth + 1) {
932 Ok(id) => lines.push(format!("filed {id} TODO [#C] {title}")),
933 Err(e) => lines.push(format!("dissent: file refused: {e:#}")),
934 }
935 }
936 Fate::Listed(objection) => lines.push(format!(
937 "listed {} from {}: {}",
938 objection.severity.word(),
939 objection.persona,
940 objection.text
941 )),
942 }
943 }
944 if disposal.close && !closed {
945 match close_settled(issue, &disposal.summary) {
946 Ok(said) => lines.push(said.trim().to_string()),
947 Err(e) => lines.push(format!("dissent: close refused: {e:#}")),
948 }
949 }
950 if passed {
954 lines.extend(file_settled_minority(
955 issue,
956 &project,
957 &choices,
958 &children,
959 depth + 1,
960 ));
961 }
962 lines
963}
964
965fn format_note(objection: &Objection) -> String {
966 if objection.files_as_child() {
967 format!(
968 "finding: {}: blocking: {} :: {} @ {}",
969 objection.persona, objection.reason, objection.text, objection.target
970 )
971 } else {
972 format!(
973 "finding: {}: {}: {} @ {}",
974 objection.persona,
975 objection.severity.word(),
976 objection.text,
977 objection.target
978 )
979 }
980}
981
982fn dissent_title(objection: &Objection) -> String {
983 let text = clip(&objection.text, 120);
984 format!("Dissent holds {text}")
985}
986
987fn child_titles(issue: &str) -> Vec<String> {
988 let Ok(said) = crate::run_captured("vissue", &["children", issue, "--json"]) else {
989 return Vec::new();
990 };
991 let Ok(rows) = serde_json::from_str::<Vec<Value>>(&said.stdout) else {
992 return Vec::new();
993 };
994 rows.into_iter()
995 .filter_map(|row| {
996 let title = row["title"].as_str().map(str::to_string);
997 if title.is_some() {
998 return title;
999 }
1000 let id = row["id"].as_str()?;
1001 crate::issue_record(id)
1002 .ok()
1003 .and_then(|v| v["title"].as_str().map(str::to_string))
1004 })
1005 .collect()
1006}
1007
1008fn file_child(parent: &str, project: &str, title: &str, depth: usize) -> Result<String> {
1009 let mut args = vec!["q".to_string()];
1010 if !project.is_empty() {
1011 args.push("-p".into());
1012 args.push(project.to_string());
1013 }
1014 args.extend([
1015 "--parent".into(),
1016 parent.to_string(),
1017 "-t".into(),
1018 "task".into(),
1019 title.to_string(),
1020 ]);
1021 let said = crate::run_captured("vissue", &args)?;
1022 let id = said.stdout.trim().to_string();
1023 if id.is_empty() {
1024 anyhow::bail!("vissue q printed no id");
1025 }
1026 let _ = crate::run_captured("vissue", &["note", &id, &format!("dissent-depth: {depth}")]);
1027 Ok(id)
1028}
1029
1030fn note_issue(id: &str, text: &str) -> Result<()> {
1031 crate::run_captured("vissue", &["note", id, text])?;
1032 Ok(())
1033}
1034
1035pub fn close_settled(id: &str, summary: &str) -> Result<String> {
1037 note_issue(id, summary).with_context(|| format!("note {id}"))?;
1038 crate::run_captured("vissue", &["update", id, "-s", "DONE"])
1039 .with_context(|| format!("close {id}"))?;
1040 let mut out = format!("{id} closed as {summary}\n");
1041 out.push_str(&crate::persist_tracker(id, "settled with notes"));
1042 Ok(out)
1043}
1044
1045pub fn file_fix(parent: &str, project: &str, objection: &Objection) -> Result<String> {
1050 let title = format!("fix: {}", clip(&objection.text, 120));
1051 let id = crate::workflow_child(parent, project, "task", crate::Stage::Implement, &title)?;
1052 let _ = note_issue(
1053 &id,
1054 &format!(
1055 "finding: {}: blocking: {} :: {} @ {}",
1056 objection.persona, objection.reason, objection.text, parent
1057 ),
1058 );
1059 Ok(id)
1060}
1061
1062#[derive(Debug, Clone, PartialEq, Eq)]
1064struct Node {
1065 id: String,
1066 parent: Option<String>,
1067 title: String,
1068 open: bool,
1069 blocking: usize,
1070}
1071
1072#[must_use]
1077pub fn reap_runaways(apply: bool) -> String {
1078 let Ok(said) = crate::run_captured("vissue", &["export"]) else {
1079 return String::new();
1080 };
1081 let budget = load_budget();
1082 let mut out = String::new();
1083 for (id, why) in flag_runaways(&said.stdout, &budget) {
1084 out.push_str(&format!("runaway: {id} {why}\n"));
1085 if !apply {
1086 continue;
1087 }
1088 let summary = format!("settled-with-notes: runaway, {why}");
1089 match close_settled(&id, &summary) {
1090 Ok(said) => out.push_str(&said),
1091 Err(e) => out.push_str(&format!("reap: {id}: close refused: {e:#}\n")),
1092 }
1093 }
1094 out
1095}
1096
1097#[must_use]
1099pub fn flag_runaways(export: &str, budget: &PanelBudget) -> Vec<(String, String)> {
1100 let nodes = nodes_of(export);
1101 let children = children_of(&nodes);
1102 let mut desc_memo: BTreeMap<String, usize> = BTreeMap::new();
1103 let mut round_memo: BTreeMap<String, usize> = BTreeMap::new();
1104 let mut block_memo: BTreeMap<String, usize> = BTreeMap::new();
1105 let mut flags = Vec::new();
1106 for node in &nodes {
1107 if !node.open {
1108 continue;
1109 }
1110 let limits = if is_workflow(&nodes, &node.id) {
1111 &budget.workflow
1112 } else {
1113 &budget.panel
1114 };
1115 let desc = descendants(&node.id, &children, &mut desc_memo, &mut BTreeSet::new());
1116 let parent_over = node.parent.as_ref().is_some_and(|p| {
1117 let parent_limits = if is_workflow(&nodes, p) {
1118 &budget.workflow
1119 } else {
1120 &budget.panel
1121 };
1122 descendants(p, &children, &mut desc_memo, &mut BTreeSet::new())
1123 > parent_limits.max_descendants
1124 });
1125 if desc > limits.max_descendants && !parent_over {
1126 flags.push((
1127 node.id.clone(),
1128 format!(
1129 "has {desc} descendants, over the limit of {}",
1130 limits.max_descendants
1131 ),
1132 ));
1133 }
1134 let round = max_round(
1135 &node.id,
1136 &nodes,
1137 &children,
1138 &mut round_memo,
1139 &mut BTreeSet::new(),
1140 );
1141 let blocking = blocking_under(
1142 &node.id,
1143 &nodes,
1144 &children,
1145 &mut block_memo,
1146 &mut BTreeSet::new(),
1147 );
1148 let grew = round >= 2 && round > blocking + 1;
1149 let parent_grew = node.parent.as_ref().is_some_and(|p| {
1150 let parent_round =
1151 max_round(p, &nodes, &children, &mut round_memo, &mut BTreeSet::new());
1152 let parent_blocking =
1153 blocking_under(p, &nodes, &children, &mut block_memo, &mut BTreeSet::new());
1154 parent_round >= round && parent_round >= 2 && parent_round > parent_blocking + 1
1155 });
1156 if grew && !parent_grew {
1157 flags.push((
1158 node.id.clone(),
1159 format!(
1160 "reached round {round} with {blocking} blocking {}; rounds grew without a new blocking objection",
1161 if blocking == 1 { "objection" } else { "objections" }
1162 ),
1163 ));
1164 }
1165 }
1166 flags
1167}
1168
1169fn is_workflow(nodes: &[Node], id: &str) -> bool {
1170 nodes.iter().any(|n| {
1171 n.id == id
1172 && (n.title.contains("workflow")
1173 || n.title.starts_with("design:")
1174 || n.title.starts_with("panel r"))
1175 }) || nodes
1176 .iter()
1177 .any(|n| n.parent.as_deref() == Some(id) && crate::panel_round_number(&n.title).is_some())
1178}
1179
1180fn nodes_of(export: &str) -> Vec<Node> {
1181 let mut nodes = Vec::new();
1182 for line in export.lines() {
1183 let Ok(v) = serde_json::from_str::<Value>(line) else {
1184 continue;
1185 };
1186 let Some(id) = v["id"].as_str().filter(|s| !s.is_empty()) else {
1187 continue;
1188 };
1189 let state = v["state"].as_str().unwrap_or("");
1190 let open = !matches!(state, "DONE" | "CANCELLED");
1191 let blocking = objections_in(&v, id)
1192 .iter()
1193 .filter(|o| o.files_as_child())
1194 .count();
1195 nodes.push(Node {
1196 id: id.to_string(),
1197 parent: v["parent"]
1198 .as_str()
1199 .filter(|s| !s.is_empty())
1200 .map(str::to_string),
1201 title: v["title"].as_str().unwrap_or("").to_string(),
1202 open,
1203 blocking,
1204 });
1205 }
1206 nodes
1207}
1208
1209fn children_of(nodes: &[Node]) -> BTreeMap<String, Vec<String>> {
1210 let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
1211 for node in nodes {
1212 if let Some(parent) = &node.parent {
1213 map.entry(parent.clone()).or_default().push(node.id.clone());
1214 }
1215 }
1216 map
1217}
1218
1219fn descendants(
1220 id: &str,
1221 children: &BTreeMap<String, Vec<String>>,
1222 memo: &mut BTreeMap<String, usize>,
1223 stack: &mut BTreeSet<String>,
1224) -> usize {
1225 if let Some(n) = memo.get(id) {
1226 return *n;
1227 }
1228 if !stack.insert(id.to_string()) {
1229 return 0;
1230 }
1231 let n = children.get(id).map_or(0, |kids| {
1232 kids.iter()
1233 .map(|kid| 1 + descendants(kid, children, memo, stack))
1234 .sum()
1235 });
1236 stack.remove(id);
1237 memo.insert(id.to_string(), n);
1238 n
1239}
1240
1241fn max_round(
1242 id: &str,
1243 nodes: &[Node],
1244 children: &BTreeMap<String, Vec<String>>,
1245 memo: &mut BTreeMap<String, usize>,
1246 stack: &mut BTreeSet<String>,
1247) -> usize {
1248 if let Some(n) = memo.get(id) {
1249 return *n;
1250 }
1251 if !stack.insert(id.to_string()) {
1252 return 0;
1253 }
1254 let own = nodes
1255 .iter()
1256 .find(|n| n.id == id)
1257 .map(|n| crate::panel_round_number(&n.title).unwrap_or(0))
1258 .unwrap_or(0);
1259 let below = children.get(id).map_or(0, |kids| {
1260 kids.iter()
1261 .map(|kid| max_round(kid, nodes, children, memo, stack))
1262 .max()
1263 .unwrap_or(0)
1264 });
1265 stack.remove(id);
1266 let n = own.max(below);
1267 memo.insert(id.to_string(), n);
1268 n
1269}
1270
1271fn blocking_under(
1272 id: &str,
1273 nodes: &[Node],
1274 children: &BTreeMap<String, Vec<String>>,
1275 memo: &mut BTreeMap<String, usize>,
1276 stack: &mut BTreeSet<String>,
1277) -> usize {
1278 if let Some(n) = memo.get(id) {
1279 return *n;
1280 }
1281 if !stack.insert(id.to_string()) {
1282 return 0;
1283 }
1284 let own = nodes
1285 .iter()
1286 .find(|n| n.id == id)
1287 .map(|n| n.blocking)
1288 .unwrap_or(0);
1289 let below: usize = children.get(id).map_or(0, |kids| {
1290 kids.iter()
1291 .map(|kid| blocking_under(kid, nodes, children, memo, stack))
1292 .sum()
1293 });
1294 stack.remove(id);
1295 let n = own + below;
1296 memo.insert(id.to_string(), n);
1297 n
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302 use super::*;
1303
1304 fn nit(n: usize) -> Objection {
1305 Objection::nit("ceo", &format!("comma in note {n}"), "anneal-z64x")
1306 }
1307
1308 #[test]
1309 fn the_round_comes_from_the_tracker_not_only_the_title() {
1310 let mut v = serde_json::json!({
1313 "id": "panel-a7o4",
1314 "title": "log-sink: where the log goes",
1315 "state": "TODO",
1316 "logbook": [],
1317 });
1318 assert_eq!(sitting_round(&v), (1, true));
1319 v["logbook"] = serde_json::json!([
1320 {"note": "panel-round: 2"},
1321 {"note": "finding: keep: blocking: stderr is what ops reads :: keep stderr"},
1322 {"note": "panel-round: 1"},
1323 ]);
1324 assert_eq!(recorded_round(&v), 2);
1325 assert_eq!(sitting_round(&v), (3, true));
1326 assert!(objections_in(&v, "panel-a7o4")
1327 .iter()
1328 .all(|o| !o.text.contains("panel-round")));
1329 let mut state = PanelState::at_round(sitting_round(&v).0);
1330 state.passed = true;
1331 let disposal = dispose(&state, &ScopeBudget::panel());
1332 assert!(disposal.close, "{disposal:?}");
1333 assert_eq!(
1334 disposal.summary,
1335 "settled-with-notes: round 3 reached the limit of 3"
1336 );
1337 v["state"] = serde_json::json!("DONE");
1339 assert_eq!(sitting_round(&v), (2, false));
1340 let titled = serde_json::json!({
1342 "id": "panel-hrd5",
1343 "title": "panel r3: cache place",
1344 "state": "TODO",
1345 "logbook": [{"note": "panel-round: 7"}],
1346 });
1347 assert_eq!(sitting_round(&titled), (3, false));
1348 }
1349
1350 #[test]
1351 fn a_blocking_mark_without_a_reason_is_a_nit() {
1352 let line = "finding: tester: blocking: the comma is wrong";
1353 let got = parse_objection(line, "anneal-z64x").unwrap();
1354 assert_eq!(got.severity, Severity::Nit);
1355 assert!(!got.files_as_child());
1356 let marked = parse_objection(
1357 "finding: tester: blocking: the exit status is dropped :: the gate ignores a failure @ anneal-z64x",
1358 "other",
1359 )
1360 .unwrap();
1361 assert!(marked.files_as_child());
1362 assert_eq!(marked.reason, "the exit status is dropped");
1363 assert_eq!(marked.text, "the gate ignores a failure");
1364 assert_eq!(marked.target, "anneal-z64x");
1365 let plain = parse_objection("finding: cto: heading tone", "anneal-z64x").unwrap();
1366 assert_eq!(plain.severity, Severity::Nit);
1367 assert_eq!(plain.text, "heading tone");
1368 let wording =
1369 parse_objection("finding: cto: wording: heading tone", "anneal-z64x").unwrap();
1370 assert_eq!(wording.severity, Severity::Wording);
1371 assert!(parse_objection("not a finding", "x").is_none());
1372 }
1373
1374 #[test]
1375 fn the_same_text_and_target_are_one_objection() {
1376 let a = Objection::nit("ceo", "Heading, tone!", "anneal-z64x");
1377 let b = Objection::wording("cto", "heading tone", "Anneal-z64x");
1378 assert_eq!(a.key(), b.key());
1379 let other = Objection::nit("ceo", "heading tone", "anneal-other");
1380 assert_ne!(a.key(), other.key());
1381 }
1382
1383 #[test]
1384 fn an_endless_stream_of_nits_after_a_pass_files_nothing() {
1385 let limits = ScopeBudget::panel();
1386 let mut state = PanelState::at_round(1);
1387 state.passed = true;
1388 let mut filed = 0;
1389 let mut opened = 0;
1390 for n in 0..10 {
1391 state.objections.push(nit(n));
1392 state
1393 .objections
1394 .push(Objection::wording("cto", "heading tone", "anneal-z64x"));
1395 state.objections.push(Objection::style(
1396 "junior-dev",
1397 "whitespace in the comment",
1398 "anneal-z64x",
1399 ));
1400 match plan_round(&state, &limits) {
1401 RoundPlan::FileBlocking { objections } => filed += objections.len(),
1402 RoundPlan::OpenRound { n } => {
1403 opened += 1;
1404 state.round = n;
1405 }
1406 RoundPlan::Settle { summary } => {
1407 panic!("a pass does not need a close to stop: {summary}");
1408 }
1409 RoundPlan::Passed => {}
1410 RoundPlan::Hold { why } => panic!("a pass does not hold: {why}"),
1411 }
1412 }
1413 assert_eq!(filed, 0, "nits after a pass are notes");
1414 assert_eq!(opened, 0, "a pass does not open another round");
1415 assert!(state.round <= limits.max_rounds);
1416
1417 let mut state = PanelState::at_round(1);
1420 let mut filed = 0;
1421 let mut opened = 0;
1422 let mut settled = 0;
1423 let mut last = String::new();
1424 for n in 0..10 {
1425 state.objections.push(nit(n));
1426 state
1427 .objections
1428 .push(Objection::wording("cto", "heading tone", "anneal-z64x"));
1429 match plan_round(&state, &limits) {
1430 RoundPlan::FileBlocking { objections } => filed += objections.len(),
1431 RoundPlan::OpenRound { n } => {
1432 opened += 1;
1433 state.round = n;
1434 }
1435 RoundPlan::Settle { summary } => {
1436 settled += 1;
1437 last = summary;
1438 }
1439 RoundPlan::Passed | RoundPlan::Hold { .. } => {}
1440 }
1441 }
1442 assert_eq!(filed, 0);
1443 assert_eq!(opened, 0);
1444 assert!(settled >= 1);
1445 assert!(state.round <= limits.max_rounds);
1446 assert!(last.contains("settled-with-notes"), "{last}");
1447 assert!(last.contains("comma in note 0"), "{last}");
1448 assert!(last.contains("heading tone"), "{last}");
1449 assert_eq!(last.matches("heading tone").count(), 1, "{last}");
1451 }
1452
1453 #[test]
1454 fn a_blocking_dissent_files_a_child_and_a_nit_does_not_file_a_grandchild() {
1455 let limits = ScopeBudget::panel();
1456 let mut state = PanelState::at_round(1);
1457 state.passed = true;
1458 state.objections.push(Objection::blocking(
1459 "tester",
1460 "the gate drops a real failure",
1461 "the exit status is ignored",
1462 "anneal-z64x",
1463 ));
1464 state
1465 .objections
1466 .push(Objection::nit("ceo", "comma in note 0", "anneal-z64x"));
1467 let plan = plan_round(&state, &limits);
1468 let RoundPlan::FileBlocking { objections } = plan else {
1469 panic!("a blocking dissent is a child: {plan:?}");
1470 };
1471 assert_eq!(objections.len(), 1);
1472 assert!(objections[0].text.contains("exit status"));
1473
1474 let mut child = PanelState::at_round(1);
1475 child.dissent_depth = 1;
1476 child.passed = true;
1477 child
1478 .objections
1479 .push(Objection::nit("ceo", "rename the local", "anneal-child"));
1480 child
1481 .objections
1482 .push(Objection::wording("cto", "heading tone", "anneal-child"));
1483 match plan_round(&child, &limits) {
1484 RoundPlan::Passed | RoundPlan::Settle { .. } => {}
1485 other => panic!("a dissent child does not file a nit grandchild: {other:?}"),
1486 }
1487 let mut deeper = child.clone();
1488 deeper.passed = true;
1489 deeper.objections.push(Objection::blocking(
1490 "tester",
1491 "the child still drops the status",
1492 "the wait is unchecked",
1493 "anneal-child",
1494 ));
1495 match plan_round(&deeper, &limits) {
1496 RoundPlan::FileBlocking { objections } => {
1497 assert_eq!(objections.len(), 1);
1498 assert!(objections[0].text.contains("unchecked"));
1499 }
1500 other => panic!("a blocking objection on a dissent child is still a child: {other:?}"),
1501 }
1502 }
1503
1504 #[test]
1505 fn new_blocking_objections_stop_at_the_round_limit() {
1506 let limits = ScopeBudget::panel();
1507 let mut state = PanelState::at_round(1);
1508 let mut filed = Vec::new();
1509 for n in 0..6 {
1510 state.objections.push(Objection::blocking(
1511 "tester",
1512 &format!("failure {n} escapes"),
1513 &format!("bug {n} is still open"),
1514 "anneal-z64x",
1515 ));
1516 state.open_children = 0;
1517 match plan_round(&state, &limits) {
1518 RoundPlan::FileBlocking { objections } => {
1519 assert_eq!(objections.len(), 1, "{objections:?}");
1520 let objection = &objections[0];
1521 filed.push(objection.text.clone());
1522 state.seen.push(objection.key());
1523 state.children_filed += 1;
1524 match plan_round(&state, &limits) {
1525 RoundPlan::OpenRound { n } => state.round = n,
1526 RoundPlan::Settle { summary } => {
1527 assert!(summary.contains("settled-with-notes"), "{summary}");
1528 break;
1529 }
1530 other => panic!("after the fix closed: {other:?}"),
1531 }
1532 }
1533 RoundPlan::Settle { summary } => {
1534 assert!(summary.contains("settled-with-notes"), "{summary}");
1535 assert!(summary.contains("bug"), "{summary}");
1536 break;
1537 }
1538 other => panic!("step {n}: {other:?}"),
1539 }
1540 }
1541 assert!(state.round <= limits.max_rounds, "round {}", state.round);
1542 assert!(filed.len() < 6, "filed without a cap: {filed:?}");
1543 assert!(filed.iter().any(|text| text.contains("bug 0")), "{filed:?}");
1544 assert!(
1545 !filed.iter().any(|text| text.contains("bug 5")),
1546 "the later bugs are listed, not filed: {filed:?}"
1547 );
1548 }
1549
1550 #[test]
1551 fn a_wall_budget_closes_without_filing() {
1552 let mut limits = ScopeBudget::panel();
1553 limits.max_wall_secs = Some(60);
1554 let mut state = PanelState::at_round(1);
1555 state.wall_secs = Some(120);
1556 state.objections.push(Objection::blocking(
1557 "tester",
1558 "a real failure",
1559 "the exit status is ignored",
1560 "anneal-z64x",
1561 ));
1562 match plan_round(&state, &limits) {
1563 RoundPlan::Settle { summary } => {
1564 assert!(summary.contains("wall time"), "{summary}");
1565 assert!(summary.contains("exit status"), "{summary}");
1566 }
1567 other => panic!("over the wall budget the objection is listed: {other:?}"),
1568 }
1569 }
1570
1571 #[test]
1572 fn a_settled_majority_names_only_the_minority() {
1573 let majority = vec![
1574 ("alice".to_string(), "drop".to_string()),
1575 ("bob".to_string(), "drop".to_string()),
1576 ("keeper".to_string(), "keep".to_string()),
1577 ];
1578 assert_eq!(minority_options(&majority), vec!["keep".to_string()]);
1579 let tie = vec![
1580 ("alice".to_string(), "yes".to_string()),
1581 ("bob".to_string(), "no".to_string()),
1582 ];
1583 assert!(minority_options(&tie).is_empty());
1584 let unanimous = vec![
1585 ("alice".to_string(), "ship".to_string()),
1586 ("bob".to_string(), "ship".to_string()),
1587 ];
1588 assert!(minority_options(&unanimous).is_empty());
1589 }
1590
1591 #[test]
1592 fn a_duplicate_is_not_filed_again() {
1593 let limits = ScopeBudget::panel();
1594 let mut state = PanelState::at_round(1);
1595 let objection = Objection::blocking(
1596 "tester",
1597 "the gate drops a real failure",
1598 "the exit status is ignored",
1599 "anneal-z64x",
1600 );
1601 state.seen.push(objection.key());
1602 state.objections.push(objection);
1603 state.objections.push(Objection::blocking(
1604 "cto",
1605 "the gate drops a real failure",
1606 "The exit status is ignored.",
1607 "anneal-z64x",
1608 ));
1609 let disposal = dispose(&state, &limits);
1610 assert!(disposal
1611 .fates
1612 .iter()
1613 .all(|f| matches!(f, Fate::Duplicate(_))));
1614 assert!(!disposal.close);
1615 }
1616
1617 #[test]
1618 fn toml_overlays_the_defaults_and_leaves_the_other_scope() {
1619 let budget = budget_from_toml(
1620 "[panel]\nmax_rounds = 2\nmax_tokens = 1000\n[workflow]\nmax_children = 4\n",
1621 );
1622 assert_eq!(budget.panel.max_rounds, 2);
1623 assert_eq!(budget.panel.max_children, ScopeBudget::panel().max_children);
1624 assert_eq!(budget.panel.max_tokens, Some(1000));
1625 assert_eq!(budget.workflow.max_children, 4);
1626 assert_eq!(budget.workflow.max_rounds, 3);
1627 assert_eq!(budget_from_toml("not toml"), PanelBudget::default());
1628 }
1629
1630 #[test]
1631 fn reap_flags_a_wide_tree_and_rounds_with_no_new_blocking_objection() {
1632 let budget = PanelBudget::default();
1633 let mut lines = Vec::new();
1634 lines.push(
1635 r#"{"id":"anneal-z64x","title":"anneal the notes","state":"STARTED","parent":""}"#
1636 .to_string(),
1637 );
1638 for n in 1..=10 {
1639 lines.push(format!(
1640 r#"{{"id":"anneal-r{n}","title":"panel r{n}: anneal","state":"DONE","parent":"anneal-z64x"}}"#
1641 ));
1642 }
1643 for n in 0..60 {
1644 lines.push(format!(
1645 r#"{{"id":"anneal-c{n}","title":"fix: comma {n}","state":"TODO","parent":"anneal-z64x"}}"#
1646 ));
1647 }
1648 let flags = flag_runaways(&lines.join("\n"), &budget);
1650 let ids: Vec<&str> = flags.iter().map(|(id, _)| id.as_str()).collect();
1651 assert!(ids.contains(&"anneal-z64x"), "{flags:?}");
1652 let why = flags
1653 .iter()
1654 .filter(|(id, _)| id == "anneal-z64x")
1655 .map(|(_, why)| why.as_str())
1656 .collect::<Vec<_>>()
1657 .join(" ");
1658 assert!(why.contains("descendants"), "{why}");
1659 assert!(why.contains("round 10"), "{why}");
1660 assert!(why.contains("without a new blocking"), "{why}");
1661
1662 let small = [
1665 r#"{"id":"p","title":"widget","state":"STARTED"}"#,
1666 r#"{"id":"p-r1","title":"panel r1: widget","state":"DONE","parent":"p","logbook":[{"note":"finding: tester: blocking: drops a failure :: exit status @ p"}]}"#,
1667 r#"{"id":"p-r2","title":"panel r2: widget","state":"DONE","parent":"p"}"#,
1668 ]
1669 .join("\n");
1670 let flags = flag_runaways(&small, &budget);
1671 assert!(
1672 flags.iter().all(|(id, _)| id != "p"),
1673 "round 2 with one blocking objection is the fix, not a runaway: {flags:?}"
1674 );
1675 let wide_round = [
1676 r#"{"id":"p","title":"widget","state":"STARTED"}"#,
1677 r#"{"id":"p-r1","title":"panel r1: widget","state":"DONE","parent":"p"}"#,
1678 r#"{"id":"p-r2","title":"panel r2: widget","state":"DONE","parent":"p"}"#,
1679 r#"{"id":"p-r3","title":"panel r3: widget","state":"DONE","parent":"p"}"#,
1680 ]
1681 .join("\n");
1682 let flags = flag_runaways(&wide_round, &budget);
1683 assert!(
1684 flags
1685 .iter()
1686 .any(|(id, why)| id == "p" && why.contains("round 3")),
1687 "{flags:?}"
1688 );
1689 }
1690}