Skip to main content

ljos_cli/
converge.rs

1//! Once the substance has passed, a nit, a wording
2//! note or a style note is written on the issue and never becomes a child.
3//! A persona that wants a child marks the objection blocking and gives a
4//! reason; the default, with no mark or a mark and no reason, is a nit.
5//! The same objection is not filed twice. A dissent child does not file
6//! a grandchild unless the new objection is blocking. A round count is a
7//! count. It is not a budget key, and it does not close the ticket. When
8//! the seats still split, the reading names the open objection children.
9//! A person closes the ticket.
10//!
11//! `ljos consensus` files its children through [`record_dissents`], titled
12//! `Dissent holds TEXT` for a blocking objection and `Dissent holds OPTION`
13//! for a minority still held after a settled majority. A `fix:` child comes
14//! only from `ljos workflow --round` ([`file_fix`]). The round is where the
15//! tracker records it: a `panel rN` title that `ljos workflow --round`
16//! filed, else the `panel-round: N` note each filing `ljos consensus`
17//! writes.
18
19use anyhow::Result;
20use serde::Deserialize;
21use serde_json::Value;
22use std::collections::{BTreeMap, BTreeSet};
23use std::path::PathBuf;
24
25/// Severities that stay on the issue as notes once the substance has settled.
26pub const BELOW_CHILD: &[&str] = &["nit", "wording", "style"];
27const DISSENT_TITLE_LIMIT: usize = 120;
28
29/// How serious an objection is. A child is filed only for [`Severity::Blocking`]
30/// with a non-empty reason, or for a held minority on an unsettled issue
31/// that is not itself a dissent child.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Severity {
34    Nit,
35    Wording,
36    Style,
37    Blocking,
38}
39
40impl Severity {
41    #[must_use]
42    pub fn parse(word: &str) -> Option<Self> {
43        match word.trim().to_ascii_lowercase().as_str() {
44            "nit" => Some(Self::Nit),
45            "wording" => Some(Self::Wording),
46            "style" => Some(Self::Style),
47            "blocking" => Some(Self::Blocking),
48            _ => None,
49        }
50    }
51
52    #[must_use]
53    pub fn word(self) -> &'static str {
54        match self {
55            Self::Nit => "nit",
56            Self::Wording => "wording",
57            Self::Style => "style",
58            Self::Blocking => "blocking",
59        }
60    }
61
62    /// Below the line that files a child.
63    #[must_use]
64    pub fn is_note(self) -> bool {
65        !matches!(self, Self::Blocking)
66    }
67}
68
69/// One objection a persona raised against a target (an issue, or a span on it).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Objection {
72    pub persona: String,
73    pub severity: Severity,
74    /// Required for a blocking mark. Empty means the mark did not take,
75    /// and the objection is a nit.
76    pub reason: String,
77    pub text: String,
78    pub target: String,
79    /// A ballot that still holds a minority option on an issue whose
80    /// substance has not settled. Filed once, at the top of a tree only.
81    pub held: bool,
82}
83
84impl Objection {
85    #[must_use]
86    pub fn nit(persona: &str, text: &str, target: &str) -> Self {
87        Self::noted(persona, Severity::Nit, "", text, target)
88    }
89
90    #[must_use]
91    pub fn wording(persona: &str, text: &str, target: &str) -> Self {
92        Self::noted(persona, Severity::Wording, "", text, target)
93    }
94
95    #[must_use]
96    pub fn style(persona: &str, text: &str, target: &str) -> Self {
97        Self::noted(persona, Severity::Style, "", text, target)
98    }
99
100    #[must_use]
101    pub fn blocking(persona: &str, reason: &str, text: &str, target: &str) -> Self {
102        Self::noted(persona, Severity::Blocking, reason, text, target)
103    }
104
105    fn noted(persona: &str, severity: Severity, reason: &str, text: &str, target: &str) -> Self {
106        let reason = reason.trim().to_string();
107        let severity = if severity == Severity::Blocking && reason.is_empty() {
108            Severity::Nit
109        } else {
110            severity
111        };
112        Self {
113            persona: persona.trim().to_string(),
114            severity,
115            reason,
116            text: text.trim().to_string(),
117            target: target.trim().to_string(),
118            held: false,
119        }
120    }
121
122    /// A blocking mark with a reason. Anything else stays a note.
123    #[must_use]
124    pub fn files_as_child(&self) -> bool {
125        self.severity == Severity::Blocking && !self.reason.trim().is_empty()
126    }
127
128    /// Whether this objection may become a child from here.
129    /// A dissent child (`depth` >= 1) files only a blocking objection.
130    #[must_use]
131    pub fn may_file(&self, passed: bool, depth: usize) -> bool {
132        if self.files_as_child() {
133            return true;
134        }
135        self.held && !passed && depth == 0
136    }
137
138    /// Normalized text plus normalized target. Two objections with this
139    /// pair are the same objection.
140    #[must_use]
141    pub fn key(&self) -> (String, String) {
142        let text = if self.files_as_child() || self.held {
143            dissent_key_text(&self.text)
144        } else {
145            normalize(&self.text)
146        };
147        (text, normalize(&self.target))
148    }
149}
150
151/// Collapse case, punctuation and whitespace so a rephrased comma is the
152/// same objection as the last round's comma.
153#[must_use]
154pub fn normalize(text: &str) -> String {
155    text.chars()
156        .map(|c| {
157            if c.is_ascii_alphanumeric() {
158                c.to_ascii_lowercase()
159            } else {
160                ' '
161            }
162        })
163        .collect::<String>()
164        .split_whitespace()
165        .collect::<Vec<_>>()
166        .join(" ")
167}
168
169fn dissent_key_text(text: &str) -> String {
170    normalize(&clip(text, DISSENT_TITLE_LIMIT))
171}
172
173/// `finding: NAME: TEXT` is a nit. `finding: NAME: wording: TEXT` and
174/// `finding: NAME: style: TEXT` stay notes. `finding: NAME: blocking: REASON :: TEXT`
175/// files a child. A blocking mark with no reason is a nit. `@ target` at
176/// the end names the target; otherwise `default_target` is the target.
177#[must_use]
178pub fn parse_objection(line: &str, default_target: &str) -> Option<Objection> {
179    let line = line.trim().trim_start_matches('-').trim();
180    let rest = line
181        .strip_prefix("finding:")
182        .or_else(|| line.strip_prefix("objection:"))
183        .map(str::trim)
184        .filter(|s| !s.is_empty())?;
185    let (persona, rest) = rest.split_once(':')?;
186    let persona = persona.trim();
187    if persona.is_empty() {
188        return None;
189    }
190    let rest = rest.trim();
191    let (severity, rest) = match rest.split_once(':') {
192        Some((word, after)) => match Severity::parse(word) {
193            Some(severity) => (severity, after.trim()),
194            None => (Severity::Nit, rest),
195        },
196        None => (Severity::Nit, rest),
197    };
198    let (reason, rest) = if severity == Severity::Blocking {
199        match rest.split_once("::") {
200            Some((reason, text)) => (reason.trim().to_string(), text.trim()),
201            None => (String::new(), rest),
202        }
203    } else {
204        (String::new(), rest)
205    };
206    let (text, target) = match rest.rsplit_once(" @ ") {
207        Some((text, target)) if !target.trim().is_empty() => (text.trim(), target.trim()),
208        _ => (rest, default_target),
209    };
210    if text.is_empty() {
211        return None;
212    }
213    Some(Objection::noted(persona, severity, &reason, text, target))
214}
215
216/// Children, and optional wall time or token estimate.
217/// Unset wall time and tokens do not bind. A round count is not a key here.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct ScopeBudget {
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    /// A single panel: a handful of children.
228    #[must_use]
229    pub fn panel() -> Self {
230        Self {
231            max_children: 8,
232            max_descendants: 12,
233            max_wall_secs: None,
234            max_tokens: None,
235        }
236    }
237
238    /// A feature workflow, which files design and fix children of its own.
239    #[must_use]
240    pub fn workflow() -> Self {
241        Self {
242            max_children: 24,
243            max_descendants: 48,
244            max_wall_secs: None,
245            max_tokens: None,
246        }
247    }
248
249    fn overlay(&mut self, file: &ScopeFile) {
250        if let Some(n) = file.max_children {
251            self.max_children = n;
252        }
253        if let Some(n) = file.max_descendants {
254            self.max_descendants = n;
255        }
256        if file.max_wall_secs.is_some() {
257            self.max_wall_secs = file.max_wall_secs;
258        }
259        if file.max_tokens.is_some() {
260            self.max_tokens = file.max_tokens;
261        }
262    }
263}
264
265/// Budgets for one panel and for a workflow parent. Defaults apply until
266/// a toml file says otherwise.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct PanelBudget {
269    pub panel: ScopeBudget,
270    pub workflow: ScopeBudget,
271}
272
273impl Default for PanelBudget {
274    fn default() -> Self {
275        Self {
276            panel: ScopeBudget::panel(),
277            workflow: ScopeBudget::workflow(),
278        }
279    }
280}
281
282#[derive(Debug, Clone, Deserialize, Default)]
283struct BudgetFile {
284    #[serde(default)]
285    panel: ScopeFile,
286    #[serde(default)]
287    workflow: ScopeFile,
288}
289
290#[derive(Debug, Clone, Deserialize, Default)]
291struct ScopeFile {
292    max_children: Option<usize>,
293    max_descendants: Option<usize>,
294    max_wall_secs: Option<u64>,
295    max_tokens: Option<u64>,
296}
297
298/// The budget a toml document names, over the defaults. A document with
299/// neither table is the defaults. Unknown keys are ignored, and so is
300/// `max_rounds`: a round count is not a budget.
301#[must_use]
302pub fn budget_from_toml(text: &str) -> PanelBudget {
303    let mut budget = PanelBudget::default();
304    let Ok(file) = toml::from_str::<BudgetFile>(text) else {
305        return budget;
306    };
307    budget.panel.overlay(&file.panel);
308    budget.workflow.overlay(&file.workflow);
309    budget
310}
311
312/// Config dir first, then `./vissue.toml` and `./ljos.toml`, then
313/// `VISSUE_CONFIG` and `LJOS_CONFIG`. A later file overrides the keys it
314/// sets. A missing file is skipped. A file that does not parse is skipped.
315#[must_use]
316pub fn load_budget() -> PanelBudget {
317    let mut budget = PanelBudget::default();
318    for path in config_paths(None) {
319        let Ok(text) = std::fs::read_to_string(&path) else {
320            continue;
321        };
322        let Ok(file) = toml::from_str::<BudgetFile>(&text) else {
323            continue;
324        };
325        budget.panel.overlay(&file.panel);
326        budget.workflow.overlay(&file.workflow);
327    }
328    budget
329}
330
331/// The config layers in the order [`load_budget`] reads them. `own` adds
332/// a file of that name in the ljos config dir, after `ljos.toml`.
333pub(crate) fn config_paths(own: Option<&str>) -> Vec<PathBuf> {
334    let mut paths = Vec::new();
335    if let Some(base) = config_base() {
336        paths.push(base.join("vissue").join("config.toml"));
337        paths.push(base.join("ljos").join("ljos.toml"));
338        if let Some(name) = own {
339            paths.push(base.join("ljos").join(name));
340        }
341    }
342    paths.push(PathBuf::from("vissue.toml"));
343    paths.push(PathBuf::from("ljos.toml"));
344    if let Some(path) = std::env::var_os("VISSUE_CONFIG").filter(|p| !p.is_empty()) {
345        paths.push(PathBuf::from(path));
346    }
347    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|p| !p.is_empty()) {
348        paths.push(PathBuf::from(path));
349    }
350    paths
351}
352
353pub(crate) fn config_base() -> Option<PathBuf> {
354    std::env::var_os("XDG_CONFIG_HOME")
355        .map(PathBuf::from)
356        .filter(|p| p.is_absolute())
357        .or_else(|| {
358            std::env::var_os("HOME")
359                .map(|h| PathBuf::from(h).join(".config"))
360                .filter(|p| p.is_absolute())
361        })
362}
363
364/// Where a panel stands when the next round is asked for.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct PanelState {
367    pub round: usize,
368    pub passed: bool,
369    /// 0 on the work. 1 on a dissent child, and so on.
370    pub dissent_depth: usize,
371    pub children_filed: usize,
372    pub open_children: usize,
373    /// Issues under this one, not counting it. 0 when the caller has not counted.
374    pub descendants: usize,
375    /// The round has not been sat: ballots are missing.
376    pub awaiting_ballots: bool,
377    pub seen: Vec<(String, String)>,
378    pub wall_secs: Option<u64>,
379    pub tokens: Option<u64>,
380    pub objections: Vec<Objection>,
381}
382
383impl PanelState {
384    #[must_use]
385    pub fn at_round(round: usize) -> Self {
386        Self {
387            round,
388            passed: false,
389            dissent_depth: 0,
390            children_filed: 0,
391            open_children: 0,
392            descendants: 0,
393            awaiting_ballots: false,
394            seen: Vec::new(),
395            wall_secs: None,
396            tokens: None,
397            objections: Vec::new(),
398        }
399    }
400}
401
402/// What to do with one objection.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum Fate {
405    Note(Objection),
406    File(Objection),
407    Duplicate(Objection),
408    /// Would have been a child. The child list or the budget is already
409    /// full, and the parent stays open.
410    Listed(Objection),
411}
412
413/// What one call does with the objections it was shown.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct Disposal {
416    pub fates: Vec<Fate>,
417    pub close: bool,
418    pub summary: String,
419}
420
421/// The next step of a workflow panel. Nothing here closes the parent.
422/// A pass is where the loop stops. A person closes the ticket.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub enum RoundPlan {
425    OpenRound {
426        n: usize,
427    },
428    /// `objections` is what this call may file. `left_out` is every
429    /// objection that did not fit the child cap or the descendant limit.
430    FileBlocking {
431        objections: Vec<Objection>,
432        left_out: Vec<Objection>,
433    },
434    Hold {
435        why: String,
436    },
437    Passed,
438}
439
440fn over_budget(state: &PanelState, limits: &ScopeBudget) -> bool {
441    if state.children_filed >= limits.max_children {
442        return true;
443    }
444    if let (Some(used), Some(max)) = (state.wall_secs, limits.max_wall_secs) {
445        if used >= max {
446            return true;
447        }
448    }
449    if let (Some(used), Some(max)) = (state.tokens, limits.max_tokens) {
450        if used >= max {
451            return true;
452        }
453    }
454    false
455}
456
457fn name_objections(objections: &[Objection]) -> String {
458    objections
459        .iter()
460        .map(|objection| objection.text.as_str())
461        .collect::<Vec<_>>()
462        .join("; ")
463}
464
465fn clip(text: &str, n: usize) -> String {
466    let mut out: String = text.chars().take(n).collect();
467    if text.chars().count() > n {
468        out.push('…');
469    }
470    out
471}
472
473/// Sort the objections on one issue: notes, one child each for what may
474/// be filed, duplicates dropped. A full list or a spent budget lists the
475/// rest. It does not close the issue.
476#[must_use]
477pub fn dispose(state: &PanelState, limits: &ScopeBudget) -> Disposal {
478    let mut fates = Vec::new();
479    let mut batch = state.seen.iter().cloned().collect::<BTreeSet<_>>();
480    let mut fileable = Vec::new();
481    for objection in &state.objections {
482        let key = objection.key();
483        if batch.contains(&key) {
484            fates.push(Fate::Duplicate(objection.clone()));
485            continue;
486        }
487        batch.insert(key);
488        if objection.may_file(state.passed, state.dissent_depth) {
489            fileable.push(objection.clone());
490        } else {
491            fates.push(Fate::Note(objection.clone()));
492        }
493    }
494    if over_budget(state, limits) {
495        for objection in &fileable {
496            fates.push(Fate::Listed(objection.clone()));
497        }
498        return Disposal {
499            fates,
500            close: false,
501            summary: String::new(),
502        };
503    }
504    let room = limits.max_children.saturating_sub(state.children_filed);
505    let mut listed = Vec::new();
506    for (i, objection) in fileable.into_iter().enumerate() {
507        if i < room {
508            fates.push(Fate::File(objection));
509        } else {
510            listed.push(objection);
511        }
512    }
513    for objection in listed {
514        fates.push(Fate::Listed(objection));
515    }
516    Disposal {
517        fates,
518        close: false,
519        summary: String::new(),
520    }
521}
522
523/// The next workflow step. Open children and uncast ballots hold. A
524/// blocking objection files one child, including after the substance has
525/// passed, and the next round waits until that child is closed. A pass
526/// with nothing blocking left is a pass. A round that has not passed
527/// opens the next one. A round count, a wall budget, and a full child
528/// list do not close the parent.
529#[must_use]
530pub fn plan_round(state: &PanelState, limits: &ScopeBudget) -> RoundPlan {
531    if state.open_children > 0 {
532        return RoundPlan::Hold {
533            why: format!(
534                "{} {} still open",
535                state.open_children,
536                if state.open_children == 1 {
537                    "child is"
538                } else {
539                    "children are"
540                }
541            ),
542        };
543    }
544    if state.awaiting_ballots {
545        return RoundPlan::Hold {
546            why: "the panel has not all voted".into(),
547        };
548    }
549    let mut fileable = Vec::new();
550    let mut seen = state.seen.iter().cloned().collect::<BTreeSet<_>>();
551    for objection in &state.objections {
552        let key = objection.key();
553        if seen.contains(&key) {
554            continue;
555        }
556        seen.insert(key);
557        if objection.may_file(state.passed, state.dissent_depth) {
558            fileable.push(objection.clone());
559        }
560    }
561    if state.descendants >= limits.max_descendants && fileable.is_empty() {
562        return RoundPlan::Hold {
563            why: format!(
564                "{} descendants reached the limit of {}. A person has to act",
565                state.descendants, limits.max_descendants
566            ),
567        };
568    }
569    if !fileable.is_empty() {
570        let child_room = limits.max_children.saturating_sub(state.children_filed);
571        let desc_room = limits.max_descendants.saturating_sub(state.descendants);
572        let room = child_room.min(desc_room);
573        if room == 0 {
574            let named = name_objections(&fileable);
575            let why = if state.descendants >= limits.max_descendants {
576                format!(
577                    "{} descendants reached the limit of {}. A person has to act. Not filed: {named}",
578                    state.descendants, limits.max_descendants
579                )
580            } else {
581                format!(
582                    "{} children filed, at the limit of {}. Not filed: {named}",
583                    state.children_filed, limits.max_children
584                )
585            };
586            return RoundPlan::Hold { why };
587        }
588        let left_out = if fileable.len() > room {
589            fileable.split_off(room)
590        } else {
591            Vec::new()
592        };
593        return RoundPlan::FileBlocking {
594            objections: fileable,
595            left_out,
596        };
597    }
598    if state.passed {
599        return RoundPlan::Passed;
600    }
601    let n = if state.round == 0 { 1 } else { state.round + 1 };
602    RoundPlan::OpenRound { n }
603}
604
605/// Whether `pass` is the unique strict majority, or, on any other ballot,
606/// one option leads by a strict majority. A tie has not settled.
607#[must_use]
608pub fn substance_passed(choices: &[(String, String)]) -> bool {
609    if choices.is_empty() {
610        return false;
611    }
612    let norm = |s: &str| s.trim().to_ascii_lowercase();
613    let pass = choices.iter().filter(|(_, c)| norm(c) == "pass").count();
614    let revise = choices.iter().filter(|(_, c)| norm(c) == "revise").count();
615    if pass + revise == choices.len() {
616        return pass > revise && pass * 2 > choices.len();
617    }
618    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
619    for (_, choice) in choices {
620        *counts.entry(normalize(choice)).or_default() += 1;
621    }
622    let mut ranked: Vec<(String, usize)> = counts.into_iter().collect();
623    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
624    ranked.len() == 1 || (ranked[0].1 > ranked[1].1 && ranked[0].1 * 2 > choices.len())
625}
626
627/// True when two choices share the highest count. A single ballot, and a
628/// strict lead, are not ties.
629fn ballot_tie(choices: &[(String, String)]) -> bool {
630    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
631    for (_, choice) in choices {
632        *counts.entry(normalize(choice)).or_default() += 1;
633    }
634    let Some(max) = counts.values().copied().max() else {
635        return false;
636    };
637    counts.values().filter(|n| **n == max).count() > 1
638}
639
640/// Findings on an issue JSON: logbook notes and body lines.
641#[must_use]
642pub fn objections_in(v: &Value, default_target: &str) -> Vec<Objection> {
643    let mut out = Vec::new();
644    let mut push = |text: &str| {
645        if let Some(objection) = parse_objection(text, default_target) {
646            out.push(objection);
647        }
648    };
649    for entry in v["logbook"].as_array().into_iter().flatten() {
650        if let Some(note) = entry["note"].as_str() {
651            push(note);
652        }
653        if let Some(raw) = entry["raw"].as_str() {
654            push(raw);
655        }
656    }
657    if let Some(body) = v["body"].as_str() {
658        for line in body.lines() {
659            push(line);
660        }
661    }
662    out
663}
664
665/// Depth of a dissent child: the `dissent-depth: N` note, else 1 when the
666/// title is a dissent, else 0.
667#[must_use]
668pub fn dissent_depth(v: &Value) -> usize {
669    for entry in v["logbook"].as_array().into_iter().flatten() {
670        let Some(note) = entry["note"].as_str() else {
671            continue;
672        };
673        if let Some(rest) = note.trim().strip_prefix("dissent-depth:") {
674            if let Ok(n) = rest.trim().parse::<usize>() {
675                return n;
676            }
677        }
678    }
679    let title = v["title"].as_str().unwrap_or("");
680    if title.trim_start().starts_with("Dissent holds") {
681        1
682    } else {
683        0
684    }
685}
686
687/// The text a filed child carries, so a later round recognises it.
688#[must_use]
689pub fn filed_key(title: &str, target: &str) -> Option<(String, String)> {
690    let title = title.trim();
691    let text = title
692        .strip_prefix("Dissent holds ")
693        .or_else(|| title.strip_prefix("fix: "))
694        .or_else(|| title.strip_prefix("fix:"))?;
695    let text = text.trim();
696    if text.is_empty() {
697        return None;
698    }
699    Some((dissent_key_text(text), normalize(target)))
700}
701
702fn project_of(v: &Value, id: &str) -> String {
703    v["project"]
704        .as_str()
705        .filter(|s| !s.trim().is_empty())
706        .map(str::to_string)
707        .or_else(|| id.rsplit_once('-').map(|(p, _)| p.to_string()))
708        .unwrap_or_default()
709}
710
711/// Options a voter still holds that are not the unique lead. A tie, a
712/// single option, and an empty ballot name nothing: there is no minority
713/// to file.
714fn minority_options(choices: &[(String, String)]) -> Vec<String> {
715    let mut counts: BTreeMap<String, (String, usize)> = BTreeMap::new();
716    for (_, choice) in choices {
717        let shown = choice.trim();
718        let key = normalize(shown);
719        if key.is_empty() {
720            continue;
721        }
722        let row = counts.entry(key).or_insert_with(|| (shown.to_string(), 0));
723        row.1 += 1;
724    }
725    let Some(max) = counts.values().map(|(_, n)| *n).max() else {
726        return Vec::new();
727    };
728    if counts.values().filter(|(_, n)| *n == max).count() != 1 {
729        return Vec::new();
730    }
731    let mut out: Vec<String> = counts
732        .into_values()
733        .filter(|(_, n)| *n < max)
734        .map(|(shown, _)| shown)
735        .collect();
736    out.sort();
737    out
738}
739
740/// One `Dissent holds OPTION` child per minority on a settled majority.
741/// A child already filed for that option is named and left alone.
742fn file_settled_minority(
743    issue: &str,
744    project: &str,
745    choices: &[(String, String)],
746    children: &[String],
747    depth: usize,
748) -> Vec<String> {
749    let mut lines = Vec::new();
750    for option in minority_options(choices) {
751        let title = format!("Dissent holds {option}");
752        if children.iter().any(|t| normalize(t) == normalize(&title)) {
753            lines.push(format!("{issue}: dissent {option} already filed"));
754            continue;
755        }
756        match file_child(issue, project, &title, depth) {
757            Ok(id) => lines.push(format!("filed {id}  TODO  [#C]  {title}")),
758            Err(e) => lines.push(format!("dissent: file refused: {e:#}")),
759        }
760    }
761    lines
762}
763
764/// The note a filing `ljos consensus` writes for each round it sits.
765pub const ROUND_NOTE: &str = "panel-round:";
766
767/// The highest round noted on an issue JSON as `panel-round: N`, 0 with none.
768#[must_use]
769pub fn recorded_round(v: &Value) -> usize {
770    v["logbook"]
771        .as_array()
772        .into_iter()
773        .flatten()
774        .filter_map(|e| e["note"].as_str())
775        .filter_map(|n| {
776            n.trim()
777                .strip_prefix(ROUND_NOTE)?
778                .trim()
779                .parse::<usize>()
780                .ok()
781        })
782        .max()
783        .unwrap_or(0)
784}
785
786fn is_closed(v: &Value) -> bool {
787    matches!(v["state"].as_str(), Some("DONE" | "CANCELLED"))
788}
789
790/// The round this reading sits on the issue, and whether to note it. A
791/// `panel rN` title is round N, as `ljos workflow --round` filed it, and
792/// needs no note. Any other title sits one round past the last
793/// `panel-round: N` note. A closed issue sits no new round.
794#[must_use]
795pub fn sitting_round(v: &Value) -> (usize, bool) {
796    if let Some(n) = crate::panel_round_number(v["title"].as_str().unwrap_or("")) {
797        return (n, false);
798    }
799    let last = recorded_round(v);
800    if is_closed(v) {
801        return (last, false);
802    }
803    (last + 1, true)
804}
805
806/// Open `Dissent holds` and `fix:` children of `issue`. A missing state
807/// counts as open: a stub that lists only the id and the title has not
808/// closed the child.
809fn open_objection_children(issue: &str) -> Vec<(String, String)> {
810    let Ok(said) = crate::run_captured("vissue", &["children", issue, "--json"]) else {
811        return Vec::new();
812    };
813    let Ok(rows) = serde_json::from_str::<Vec<Value>>(&said.stdout) else {
814        return Vec::new();
815    };
816    let mut out = Vec::new();
817    for row in rows {
818        let Some(id) = row["id"].as_str() else {
819            continue;
820        };
821        let title = row["title"].as_str().map(str::to_string).or_else(|| {
822            crate::issue_record(id)
823                .ok()
824                .and_then(|v| v["title"].as_str().map(str::to_string))
825        });
826        let Some(title) = title else {
827            continue;
828        };
829        let titled = title.trim_start();
830        if !titled.starts_with("Dissent holds") && !titled.starts_with("fix:") {
831            continue;
832        }
833        let state = row["state"]
834            .as_str()
835            .map(str::to_string)
836            .or_else(|| {
837                crate::issue_record(id)
838                    .ok()
839                    .and_then(|v| v["state"].as_str().map(str::to_string))
840            })
841            .unwrap_or_default();
842        if matches!(state.as_str(), "DONE" | "CANCELLED") {
843            continue;
844        }
845        out.push((id.to_string(), title));
846    }
847    out
848}
849
850/// The line a split reading prints. The round number is a count. It does
851/// not close the ticket.
852fn split_advice(issue: &str, round: usize) -> String {
853    let open = open_objection_children(issue);
854    if open.is_empty() {
855        return format!("seats still split at round {round}; open objections: none");
856    }
857    let named = open
858        .iter()
859        .map(|(id, title)| format!("{id} {title}"))
860        .collect::<Vec<_>>()
861        .join("; ");
862    format!("seats still split at round {round}; open objections: {named}")
863}
864
865/// File dissent children and note the rest for `issue`. A blocking
866/// objection with a reason is one `Dissent holds TEXT` child. A settled
867/// majority still files one `Dissent holds OPTION` child for each option a
868/// voter holds that is not the lead. A tie files none. Each call sits one
869/// panel round ([`sitting_round`]). A round count does not change the
870/// ticket state. When the seats still split, the reading names the open
871/// objection children. A person closes the ticket.
872/// A tracker that does not answer leaves the reading as it was: filing
873/// is a side effect.
874#[must_use]
875pub fn record_dissents(issue: &str) -> Vec<String> {
876    let Ok(v) = crate::issue_record(issue) else {
877        return Vec::new();
878    };
879    let closed = is_closed(&v);
880    let (round, note_round) = sitting_round(&v);
881    let mut round_lines = Vec::new();
882    if note_round {
883        if let Err(e) = note_issue(issue, &format!("{ROUND_NOTE} {round}")) {
884            round_lines.push(format!("dissent: round note refused: {e:#}"));
885        }
886    }
887    let choices: Vec<(String, String)> = crate::ballot_rows(issue)
888        .into_iter()
889        .filter_map(|row| {
890            let agent = row.get("agent")?.as_str()?.to_string();
891            let choice = row.get("choice")?.as_str()?.to_string();
892            Some((agent, choice))
893        })
894        .collect();
895    let passed = substance_passed(&choices);
896    // A tie has no held side to file. Both options lead, so a dissent child
897    // for each would be the settle saying it could not choose.
898    let tied = ballot_tie(&choices);
899    let mut objections = objections_in(&v, issue);
900    let noted: BTreeSet<(String, String)> = objections.iter().map(Objection::key).collect();
901    if !passed && !tied {
902        for (persona, choice) in &choices {
903            if normalize(choice) == "pass" {
904                continue;
905            }
906            let mut held = Objection::nit(persona, &format!("holds {choice}"), issue);
907            held.held = true;
908            if !noted.contains(&held.key())
909                && !objections
910                    .iter()
911                    .any(|o| o.persona == *persona && o.files_as_child())
912            {
913                objections.push(held);
914            }
915        }
916    }
917    let children = child_titles(issue);
918    let mut seen = Vec::new();
919    let mut children_filed = 0;
920    for title in &children {
921        if let Some(key) = filed_key(title, issue) {
922            seen.push(key);
923            children_filed += 1;
924        }
925    }
926    let state = PanelState {
927        round,
928        passed,
929        dissent_depth: dissent_depth(&v),
930        children_filed,
931        open_children: 0,
932        // The limit is the whole dissent tree, not this issue's own
933        // children. A nested split otherwise files past it.
934        descendants: descendant_count(&dissent_tree_top(issue)),
935        awaiting_ballots: false,
936        seen,
937        wall_secs: None,
938        tokens: None,
939        objections,
940    };
941    let limits = load_budget().panel;
942    let mut descendants = state.descendants;
943    let mut filed_now = 0usize;
944    let disposal = dispose(&state, &limits);
945    let mut lines = round_lines;
946    let project = project_of(&v, issue);
947    let depth = state.dissent_depth;
948    for fate in disposal.fates {
949        match fate {
950            Fate::Duplicate(_) => {}
951            Fate::Note(objection) if noted.contains(&objection.key()) => {}
952            Fate::Note(objection) => match note_issue(issue, &format_note(&objection)) {
953                Ok(()) => lines.push(format!(
954                    "noted {} from {} on {}: {}",
955                    objection.severity.word(),
956                    objection.persona,
957                    objection.target,
958                    objection.text
959                )),
960                Err(e) => lines.push(format!("dissent: note refused: {e:#}")),
961            },
962            Fate::File(objection) => {
963                let over_children = state.children_filed + filed_now >= limits.max_children;
964                let over_descendants = descendants >= limits.max_descendants;
965                if over_children || over_descendants {
966                    say_unfiled(issue, &objection, &noted, &mut lines);
967                } else {
968                    let title = dissent_title(&objection);
969                    match file_child(issue, &project, &title, depth + 1) {
970                        Ok(id) => {
971                            filed_now += 1;
972                            descendants += 1;
973                            lines.push(format!("filed {id}  TODO  [#C]  {title}"));
974                        }
975                        Err(e) => lines.push(format!("dissent: file refused: {e:#}")),
976                    }
977                }
978            }
979            Fate::Listed(objection) => say_unfiled(issue, &objection, &noted, &mut lines),
980        }
981    }
982    // A round count, a child count, or a spent budget used to mark the
983    // ticket DONE here. The state stays whatever the tracker had. When
984    // the seats still split, name the open objection children.
985    if !passed && !closed {
986        lines.push(split_advice(issue, round));
987    }
988    // A strict majority has settled the substance. The option a voter
989    // still holds, and that is not the lead, is still a dissent child.
990    // The tie never reaches here with a minority to name.
991    if passed {
992        if descendants >= limits.max_descendants {
993            let pause = "paused for a person; the descendant limit is reached";
994            if !ticket_mentions(issue, pause) {
995                let _ = note_issue(issue, pause);
996            }
997            lines.push(pause.to_string());
998        } else {
999            lines.extend(file_settled_minority(
1000                issue,
1001                &project,
1002                &choices,
1003                &children,
1004                depth + 1,
1005            ));
1006        }
1007    }
1008    lines
1009}
1010
1011fn say_unfiled(
1012    issue: &str,
1013    objection: &Objection,
1014    noted: &BTreeSet<(String, String)>,
1015    lines: &mut Vec<String>,
1016) {
1017    if !noted.contains(&objection.key()) {
1018        match note_issue(issue, &format_note(objection)) {
1019            Ok(()) => lines.push(format!(
1020                "noted {} from {} on {}: {}",
1021                objection.severity.word(),
1022                objection.persona,
1023                objection.target,
1024                objection.text
1025            )),
1026            Err(e) => lines.push(format!("dissent: note refused: {e:#}")),
1027        }
1028    }
1029    let pause = format!(
1030        "paused for a person; {} from {} was not filed: {}",
1031        objection.severity.word(),
1032        objection.persona,
1033        objection.text
1034    );
1035    if !ticket_mentions(issue, &pause) {
1036        if let Err(e) = note_issue(issue, &pause) {
1037            lines.push(format!("dissent: pause note refused: {e:#}"));
1038        }
1039    }
1040    lines.push(pause);
1041}
1042
1043/// Whether the ticket text already carries `text`, so a pause is written once.
1044fn ticket_mentions(issue: &str, text: &str) -> bool {
1045    crate::issue_record(issue)
1046        .ok()
1047        .is_some_and(|v| v.to_string().contains(text))
1048}
1049
1050/// The issue at the top of a dissent or fix chain. A title that is not
1051/// one of those is the top, including `issue` itself.
1052fn dissent_tree_top(issue: &str) -> String {
1053    let mut current = issue.to_string();
1054    for _ in 0..64 {
1055        let Ok(v) = crate::issue_record(&current) else {
1056            return current;
1057        };
1058        let title = v["title"].as_str().unwrap_or("");
1059        if !(title.starts_with("Dissent holds") || title.starts_with("fix:")) {
1060            return current;
1061        }
1062        let Some(parent) = v["parent"]
1063            .as_str()
1064            .map(str::trim)
1065            .filter(|s| !s.is_empty())
1066        else {
1067            return current;
1068        };
1069        current = parent.to_string();
1070    }
1071    current
1072}
1073
1074/// Issues under `issue`, not counting `issue`. A cycle counts once.
1075#[must_use]
1076pub fn descendant_count(issue: &str) -> usize {
1077    fn walk(id: &str, seen: &mut BTreeSet<String>) -> usize {
1078        let mut n = 0;
1079        for child in child_ids(id) {
1080            if !seen.insert(child.clone()) {
1081                continue;
1082            }
1083            n += 1 + walk(&child, seen);
1084        }
1085        n
1086    }
1087    let mut seen = BTreeSet::new();
1088    seen.insert(issue.to_string());
1089    walk(issue, &mut seen)
1090}
1091
1092fn child_ids(issue: &str) -> Vec<String> {
1093    let Ok(said) = crate::run_captured("vissue", &["children", issue, "--json"]) else {
1094        return Vec::new();
1095    };
1096    let Ok(rows) = serde_json::from_str::<Vec<Value>>(&said.stdout) else {
1097        return Vec::new();
1098    };
1099    rows.into_iter()
1100        .filter_map(|row| row["id"].as_str().map(str::to_string))
1101        .collect()
1102}
1103
1104fn format_note(objection: &Objection) -> String {
1105    if objection.files_as_child() {
1106        format!(
1107            "finding: {}: blocking: {} :: {} @ {}",
1108            objection.persona, objection.reason, objection.text, objection.target
1109        )
1110    } else {
1111        format!(
1112            "finding: {}: {}: {} @ {}",
1113            objection.persona,
1114            objection.severity.word(),
1115            objection.text,
1116            objection.target
1117        )
1118    }
1119}
1120
1121fn dissent_title(objection: &Objection) -> String {
1122    let text = clip(&objection.text, DISSENT_TITLE_LIMIT);
1123    format!("Dissent holds {text}")
1124}
1125
1126fn child_titles(issue: &str) -> Vec<String> {
1127    let Ok(said) = crate::run_captured("vissue", &["children", issue, "--json"]) else {
1128        return Vec::new();
1129    };
1130    let Ok(rows) = serde_json::from_str::<Vec<Value>>(&said.stdout) else {
1131        return Vec::new();
1132    };
1133    rows.into_iter()
1134        .filter_map(|row| {
1135            let title = row["title"].as_str().map(str::to_string);
1136            if title.is_some() {
1137                return title;
1138            }
1139            let id = row["id"].as_str()?;
1140            crate::issue_record(id)
1141                .ok()
1142                .and_then(|v| v["title"].as_str().map(str::to_string))
1143        })
1144        .collect()
1145}
1146
1147fn file_child(parent: &str, project: &str, title: &str, depth: usize) -> Result<String> {
1148    let mut args = vec!["q".to_string()];
1149    if !project.is_empty() {
1150        args.push("-p".into());
1151        args.push(project.to_string());
1152    }
1153    args.extend([
1154        "--parent".into(),
1155        parent.to_string(),
1156        "-t".into(),
1157        "task".into(),
1158        title.to_string(),
1159    ]);
1160    let said = crate::run_captured("vissue", &args)?;
1161    let id = said.stdout.trim().to_string();
1162    if id.is_empty() {
1163        anyhow::bail!("vissue q printed no id");
1164    }
1165    let _ = crate::run_captured("vissue", &["note", &id, &format!("dissent-depth: {depth}")]);
1166    Ok(id)
1167}
1168
1169fn note_issue(id: &str, text: &str) -> Result<()> {
1170    crate::run_captured("vissue", &["note", id, text])?;
1171    Ok(())
1172}
1173
1174/// File one `fix:` child for a blocking objection, for `ljos workflow
1175/// --round`. The review of it is the next panel round, not a second child.
1176/// `ljos consensus` does not call this: it files `Dissent holds TEXT`
1177/// through [`record_dissents`].
1178pub fn file_fix(parent: &str, project: &str, objection: &Objection) -> Result<String> {
1179    let title = format!("fix: {}", clip(&objection.text, DISSENT_TITLE_LIMIT));
1180    let id = crate::workflow_child(parent, project, "task", crate::Stage::Implement, &title)?;
1181    let _ = note_issue(
1182        &id,
1183        &format!(
1184            "finding: {}: blocking: {} :: {} @ {}",
1185            objection.persona, objection.reason, objection.text, parent
1186        ),
1187    );
1188    Ok(id)
1189}
1190
1191/// One issue in an export, enough to see a tree running away.
1192#[derive(Debug, Clone, PartialEq, Eq)]
1193struct Node {
1194    id: String,
1195    parent: Option<String>,
1196    title: String,
1197    open: bool,
1198    blocking: usize,
1199}
1200
1201/// Lines for trees that have run away: more descendants than the budget,
1202/// or panel rounds that grew without a new blocking objection. The top of
1203/// the tree is named, not every child. `apply` names the open ticket and
1204/// does not change its state. A person closes the ticket.
1205#[must_use]
1206pub fn reap_runaways(apply: bool) -> String {
1207    let Ok(said) = crate::run_captured("vissue", &["export"]) else {
1208        return String::new();
1209    };
1210    let budget = load_budget();
1211    let mut out = String::new();
1212    for (id, why) in flag_runaways(&said.stdout, &budget) {
1213        out.push_str(&format!("runaway: {id} {why}\n"));
1214        if apply {
1215            out.push_str(&format!("reap: {id} stays open; a person closes it\n"));
1216        }
1217    }
1218    out
1219}
1220
1221/// Runaway trees in a `vissue export` (one JSON issue a line).
1222#[must_use]
1223pub fn flag_runaways(export: &str, budget: &PanelBudget) -> Vec<(String, String)> {
1224    let nodes = nodes_of(export);
1225    let children = children_of(&nodes);
1226    let mut desc_memo: BTreeMap<String, usize> = BTreeMap::new();
1227    let mut round_memo: BTreeMap<String, usize> = BTreeMap::new();
1228    let mut block_memo: BTreeMap<String, usize> = BTreeMap::new();
1229    let mut flags = Vec::new();
1230    for node in &nodes {
1231        if !node.open {
1232            continue;
1233        }
1234        let limits = if is_workflow(&nodes, &node.id) {
1235            &budget.workflow
1236        } else {
1237            &budget.panel
1238        };
1239        let desc = descendants(&node.id, &children, &mut desc_memo, &mut BTreeSet::new());
1240        let parent_over = node.parent.as_ref().is_some_and(|p| {
1241            let parent_limits = if is_workflow(&nodes, p) {
1242                &budget.workflow
1243            } else {
1244                &budget.panel
1245            };
1246            descendants(p, &children, &mut desc_memo, &mut BTreeSet::new())
1247                > parent_limits.max_descendants
1248        });
1249        if desc > limits.max_descendants && !parent_over {
1250            flags.push((
1251                node.id.clone(),
1252                format!(
1253                    "has {desc} descendants, over the limit of {}",
1254                    limits.max_descendants
1255                ),
1256            ));
1257        }
1258        let round = max_round(
1259            &node.id,
1260            &nodes,
1261            &children,
1262            &mut round_memo,
1263            &mut BTreeSet::new(),
1264        );
1265        let blocking = blocking_under(
1266            &node.id,
1267            &nodes,
1268            &children,
1269            &mut block_memo,
1270            &mut BTreeSet::new(),
1271        );
1272        let grew = round >= 2 && round > blocking + 1;
1273        let parent_grew = node.parent.as_ref().is_some_and(|p| {
1274            let parent_round =
1275                max_round(p, &nodes, &children, &mut round_memo, &mut BTreeSet::new());
1276            let parent_blocking =
1277                blocking_under(p, &nodes, &children, &mut block_memo, &mut BTreeSet::new());
1278            parent_round >= round && parent_round >= 2 && parent_round > parent_blocking + 1
1279        });
1280        if grew && !parent_grew {
1281            flags.push((
1282                node.id.clone(),
1283                format!(
1284                    "reached round {round} with {blocking} blocking {}; rounds grew without a new blocking objection",
1285                    if blocking == 1 { "objection" } else { "objections" }
1286                ),
1287            ));
1288        }
1289    }
1290    flags
1291}
1292
1293fn is_workflow(nodes: &[Node], id: &str) -> bool {
1294    nodes.iter().any(|n| {
1295        n.id == id
1296            && (n.title.contains("workflow")
1297                || n.title.starts_with("design:")
1298                || n.title.starts_with("panel r"))
1299    }) || nodes
1300        .iter()
1301        .any(|n| n.parent.as_deref() == Some(id) && crate::panel_round_number(&n.title).is_some())
1302}
1303
1304fn nodes_of(export: &str) -> Vec<Node> {
1305    let mut nodes = Vec::new();
1306    for line in export.lines() {
1307        let Ok(v) = serde_json::from_str::<Value>(line) else {
1308            continue;
1309        };
1310        let Some(id) = v["id"].as_str().filter(|s| !s.is_empty()) else {
1311            continue;
1312        };
1313        let state = v["state"].as_str().unwrap_or("");
1314        let open = !matches!(state, "DONE" | "CANCELLED");
1315        let blocking = objections_in(&v, id)
1316            .iter()
1317            .filter(|o| o.files_as_child())
1318            .count();
1319        nodes.push(Node {
1320            id: id.to_string(),
1321            parent: v["parent"]
1322                .as_str()
1323                .filter(|s| !s.is_empty())
1324                .map(str::to_string),
1325            title: v["title"].as_str().unwrap_or("").to_string(),
1326            open,
1327            blocking,
1328        });
1329    }
1330    nodes
1331}
1332
1333fn children_of(nodes: &[Node]) -> BTreeMap<String, Vec<String>> {
1334    let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
1335    for node in nodes {
1336        if let Some(parent) = &node.parent {
1337            map.entry(parent.clone()).or_default().push(node.id.clone());
1338        }
1339    }
1340    map
1341}
1342
1343fn descendants(
1344    id: &str,
1345    children: &BTreeMap<String, Vec<String>>,
1346    memo: &mut BTreeMap<String, usize>,
1347    stack: &mut BTreeSet<String>,
1348) -> usize {
1349    if let Some(n) = memo.get(id) {
1350        return *n;
1351    }
1352    if !stack.insert(id.to_string()) {
1353        return 0;
1354    }
1355    let n = children.get(id).map_or(0, |kids| {
1356        kids.iter()
1357            .map(|kid| 1 + descendants(kid, children, memo, stack))
1358            .sum()
1359    });
1360    stack.remove(id);
1361    memo.insert(id.to_string(), n);
1362    n
1363}
1364
1365fn max_round(
1366    id: &str,
1367    nodes: &[Node],
1368    children: &BTreeMap<String, Vec<String>>,
1369    memo: &mut BTreeMap<String, usize>,
1370    stack: &mut BTreeSet<String>,
1371) -> usize {
1372    if let Some(n) = memo.get(id) {
1373        return *n;
1374    }
1375    if !stack.insert(id.to_string()) {
1376        return 0;
1377    }
1378    let own = nodes
1379        .iter()
1380        .find(|n| n.id == id)
1381        .map(|n| crate::panel_round_number(&n.title).unwrap_or(0))
1382        .unwrap_or(0);
1383    let below = children.get(id).map_or(0, |kids| {
1384        kids.iter()
1385            .map(|kid| max_round(kid, nodes, children, memo, stack))
1386            .max()
1387            .unwrap_or(0)
1388    });
1389    stack.remove(id);
1390    let n = own.max(below);
1391    memo.insert(id.to_string(), n);
1392    n
1393}
1394
1395fn blocking_under(
1396    id: &str,
1397    nodes: &[Node],
1398    children: &BTreeMap<String, Vec<String>>,
1399    memo: &mut BTreeMap<String, usize>,
1400    stack: &mut BTreeSet<String>,
1401) -> usize {
1402    if let Some(n) = memo.get(id) {
1403        return *n;
1404    }
1405    if !stack.insert(id.to_string()) {
1406        return 0;
1407    }
1408    let own = nodes
1409        .iter()
1410        .find(|n| n.id == id)
1411        .map(|n| n.blocking)
1412        .unwrap_or(0);
1413    let below: usize = children.get(id).map_or(0, |kids| {
1414        kids.iter()
1415            .map(|kid| blocking_under(kid, nodes, children, memo, stack))
1416            .sum()
1417    });
1418    stack.remove(id);
1419    let n = own + below;
1420    memo.insert(id.to_string(), n);
1421    n
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427
1428    fn nit(n: usize) -> Objection {
1429        Objection::nit("ceo", &format!("comma in note {n}"), "anneal-z64x")
1430    }
1431
1432    #[test]
1433    fn the_round_comes_from_the_tracker_not_only_the_title() {
1434        // The panel eval's twelve decisions had question titles, so the
1435        // closer read round 0 and log-sink stayed TODO after three rounds.
1436        let mut v = serde_json::json!({
1437            "id": "panel-a7o4",
1438            "title": "log-sink: where the log goes",
1439            "state": "TODO",
1440            "logbook": [],
1441        });
1442        assert_eq!(sitting_round(&v), (1, true));
1443        v["logbook"] = serde_json::json!([
1444            {"note": "panel-round: 2"},
1445            {"note": "finding: keep: blocking: stderr is what ops reads :: keep stderr"},
1446            {"note": "panel-round: 1"},
1447        ]);
1448        assert_eq!(recorded_round(&v), 2);
1449        assert_eq!(sitting_round(&v), (3, true));
1450        assert!(objections_in(&v, "panel-a7o4")
1451            .iter()
1452            .all(|o| !o.text.contains("panel-round")));
1453        let mut state = PanelState::at_round(sitting_round(&v).0);
1454        state.passed = true;
1455        let disposal = dispose(&state, &ScopeBudget::panel());
1456        assert!(!disposal.close, "{disposal:?}");
1457        assert!(
1458            disposal.summary.is_empty(),
1459            "a round count does not close: {disposal:?}"
1460        );
1461        // A closed issue sits no new round.
1462        v["state"] = serde_json::json!("DONE");
1463        assert_eq!(sitting_round(&v), (2, false));
1464        // A `panel rN` title is the round `ljos workflow --round` filed.
1465        let titled = serde_json::json!({
1466            "id": "panel-hrd5",
1467            "title": "panel r3: cache place",
1468            "state": "TODO",
1469            "logbook": [{"note": "panel-round: 7"}],
1470        });
1471        assert_eq!(sitting_round(&titled), (3, false));
1472    }
1473
1474    #[test]
1475    fn a_blocking_mark_without_a_reason_is_a_nit() {
1476        let line = "finding: tester: blocking: the comma is wrong";
1477        let got = parse_objection(line, "anneal-z64x").unwrap();
1478        assert_eq!(got.severity, Severity::Nit);
1479        assert!(!got.files_as_child());
1480        let marked = parse_objection(
1481            "finding: tester: blocking: the exit status is dropped :: the gate ignores a failure @ anneal-z64x",
1482            "other",
1483        )
1484        .unwrap();
1485        assert!(marked.files_as_child());
1486        assert_eq!(marked.reason, "the exit status is dropped");
1487        assert_eq!(marked.text, "the gate ignores a failure");
1488        assert_eq!(marked.target, "anneal-z64x");
1489        let plain = parse_objection("finding: cto: heading tone", "anneal-z64x").unwrap();
1490        assert_eq!(plain.severity, Severity::Nit);
1491        assert_eq!(plain.text, "heading tone");
1492        let wording =
1493            parse_objection("finding: cto: wording: heading tone", "anneal-z64x").unwrap();
1494        assert_eq!(wording.severity, Severity::Wording);
1495        assert!(parse_objection("not a finding", "x").is_none());
1496    }
1497
1498    #[test]
1499    fn the_same_text_and_target_are_one_objection() {
1500        let a = Objection::nit("ceo", "Heading, tone!", "anneal-z64x");
1501        let b = Objection::wording("cto", "heading tone", "Anneal-z64x");
1502        assert_eq!(a.key(), b.key());
1503        let other = Objection::nit("ceo", "heading tone", "anneal-other");
1504        assert_ne!(a.key(), other.key());
1505    }
1506
1507    #[test]
1508    fn an_endless_stream_of_nits_after_a_pass_files_nothing() {
1509        let limits = ScopeBudget::panel();
1510        let mut state = PanelState::at_round(1);
1511        state.passed = true;
1512        let mut filed = 0;
1513        let mut opened = 0;
1514        let mut passed = 0;
1515        for n in 0..10 {
1516            state.objections.push(nit(n));
1517            state
1518                .objections
1519                .push(Objection::wording("cto", "heading tone", "anneal-z64x"));
1520            state.objections.push(Objection::style(
1521                "junior-dev",
1522                "whitespace in the comment",
1523                "anneal-z64x",
1524            ));
1525            match plan_round(&state, &limits) {
1526                RoundPlan::FileBlocking { objections, .. } => filed += objections.len(),
1527                RoundPlan::OpenRound { n } => {
1528                    opened += 1;
1529                    state.round = n;
1530                }
1531                RoundPlan::Passed => passed += 1,
1532                RoundPlan::Hold { why } => panic!("a pass does not hold: {why}"),
1533            }
1534        }
1535        assert_eq!(filed, 0, "nits after a pass are notes");
1536        assert_eq!(opened, 0, "a pass does not open another round");
1537        assert_eq!(passed, 10);
1538        assert_eq!(state.round, 1);
1539
1540        // Revise votes on wording, so the round has not passed. The loop
1541        // opens the next round. A round count does not close it.
1542        let mut state = PanelState::at_round(1);
1543        let mut filed = 0;
1544        let mut opened = 0;
1545        for n in 0..10 {
1546            state.objections.push(nit(n));
1547            state
1548                .objections
1549                .push(Objection::wording("cto", "heading tone", "anneal-z64x"));
1550            match plan_round(&state, &limits) {
1551                RoundPlan::FileBlocking { objections, .. } => filed += objections.len(),
1552                RoundPlan::OpenRound { n } => {
1553                    opened += 1;
1554                    state.round = n;
1555                }
1556                RoundPlan::Passed => panic!("wording alone is not a pass"),
1557                RoundPlan::Hold { why } => panic!("nits do not hold the round: {why}"),
1558            }
1559        }
1560        assert_eq!(filed, 0);
1561        assert_eq!(opened, 10);
1562        assert!(state.round > 3, "round {}", state.round);
1563    }
1564
1565    #[test]
1566    fn a_blocking_dissent_files_a_child_and_a_nit_does_not_file_a_grandchild() {
1567        let limits = ScopeBudget::panel();
1568        let mut state = PanelState::at_round(1);
1569        state.passed = true;
1570        state.objections.push(Objection::blocking(
1571            "tester",
1572            "the gate drops a real failure",
1573            "the exit status is ignored",
1574            "anneal-z64x",
1575        ));
1576        state
1577            .objections
1578            .push(Objection::nit("ceo", "comma in note 0", "anneal-z64x"));
1579        let plan = plan_round(&state, &limits);
1580        let RoundPlan::FileBlocking { objections, .. } = plan else {
1581            panic!("a blocking dissent is a child: {plan:?}");
1582        };
1583        assert_eq!(objections.len(), 1);
1584        assert!(objections[0].text.contains("exit status"));
1585
1586        let mut child = PanelState::at_round(1);
1587        child.dissent_depth = 1;
1588        child.passed = true;
1589        child
1590            .objections
1591            .push(Objection::nit("ceo", "rename the local", "anneal-child"));
1592        child
1593            .objections
1594            .push(Objection::wording("cto", "heading tone", "anneal-child"));
1595        match plan_round(&child, &limits) {
1596            RoundPlan::Passed => {}
1597            other => panic!("a dissent child does not file a nit grandchild: {other:?}"),
1598        }
1599        let mut deeper = child.clone();
1600        deeper.passed = true;
1601        deeper.objections.push(Objection::blocking(
1602            "tester",
1603            "the child still drops the status",
1604            "the wait is unchecked",
1605            "anneal-child",
1606        ));
1607        match plan_round(&deeper, &limits) {
1608            RoundPlan::FileBlocking { objections, .. } => {
1609                assert_eq!(objections.len(), 1);
1610                assert!(objections[0].text.contains("unchecked"));
1611            }
1612            other => panic!("a blocking objection on a dissent child is still a child: {other:?}"),
1613        }
1614    }
1615
1616    #[test]
1617    fn new_blocking_objections_keep_filing_past_round_three() {
1618        let limits = ScopeBudget::panel();
1619        let mut state = PanelState::at_round(1);
1620        let mut filed = Vec::new();
1621        for n in 0..6 {
1622            state.objections.push(Objection::blocking(
1623                "tester",
1624                &format!("failure {n} escapes"),
1625                &format!("bug {n} is still open"),
1626                "anneal-z64x",
1627            ));
1628            state.open_children = 0;
1629            match plan_round(&state, &limits) {
1630                RoundPlan::FileBlocking { objections, .. } => {
1631                    assert_eq!(objections.len(), 1, "{objections:?}");
1632                    let objection = &objections[0];
1633                    filed.push(objection.text.clone());
1634                    state.seen.push(objection.key());
1635                    state.children_filed += 1;
1636                    match plan_round(&state, &limits) {
1637                        RoundPlan::OpenRound { n } => state.round = n,
1638                        other => panic!("after the fix the next round opens: {other:?}"),
1639                    }
1640                }
1641                other => panic!("step {n}: {other:?}"),
1642            }
1643        }
1644        assert!(state.round > 3, "round {}", state.round);
1645        assert_eq!(filed.len(), 6, "{filed:?}");
1646        assert!(filed.iter().any(|text| text.contains("bug 5")), "{filed:?}");
1647    }
1648
1649    #[test]
1650    fn a_wall_budget_does_not_skip_a_blocking_fix() {
1651        let mut limits = ScopeBudget::panel();
1652        limits.max_wall_secs = Some(60);
1653        let mut state = PanelState::at_round(1);
1654        state.wall_secs = Some(120);
1655        state.objections.push(Objection::blocking(
1656            "tester",
1657            "a real failure",
1658            "the exit status is ignored",
1659            "anneal-z64x",
1660        ));
1661        match plan_round(&state, &limits) {
1662            RoundPlan::FileBlocking { objections, .. } => {
1663                assert_eq!(objections.len(), 1, "{objections:?}");
1664                assert!(objections[0].text.contains("exit status"));
1665            }
1666            other => panic!("a spent wall budget does not close the parent: {other:?}"),
1667        }
1668    }
1669
1670    #[test]
1671    fn a_descendant_limit_pauses_for_a_person() {
1672        let limits = ScopeBudget::panel();
1673        let mut state = PanelState::at_round(2);
1674        state.descendants = limits.max_descendants;
1675        match plan_round(&state, &limits) {
1676            RoundPlan::Hold { why } => {
1677                assert!(why.contains("person has to act"), "{why}");
1678                assert!(why.contains("descendants"), "{why}");
1679            }
1680            other => panic!("the descendant limit does not close the parent: {other:?}"),
1681        }
1682        state.descendants = limits.max_descendants - 1;
1683        state.passed = false;
1684        assert!(matches!(
1685            plan_round(&state, &limits),
1686            RoundPlan::OpenRound { .. }
1687        ));
1688    }
1689
1690    #[test]
1691    fn a_full_child_list_holds_and_leaves_the_parent_open() {
1692        let limits = ScopeBudget::panel();
1693        let mut state = PanelState::at_round(4);
1694        state.children_filed = limits.max_children;
1695        state.objections.push(Objection::blocking(
1696            "tester",
1697            "a real failure",
1698            "one more bug",
1699            "anneal-z64x",
1700        ));
1701        match plan_round(&state, &limits) {
1702            RoundPlan::Hold { why } => assert!(why.contains("children filed"), "{why}"),
1703            other => panic!("a full child list does not close the parent: {other:?}"),
1704        }
1705    }
1706
1707    #[test]
1708    fn a_settled_majority_names_only_the_minority() {
1709        let majority = vec![
1710            ("alice".to_string(), "drop".to_string()),
1711            ("bob".to_string(), "drop".to_string()),
1712            ("keeper".to_string(), "keep".to_string()),
1713        ];
1714        assert_eq!(minority_options(&majority), vec!["keep".to_string()]);
1715        let tie = vec![
1716            ("alice".to_string(), "yes".to_string()),
1717            ("bob".to_string(), "no".to_string()),
1718        ];
1719        assert!(minority_options(&tie).is_empty());
1720        let unanimous = vec![
1721            ("alice".to_string(), "ship".to_string()),
1722            ("bob".to_string(), "ship".to_string()),
1723        ];
1724        assert!(minority_options(&unanimous).is_empty());
1725    }
1726
1727    #[test]
1728    fn a_duplicate_is_not_filed_again() {
1729        let limits = ScopeBudget::panel();
1730        let mut state = PanelState::at_round(1);
1731        let objection = Objection::blocking(
1732            "tester",
1733            "the gate drops a real failure",
1734            "the exit status is ignored",
1735            "anneal-z64x",
1736        );
1737        state.seen.push(objection.key());
1738        state.objections.push(objection);
1739        state.objections.push(Objection::blocking(
1740            "cto",
1741            "the gate drops a real failure",
1742            "The exit status is ignored.",
1743            "anneal-z64x",
1744        ));
1745        let disposal = dispose(&state, &limits);
1746        assert!(disposal
1747            .fates
1748            .iter()
1749            .all(|f| matches!(f, Fate::Duplicate(_))));
1750        assert!(!disposal.close);
1751    }
1752
1753    #[test]
1754    fn a_long_blocking_finding_read_three_times_files_one_child() {
1755        let finding = "x".repeat(181);
1756        assert_eq!(finding.chars().count(), 181);
1757        let objection = Objection::blocking("tester", "this blocks merge", &finding, "panel-long");
1758        let title = dissent_title(&objection);
1759        let key = filed_key(&title, "panel-long").expect("dissent title yields a filed key");
1760        assert_eq!(key, objection.key());
1761
1762        let mut state = PanelState::at_round(1);
1763        state.objections = vec![objection.clone()];
1764        let limits = ScopeBudget::panel();
1765        let mut filed = 0;
1766        for read in 0..3 {
1767            state.seen = if read == 0 {
1768                Vec::new()
1769            } else {
1770                vec![key.clone()]
1771            };
1772            let disposal = dispose(&state, &limits);
1773            filed += disposal
1774                .fates
1775                .iter()
1776                .filter(|f| matches!(f, Fate::File(_)))
1777                .count();
1778        }
1779        assert_eq!(filed, 1);
1780    }
1781
1782    #[test]
1783    fn a_spent_budget_lists_and_does_not_ask_to_close() {
1784        let mut limits = ScopeBudget::panel();
1785        limits.max_wall_secs = Some(60);
1786        limits.max_tokens = Some(10);
1787        let mut state = PanelState::at_round(4);
1788        state.wall_secs = Some(120);
1789        state.tokens = Some(40);
1790        state.children_filed = limits.max_children;
1791        state.objections.push(Objection::blocking(
1792            "tester",
1793            "a real failure",
1794            "the exit status is ignored",
1795            "anneal-z64x",
1796        ));
1797        let disposal = dispose(&state, &limits);
1798        assert!(!disposal.close, "{disposal:?}");
1799        assert!(disposal.summary.is_empty(), "{disposal:?}");
1800        assert!(
1801            disposal
1802                .fates
1803                .iter()
1804                .any(|fate| matches!(fate, Fate::Listed(_))),
1805            "{disposal:?}"
1806        );
1807    }
1808
1809    #[test]
1810    fn toml_overlays_the_defaults_and_leaves_the_other_scope() {
1811        let budget = budget_from_toml(
1812            "[panel]\nmax_rounds = 2\nmax_tokens = 1000\n[workflow]\nmax_children = 4\n",
1813        );
1814        assert_eq!(budget.panel.max_children, ScopeBudget::panel().max_children);
1815        assert_eq!(budget.panel.max_tokens, Some(1000));
1816        assert_eq!(budget.workflow.max_children, 4);
1817        assert_eq!(
1818            budget.workflow.max_tokens,
1819            ScopeBudget::workflow().max_tokens
1820        );
1821        let ignored = budget_from_toml("[panel]\nmax_rounds = 1\n[workflow]\nmax_rounds = 9\n");
1822        assert_eq!(ignored, PanelBudget::default());
1823        assert_eq!(budget_from_toml("not toml"), PanelBudget::default());
1824    }
1825
1826    #[test]
1827    fn reap_flags_a_wide_tree_and_rounds_with_no_new_blocking_objection() {
1828        let budget = PanelBudget::default();
1829        let mut lines = Vec::new();
1830        lines.push(
1831            r#"{"id":"anneal-z64x","title":"anneal the notes","state":"STARTED","parent":""}"#
1832                .to_string(),
1833        );
1834        for n in 1..=10 {
1835            lines.push(format!(
1836                r#"{{"id":"anneal-r{n}","title":"panel r{n}: anneal","state":"DONE","parent":"anneal-z64x"}}"#
1837            ));
1838        }
1839        for n in 0..60 {
1840            lines.push(format!(
1841                r#"{{"id":"anneal-c{n}","title":"fix: comma {n}","state":"TODO","parent":"anneal-z64x"}}"#
1842            ));
1843        }
1844        // 70 descendants and round 10, with no blocking note.
1845        let flags = flag_runaways(&lines.join("\n"), &budget);
1846        let ids: Vec<&str> = flags.iter().map(|(id, _)| id.as_str()).collect();
1847        assert!(ids.contains(&"anneal-z64x"), "{flags:?}");
1848        let why = flags
1849            .iter()
1850            .filter(|(id, _)| id == "anneal-z64x")
1851            .map(|(_, why)| why.as_str())
1852            .collect::<Vec<_>>()
1853            .join(" ");
1854        assert!(why.contains("descendants"), "{why}");
1855        assert!(why.contains("round 10"), "{why}");
1856        assert!(why.contains("without a new blocking"), "{why}");
1857
1858        // One blocking objection justifies a second round. A third does not
1859        // when nothing new is blocking.
1860        let small = [
1861            r#"{"id":"p","title":"widget","state":"STARTED"}"#,
1862            r#"{"id":"p-r1","title":"panel r1: widget","state":"DONE","parent":"p","logbook":[{"note":"finding: tester: blocking: drops a failure :: exit status @ p"}]}"#,
1863            r#"{"id":"p-r2","title":"panel r2: widget","state":"DONE","parent":"p"}"#,
1864        ]
1865        .join("\n");
1866        let flags = flag_runaways(&small, &budget);
1867        assert!(
1868            flags.iter().all(|(id, _)| id != "p"),
1869            "round 2 with one blocking objection is the fix, not a runaway: {flags:?}"
1870        );
1871        let wide_round = [
1872            r#"{"id":"p","title":"widget","state":"STARTED"}"#,
1873            r#"{"id":"p-r1","title":"panel r1: widget","state":"DONE","parent":"p"}"#,
1874            r#"{"id":"p-r2","title":"panel r2: widget","state":"DONE","parent":"p"}"#,
1875            r#"{"id":"p-r3","title":"panel r3: widget","state":"DONE","parent":"p"}"#,
1876        ]
1877        .join("\n");
1878        let flags = flag_runaways(&wide_round, &budget);
1879        assert!(
1880            flags
1881                .iter()
1882                .any(|(id, why)| id == "p" && why.contains("round 3")),
1883            "{flags:?}"
1884        );
1885    }
1886}