Skip to main content

pma_core/
report.rs

1//! Text output for `pma matrix` and `pma status`.
2
3use crate::accept;
4use crate::class::Class;
5use crate::config::Config;
6use crate::rank::{self, Placed, Project, Quadrant, State, Urgency};
7use crate::scan::Ci;
8use crate::store::{Attempt, Run, RunState};
9
10/// Lays out rows as left-aligned columns separated by two spaces. The last
11/// column is not padded.
12pub fn table(rows: &[Vec<String>], indent: &str) -> String {
13    let cols = rows.iter().map(Vec::len).max().unwrap_or(0);
14    let widths: Vec<usize> = (0..cols)
15        .map(|c| {
16            rows.iter()
17                .filter_map(|r| r.get(c))
18                .map(|s| s.chars().count())
19                .max()
20                .unwrap_or(0)
21        })
22        .collect();
23    let mut out = String::new();
24    for row in rows {
25        let mut line = String::from(indent);
26        for (c, cell) in row.iter().enumerate() {
27            if c + 1 == row.len() {
28                line.push_str(cell);
29            } else {
30                line.push_str(&format!("{cell:<w$}  ", w = widths[c]));
31            }
32        }
33        out.push_str(line.trim_end());
34        out.push('\n');
35    }
36    out
37}
38
39/// Longest task text shown in the matrix, in characters.
40const TEXT_WIDTH: usize = 100;
41
42/// Longest group shown in the matrix, in characters.
43const GROUP_WIDTH: usize = 24;
44
45pub fn truncate(s: &str, max: usize) -> String {
46    if s.chars().count() <= max {
47        return s.to_string();
48    }
49    let mut out: String = s.chars().take(max - 3).collect();
50    out.push_str("...");
51    out
52}
53
54/// Why a task is urgent, or how far off it is.
55pub fn when(p: &Placed, today: i64) -> String {
56    match (p.urgency, p.task.due) {
57        (Some(Urgency::Due(d)), _) if d < 0 => format!("overdue {}d", -d),
58        (Some(Urgency::Due(0)), _) => "due today".into(),
59        (Some(Urgency::Due(d)), _) => format!("due in {d}d"),
60        (Some(Urgency::Tagged), _) => "#urgent".into(),
61        (Some(Urgency::Signal), _) => "signal".into(),
62        (None, Some(due)) => format!("due in {}d", due - today),
63        (None, None) => format!("open {}d", p.task.age_days),
64    }
65}
66
67/// Open tasks by age, oldest first. Age used to make a task urgent, which in
68/// a portfolio with no due dates meant "older than 30 days" and told the
69/// queue nothing. It is a list to prune, not a reason to work.
70pub fn stale(placed: &[Placed], limit: usize) -> String {
71    let mut rows: Vec<&Placed> = placed.iter().filter(|p| p.task.line.is_some()).collect();
72    if rows.is_empty() {
73        return "no open items\n".into();
74    }
75    rows.sort_by_key(|p| std::cmp::Reverse(p.task.age_days));
76    let shown = rows.len().min(limit);
77    let cells: Vec<Vec<String>> = rows[..shown]
78        .iter()
79        .map(|p| {
80            vec![
81                format!("{}:{}", p.task.project, p.task.line.unwrap_or(0)),
82                format!("T{}", p.task.tier),
83                p.task.priority.name().into(),
84                format!("open {}d", p.task.age_days),
85                truncate(&p.task.text, TEXT_WIDTH),
86            ]
87        })
88        .collect();
89    let rest = rows.len() - shown;
90    format!(
91        "{}{}",
92        table(&cells, "  "),
93        match rest {
94            0 => String::new(),
95            n => format!("  and {n} more\n"),
96        }
97    )
98}
99
100/// `limit` caps the rows shown per quadrant; the count shows the rest.
101pub fn matrix(
102    placed: &[Placed],
103    limit: Option<usize>,
104    only: Option<Quadrant>,
105    today: i64,
106) -> String {
107    let mut out = String::new();
108    for q in Quadrant::ALL {
109        if only.is_some_and(|o| o != q) {
110            continue;
111        }
112        let tasks: Vec<&Placed> = placed.iter().filter(|p| p.quadrant == q).collect();
113        let shown = limit.map_or(tasks.len(), |l| l.min(tasks.len()));
114        let count = if shown < tasks.len() {
115            format!("{}, top {shown}", tasks.len())
116        } else {
117            tasks.len().to_string()
118        };
119        if !out.is_empty() {
120            out.push('\n');
121        }
122        out.push_str(&format!("{q:?} {}: {count}\n", q.title()));
123        // The group column appears only when a shown task has a group.
124        let grouped = tasks[..shown].iter().any(|p| p.task.group.is_some());
125        let rows: Vec<Vec<String>> = tasks[..shown]
126            .iter()
127            .map(|p| {
128                let t = &p.task;
129                let place = match t.line {
130                    Some(line) => format!("{}:{line}", t.project),
131                    None => t.project.clone(),
132                };
133                let mut row = vec![place, format!("T{}", t.tier), t.priority.name().into()];
134                row.push(when(p, today));
135                if grouped {
136                    row.push(truncate(t.group.as_deref().unwrap_or(""), GROUP_WIDTH));
137                }
138                row.push(truncate(&t.text, TEXT_WIDTH));
139                row
140            })
141            .collect();
142        out.push_str(&table(&rows, "  "));
143    }
144    out
145}
146
147pub struct StatusRow<'a> {
148    pub project: &'a Project,
149    /// False for an untiered project that `status --all` ranks as tier 5.
150    pub tiered: bool,
151    pub has_todo: bool,
152    pub lint_errors: i64,
153    pub scan_error: Option<&'a str>,
154}
155
156/// Projects worst first: by the worst state each is in, then by tier, then by
157/// name. The state column names the worst, with a count of any others;
158/// `explain` lists every state and what put the project in it.
159/// The projects in status order, worst state first, each with its states.
160fn placed<'a>(
161    cfg: &Config,
162    rows: &'a [StatusRow<'a>],
163) -> Vec<(Vec<(State, String)>, &'a StatusRow<'a>)> {
164    let mut placed: Vec<(Vec<(State, String)>, &StatusRow)> = rows
165        .iter()
166        .map(|r| {
167            let facts = rank::Facts {
168                lint_errors: r.lint_errors,
169                scan_error: r.scan_error,
170            };
171            (rank::states(cfg, r.project, facts), r)
172        })
173        .collect();
174    placed.sort_by(|a, b| {
175        a.0[0]
176            .0
177            .cmp(&b.0[0].0)
178            .then(a.1.project.tier.cmp(&b.1.project.tier))
179            .then_with(|| a.1.project.name.cmp(&b.1.project.name))
180    });
181
182    placed
183}
184
185/// The cells of one status row, as `status` prints them.
186fn status_cells(states: &[(State, String)], r: &StatusRow) -> Vec<String> {
187    let p = r.project;
188    let local = [
189        (p.dirty > 0).then(|| format!("{} changed", p.dirty)),
190        p.ahead.filter(|n| *n > 0).map(|n| format!("{n} ahead")),
191        (p.leftover > 0).then(|| format!("{} leftover", p.leftover)),
192    ]
193    .into_iter()
194    .flatten()
195    .collect::<Vec<_>>()
196    .join(", ");
197    vec![
198        p.name.clone(),
199        if r.tiered {
200            p.tier.to_string()
201        } else {
202            "-".into()
203        },
204        match states.len() {
205            1 => states[0].0.name().to_string(),
206            n => format!("{} +{}", states[0].0.name(), n - 1),
207        },
208        p.open.len().to_string(),
209        p.idle_days.map_or("-".into(), |d| format!("{d}d")),
210        match &p.ci {
211            Ci::Failing(_) => "failing".to_string(),
212            Ci::Passing => "passing".into(),
213            Ci::NoRuns => "none".into(),
214            Ci::Unknown(_) => "unknown".into(),
215        },
216        p.deps.map_or("-".into(), |(n, _)| n.to_string()),
217        if local.is_empty() {
218            "clean".into()
219        } else {
220            local
221        },
222        match (r.has_todo, r.lint_errors, r.scan_error) {
223            (_, _, Some(_)) => "scan error".into(),
224            (false, _, _) => "missing".into(),
225            (true, 0, _) => "ok".into(),
226            (true, n, _) => format!("{n} lint errors"),
227        },
228    ]
229}
230
231pub const STATUS_HEADINGS: [&str; 9] = [
232    "project", "tier", "state", "open", "idle", "ci", "deps", "local", "todo",
233];
234
235/// A project's states, worst first, each with why; then its status cells.
236pub type StatusLine = (Vec<(State, String)>, Vec<String>);
237
238pub fn status_rows(cfg: &Config, rows: &[StatusRow]) -> Vec<StatusLine> {
239    placed(cfg, rows)
240        .into_iter()
241        .map(|(states, r)| {
242            let cells = status_cells(&states, r);
243            (states, cells)
244        })
245        .collect()
246}
247
248pub fn status(cfg: &Config, rows: &[StatusRow], explain: bool) -> String {
249    let placed = placed(cfg, rows);
250    let mut cells = vec![STATUS_HEADINGS.map(String::from).to_vec()];
251    for (states, r) in &placed {
252        cells.push(status_cells(states, r));
253    }
254    let mut out = table(&cells, "");
255
256    if explain {
257        for (states, r) in &placed {
258            let p = r.project;
259            out.push_str(&format!(
260                "\n{}  tier {}{}\n",
261                p.name,
262                p.tier,
263                if r.tiered { "" } else { ", untiered" },
264            ));
265            let lines: Vec<Vec<String>> = states
266                .iter()
267                .map(|(state, why)| vec![state.name().to_string(), why.clone()])
268                .collect();
269            out.push_str(&table(&lines, "  "));
270            if let Ci::Unknown(why) = &p.ci {
271                out.push_str(&format!("  CI not measured: {why}\n"));
272            }
273            if p.deps.is_none() {
274                out.push_str("  deps not measured; `pma scan --deps`\n");
275            }
276        }
277    }
278    out
279}
280
281fn verify_cell(run: &Run) -> String {
282    match (run.verify.as_deref(), run.verify_ok) {
283        (_, Some(true)) => "verify ok".into(),
284        (_, Some(false)) => "verify FAILED".into(),
285        (None, None) if run.state == RunState::Ready => "no verify".into(),
286        _ => "-".into(),
287    }
288}
289
290/// What the scope check made of the paths the run changed. An enumeration
291/// failure is reported as such: it is not a clean run.
292fn scope_cell(run: &Run, class: Class) -> String {
293    let Some(paths) = &run.changed_paths else {
294        return format!(
295            "NOT CHECKED: {}",
296            run.scope_error.as_deref().unwrap_or("paths not enumerated")
297        );
298    };
299    match class.violations(&run.scope, paths) {
300        v if v.is_empty() => format!("{} files, all permitted", paths.len()),
301        v => format!(
302            "{} of {} files OUTSIDE: {}",
303            v.len(),
304            paths.len(),
305            v.join(" ")
306        ),
307    }
308}
309
310fn verify_state(ok: Option<bool>) -> &'static str {
311    match ok {
312        Some(true) => "passed",
313        Some(false) => "FAILED",
314        None => "not run",
315    }
316}
317
318pub fn duration(seconds: Option<i64>) -> String {
319    match seconds {
320        None => "-".into(),
321        Some(s) if s < 60 => format!("{s}s"),
322        Some(s) => format!("{}m{:02}s", s / 60, s % 60),
323    }
324}
325
326fn run_cells(run: &Run) -> Vec<String> {
327    vec![
328        format!("#{}", run.id),
329        run.state.name().into(),
330        run.project.clone(),
331        verify_cell(run),
332        run.cost_usd.map_or("-".into(), |c| format!("${c:.2}")),
333        duration(run.seconds),
334        match accept::review_reasons(run).len() {
335            0 => "clean".into(),
336            n => format!("{n} to read"),
337        },
338        truncate(&run.text, 60),
339    ]
340}
341
342/// One line per finished run, for `pma dispatch`.
343pub fn run_line(run: &Run) -> String {
344    let mut line = run_cells(run).join("  ");
345    if let Some(e) = &run.error {
346        line.push_str(&format!("\n  {e}"));
347    }
348    line
349}
350
351pub fn runs(runs: &[Run]) -> String {
352    let rows: Vec<Vec<String>> = runs.iter().map(run_cells).collect();
353    table(&rows, "")
354}
355
356/// One line per attempt, oldest first. A run with a single attempt says
357/// nothing the rows above it do not, so it is left out.
358/// One row per attempt: number, outcome, verify, model, cost, time.
359pub fn attempt_rows(attempts: &[Attempt]) -> Vec<Vec<String>> {
360    attempts
361        .iter()
362        .map(|a| {
363            vec![
364                a.n.to_string(),
365                a.outcome.clone().unwrap_or_else(|| "?".into()),
366                match a.verify_ok {
367                    Some(true) => "verify passed".into(),
368                    Some(false) => "verify FAILED".into(),
369                    None => "verify not run".into(),
370                },
371                a.model.clone().unwrap_or_else(|| a.agent.clone()),
372                a.cost_usd
373                    .map_or("cost not reported".into(), |c| format!("${c:.2}")),
374                duration(a.seconds),
375            ]
376        })
377        .collect()
378}
379
380fn attempt_lines(attempts: &[Attempt]) -> String {
381    if attempts.len() < 2 {
382        return String::new();
383    }
384    let mut rows = attempt_rows(attempts);
385    for r in &mut rows {
386        r[0] = format!("  {}", r[0]);
387    }
388    format!("\nattempts:\n{}", table(&rows, ""))
389}
390
391/// `changelog` is the run's drafted entries: `None` when its project keeps
392/// no `CHANGELOG.md`, empty when the agent drafted none.
393/// A run's facts as label and value, in the order `run_detail` prints them.
394pub fn run_fields(run: &Run) -> Vec<Vec<String>> {
395    let mut rows = vec![
396        vec!["task".into(), run.text.clone()],
397        vec![
398            "agent".into(),
399            format!(
400                "{}{}, {}, {}",
401                run.agent,
402                run.model
403                    .as_deref()
404                    .map_or(String::new(), |m| format!(" {m}")),
405                run.cost_usd
406                    .map_or("cost not reported".into(), |c| format!("${c:.2}")),
407                duration(run.seconds)
408            ),
409        ],
410        vec![
411            "branch".into(),
412            format!("{} in {}", run.branch, run.worktree.display()),
413        ],
414        vec![
415            "verify".into(),
416            match &run.verify {
417                // Base and head together separate a regression from a
418                // repository that was already failing.
419                Some(v) => format!(
420                    "`{v}`: base {}, head {}",
421                    verify_state(run.verify_base_ok),
422                    verify_state(run.verify_ok)
423                ),
424                None => "none detected; set projects.<name>.verify".into(),
425            },
426        ],
427    ];
428    if let Some(c) = run.class {
429        rows.push(vec![
430            "class".into(),
431            match run.scope.as_slice() {
432                [] => format!("{}, any path", c.name()),
433                globs => format!("{}, within {}", c.name(), globs.join(" ")),
434            },
435        ]);
436        rows.push(vec!["scope".into(), scope_cell(run, c)]);
437        // A revision with no approval was in shadow, which records a route,
438        // or none, and applies nothing.
439        if let Some(rev) = run.route_revision {
440            let name = run.route.as_deref().unwrap_or("no route");
441            rows.push(vec![
442                "route".into(),
443                match run.approval {
444                    Some(a) => format!("{name} in revision {rev}, approval {}", a.name()),
445                    None => format!("{name} in revision {rev}, in shadow"),
446                },
447            ]);
448        }
449        if let Some(n) = run.complexity {
450            rows.push(vec![
451                "complexity".into(),
452                format!("{n} of 5 ({})", run.estimator.as_deref().unwrap_or("?")),
453            ]);
454        }
455    }
456    if let Some(stat) = &run.diffstat {
457        rows.push(vec!["changes".into(), stat.clone()]);
458    }
459    if let Some(n) = run.commits.filter(|n| *n > 0) {
460        rows.push(vec![
461            "commits".into(),
462            format!("{n} beyond the base; the agent was told not to commit"),
463        ]);
464    }
465    if let Some(by) = &run.approved_by {
466        rows.push(vec![
467            "approved".into(),
468            format!(
469                "by {by}, tree {}",
470                run.approved_tree
471                    .as_deref()
472                    .map_or("?", |t| &t[..t.len().min(12)])
473            ),
474        ]);
475    }
476    if let Some(s) = run.review_seconds {
477        rows.push(vec!["reviewed".into(), duration(Some(s))]);
478    }
479    for (label, value) in [
480        ("feedback", &run.feedback),
481        ("error", &run.error),
482        ("outcome", &run.outcome),
483    ] {
484        if let Some(v) = value {
485            rows.push(vec![label.into(), v.clone()]);
486        }
487    }
488    rows
489}
490
491pub fn run_detail(run: &Run, attempts: &[Attempt], changelog: Option<&str>, diff: &str) -> String {
492    let rows = run_fields(run);
493    let mut out = format!(
494        "#{} {}  {}{}\n",
495        run.id,
496        run.state.name(),
497        run.project,
498        run.quadrant
499            .as_deref()
500            .map_or(String::new(), |q| format!("  {q}"))
501    );
502    out.push_str(&table(&rows, "  "));
503    if let Some(summary) = run.summary.as_deref().filter(|s| !s.trim().is_empty()) {
504        out.push_str("\nsummary:\n");
505        for line in summary.lines() {
506            out.push_str(&format!("  {line}\n").replace("  \n", "\n"));
507        }
508    }
509    match changelog.map(str::trim) {
510        Some("") => out.push_str("\nchangelog: none drafted\n"),
511        Some(entries) => {
512            out.push_str("\nchangelog:\n");
513            for line in entries.lines() {
514                out.push_str(&format!("  {line}\n").replace("  \n", "\n"));
515            }
516        }
517        None => {}
518    }
519    let reasons = accept::review_reasons(run);
520    if reasons.is_empty() {
521        out.push_str("\naccept: every recorded gate is clean\n");
522    } else {
523        out.push_str("\nread this run because:\n");
524        for r in &reasons {
525            out.push_str(&format!("  - {r}\n"));
526        }
527    }
528    out.push_str(&attempt_lines(attempts));
529    out.push_str("\ndiff:\n");
530    out.push_str(diff);
531    if !diff.ends_with('\n') {
532        out.push('\n');
533    }
534    out
535}
536
537pub fn ago(seconds: i64) -> String {
538    match seconds {
539        s if s < 90 => "just now".into(),
540        s if s < 90 * 60 => format!("{} minutes ago", s / 60),
541        s if s < 36 * 3600 => format!("{} hours ago", s / 3600),
542        s => format!("{} days ago", s / 86_400),
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::rank::Task;
550    use crate::todo::Priority;
551
552    fn placed(
553        project: &str,
554        line: Option<i64>,
555        q: Quadrant,
556        urgency: Option<Urgency>,
557        due: Option<i64>,
558    ) -> Placed {
559        Placed {
560            task: Task {
561                project: project.into(),
562                tier: 1,
563                priority: Priority::High,
564                text: format!("text of {project}"),
565                line,
566                key: None,
567                group: None,
568                due,
569                tagged_urgent: false,
570                signal_urgent: false,
571                eligible: false,
572                age_days: 12,
573            },
574            importance: 0.5,
575            urgency,
576            quadrant: q,
577        }
578    }
579
580    #[test]
581    fn matrix_lists_quadrants_with_counts_and_limits() {
582        let rows = vec![
583            placed(
584                "alpha",
585                Some(5),
586                Quadrant::Q1,
587                Some(Urgency::Due(-3)),
588                Some(97),
589            ),
590            placed("beta", None, Quadrant::Q1, Some(Urgency::Signal), None),
591            placed("gamma", Some(9), Quadrant::Q2, None, Some(130)),
592            placed("delta", Some(2), Quadrant::Q2, None, None),
593        ];
594        let out = matrix(&rows, Some(1), None, 100);
595        assert_eq!(
596            out,
597            "Q1 Do right away: 2, top 1\n  alpha:5  T1  high  overdue 3d  text of alpha\n\
598             \nQ2 Schedule for later: 2, top 1\n  gamma:9  T1  high  due in 30d  text of gamma\n\
599             \nQ3 Delegate or avoid: 0\n\
600             \nQ4 Later: 0\n"
601        );
602        let q2 = matrix(&rows, None, Some(Quadrant::Q2), 100);
603        assert!(q2.starts_with("Q2 Schedule for later: 2\n"), "{q2}");
604        assert!(
605            q2.contains("delta:2  T1  high  open 12d    text of delta"),
606            "{q2}"
607        );
608        assert!(!q2.contains("Q1"), "{q2}");
609    }
610
611    #[test]
612    fn group_column_appears_only_when_a_shown_task_has_one() {
613        let mut a = placed("a", Some(1), Quadrant::Q2, None, None);
614        a.task.group = Some("Security Hardening".into());
615        let b = placed("b", Some(2), Quadrant::Q2, None, None);
616        let c = placed("c", Some(3), Quadrant::Q4, None, None);
617        let out = matrix(&[a, b, c], None, None, 0);
618        assert!(
619            out.contains("  a:1  T1  high  open 12d  Security Hardening  text of a\n"),
620            "{out}"
621        );
622        assert!(
623            out.contains("  b:2  T1  high  open 12d                      text of b\n"),
624            "{out}"
625        );
626        assert!(
627            out.contains("  c:3  T1  high  open 12d  text of c\n"),
628            "{out}"
629        );
630    }
631
632    #[test]
633    fn long_text_is_truncated() {
634        assert_eq!(truncate("abcdef", 6), "abcdef");
635        assert_eq!(truncate("abcdefg", 6), "abc...");
636        let mut long = placed("p", Some(1), Quadrant::Q1, Some(Urgency::Tagged), None);
637        long.task.text = "x".repeat(150);
638        let out = matrix(&[long], None, Some(Quadrant::Q1), 0);
639        assert!(out.contains(&format!("{}...\n", "x".repeat(97))), "{out}");
640    }
641
642    /// Worst state first, then tier: a tier-2 project with failing CI comes
643    /// before a tier-1 project with unpublished work, which comes before a
644    /// clear one. The state column names the worst and counts the rest.
645    #[test]
646    fn status_sorts_by_the_worst_state_then_tier() {
647        let cfg = Config::default();
648        let quiet = Project {
649            name: "quiet".into(),
650            tier: 1,
651            open: vec![],
652            idle_days: Some(1),
653            ci: Ci::Passing,
654            dirty: 0,
655            leftover: 0,
656            ahead: Some(0),
657            deps: None,
658        };
659        let busy = Project {
660            name: "busy".into(),
661            open: vec![Priority::Critical],
662            ci: Ci::Unknown("offline".into()),
663            dirty: 2,
664            ahead: Some(1),
665            deps: Some((3, 0)),
666            ..quiet.clone()
667        };
668        let red = Project {
669            name: "red".into(),
670            tier: 2,
671            ci: Ci::Failing(vec!["test".into()]),
672            ..quiet.clone()
673        };
674        let row = |project| StatusRow {
675            project,
676            tiered: true,
677            has_todo: true,
678            lint_errors: 0,
679            scan_error: None,
680        };
681        let out = status(&cfg, &[row(&quiet), row(&busy), row(&red)], true);
682        let lines: Vec<&str> = out.lines().collect();
683        assert_eq!(
684            lines[..4],
685            [
686                "project  tier  state           open  idle  ci       deps  local               todo",
687                "red      2     failing CI      0     1d    failing  -     clean               ok",
688                "busy     1     unpublished +2  1     1d    unknown  3     2 changed, 1 ahead  ok",
689                "quiet    1     clear           0     1d    passing  -     clean               ok",
690            ]
691        );
692        assert!(
693            out.contains(
694                "\nbusy  tier 1\n  unpublished  1 unpushed commit\n  critical     1 open item\n  \
695                 stale deps   3 outdated, measured today\n  CI not measured: offline\n"
696            ),
697            "{out}"
698        );
699        assert!(
700            out.contains("\nquiet  tier 1\n  clear\n  deps not measured; `pma scan --deps`\n"),
701            "{out}"
702        );
703    }
704}