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