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