1use 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
10pub 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
39const TEXT_WIDTH: usize = 100;
41
42const 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
54pub 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
67pub 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
100pub 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 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 pub tiered: bool,
151 pub has_todo: bool,
152 pub lint_errors: i64,
153 pub scan_error: Option<&'a str>,
154}
155
156fn 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
185fn 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
235pub 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
290fn 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
342pub 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
356pub 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
391pub 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 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 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 #[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}