1use std::cmp::Ordering;
10
11use crate::config::Config;
12use crate::scan::Ci;
13use crate::todo::Priority;
14
15const EPSILON: f64 = 1e-9;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
19pub enum Quadrant {
20 Q1,
21 Q2,
22 Q3,
23 Q4,
24}
25
26impl Quadrant {
27 pub const ALL: [Quadrant; 4] = [Quadrant::Q1, Quadrant::Q2, Quadrant::Q3, Quadrant::Q4];
28
29 pub fn title(self) -> &'static str {
30 match self {
31 Quadrant::Q1 => "Do right away",
32 Quadrant::Q2 => "Schedule for later",
33 Quadrant::Q3 => "Delegate or avoid",
34 Quadrant::Q4 => "Later",
35 }
36 }
37
38 pub fn parse(s: &str) -> Option<Quadrant> {
40 Quadrant::ALL
41 .into_iter()
42 .find(|q| format!("{q:?}").eq_ignore_ascii_case(s))
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Urgency {
48 Due(i64),
50 Tagged,
51 Signal,
53}
54
55#[derive(Debug, Clone)]
57pub struct Project {
58 pub name: String,
59 pub tier: u8,
60 pub open: Vec<Priority>,
61 pub idle_days: Option<i64>,
63 pub ci: Ci,
64 pub dirty: i64,
65 pub leftover: i64,
67 pub ahead: Option<i64>,
68 pub deps: Option<(i64, i64)>,
71}
72
73#[derive(Debug, Clone, PartialEq)]
74pub struct Task {
75 pub project: String,
76 pub tier: u8,
77 pub priority: Priority,
78 pub text: String,
79 pub line: Option<i64>,
81 pub key: Option<String>,
84 pub group: Option<String>,
86 pub due: Option<i64>,
88 pub tagged_urgent: bool,
89 pub signal_urgent: bool,
90 pub age_days: i64,
91 pub eligible: bool,
95}
96
97#[derive(Debug, Clone, PartialEq)]
98pub struct Placed {
99 pub task: Task,
100 pub importance: f64,
101 pub urgency: Option<Urgency>,
102 pub quadrant: Quadrant,
103}
104
105pub fn urgency(cfg: &Config, t: &Task, today: i64) -> Option<Urgency> {
106 if let Some(due) = t.due
107 && due - today <= cfg.urgent_within
108 {
109 return Some(Urgency::Due(due - today));
110 }
111 if t.tagged_urgent {
112 return Some(Urgency::Tagged);
113 }
114 t.signal_urgent.then_some(Urgency::Signal)
115}
116
117pub fn place(cfg: &Config, tasks: Vec<Task>, today: i64) -> Vec<Placed> {
120 let mut placed: Vec<Placed> = tasks
121 .into_iter()
122 .map(|task| {
123 let importance = cfg.tier(task.tier) * cfg.priority(task.priority);
124 let urgency = urgency(cfg, &task, today);
125 let quadrant = match (
126 importance + EPSILON >= cfg.important_threshold,
127 urgency.is_some(),
128 ) {
129 (true, true) => Quadrant::Q1,
130 (true, false) => Quadrant::Q2,
131 (false, true) => Quadrant::Q3,
132 (false, false) => Quadrant::Q4,
133 };
134 Placed {
135 task,
136 importance,
137 urgency,
138 quadrant,
139 }
140 })
141 .collect();
142 placed.sort_by(|a, b| {
143 a.quadrant
144 .cmp(&b.quadrant)
145 .then(b.importance.total_cmp(&a.importance))
146 .then(due_order(a.task.due, b.task.due))
147 .then(b.task.age_days.cmp(&a.task.age_days))
148 .then_with(|| (&a.task.project, a.task.line).cmp(&(&b.task.project, b.task.line)))
149 });
150 placed
151}
152
153fn due_order(a: Option<i64>, b: Option<i64>) -> Ordering {
154 match (a, b) {
155 (Some(a), Some(b)) => a.cmp(&b),
156 (Some(_), None) => Ordering::Less,
157 (None, Some(_)) => Ordering::Greater,
158 (None, None) => Ordering::Equal,
159 }
160}
161
162pub fn signal_tasks(cfg: &Config, p: &Project) -> Vec<Task> {
165 let task = |priority, text: String, urgent, age_days| Task {
166 project: p.name.clone(),
167 tier: p.tier,
168 priority,
169 text,
170 line: None,
171 key: None,
172 group: None,
173 due: None,
174 tagged_urgent: false,
175 signal_urgent: urgent,
176 age_days,
177 eligible: false,
178 };
179 let mut tasks = Vec::new();
180 if let Ci::Failing(workflows) = &p.ci {
181 tasks.push(Task {
182 key: Some("ci".into()),
183 eligible: true,
184 ..task(
185 cfg.signals.ci,
186 format!("fix CI: {}", workflows.join(", ")),
187 true,
188 0,
189 )
190 });
191 }
192 if let Some((n, _)) = p.deps.filter(|(n, _)| *n > 0) {
193 tasks.push(Task {
194 key: Some("deps".into()),
195 eligible: true,
196 ..task(
197 cfg.signals.deps,
198 format!("update dependencies: {n} outdated"),
199 false,
200 0,
201 )
202 });
203 }
204 let local = local_changes(p);
205 if !local.is_empty() {
206 tasks.push(task(
207 cfg.signals.hygiene,
208 format!("resolve local changes: {local}"),
209 false,
210 0,
211 ));
212 }
213 let horizon = cfg.activity_horizon[usize::from(p.tier) - 1];
214 match p.idle_days {
215 Some(days) if days > horizon => tasks.push(task(
216 cfg.signals.activity,
217 format!("review project: no code commits in {days} days"),
218 false,
219 days - horizon,
221 )),
222 None => tasks.push(task(
223 cfg.signals.activity,
224 "review project: no code commits found".into(),
225 false,
226 0,
227 )),
228 Some(_) => {}
229 }
230 tasks
231}
232
233fn count(n: i64, one: &str, many: &str) -> String {
235 format!("{n} {}", if n == 1 { one } else { many })
236}
237
238pub fn local_changes(p: &Project) -> String {
239 let mut parts = Vec::new();
240 if p.dirty > 0 {
241 parts.push(count(p.dirty, "changed file", "changed files"));
242 }
243 if let Some(n) = p.ahead.filter(|n| *n > 0) {
244 parts.push(count(n, "unpushed commit", "unpushed commits"));
245 }
246 if p.leftover > 0 {
247 parts.push(count(
248 p.leftover,
249 "leftover pma branch",
250 "leftover pma branches",
251 ));
252 }
253 parts.join(", ")
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
261pub enum State {
262 FailingCi,
264 Broken,
266 Unpublished,
268 Critical,
270 StaleDeps,
272 Idle,
274 Clear,
276}
277
278impl State {
279 pub fn name(self) -> &'static str {
280 match self {
281 State::FailingCi => "failing CI",
282 State::Broken => "broken",
283 State::Unpublished => "unpublished",
284 State::Critical => "critical",
285 State::StaleDeps => "stale deps",
286 State::Idle => "idle",
287 State::Clear => "clear",
288 }
289 }
290}
291
292#[derive(Debug, Clone, Copy, Default)]
295pub struct Facts<'a> {
296 pub lint_errors: i64,
297 pub scan_error: Option<&'a str>,
298}
299
300pub fn states(cfg: &Config, p: &Project, facts: Facts<'_>) -> Vec<(State, String)> {
304 let mut out = Vec::new();
305 if let Ci::Failing(workflows) = &p.ci {
306 out.push((State::FailingCi, workflows.join(", ")));
307 }
308 let mut broken = Vec::new();
309 if let Some(e) = facts.scan_error {
310 broken.push(format!("scan error: {e}"));
311 }
312 if facts.lint_errors > 0 {
313 broken.push(format!(
314 "{} in TODO.md",
315 count(facts.lint_errors, "lint error", "lint errors")
316 ));
317 }
318 if !broken.is_empty() {
319 out.push((State::Broken, broken.join("; ")));
320 }
321 let mut unpublished = Vec::new();
322 if let Some(n) = p.ahead.filter(|n| *n > 0) {
323 unpublished.push(count(n, "unpushed commit", "unpushed commits"));
324 }
325 if p.leftover > 0 {
326 unpublished.push(count(
327 p.leftover,
328 "leftover pma branch",
329 "leftover pma branches",
330 ));
331 }
332 if !unpublished.is_empty() {
333 out.push((State::Unpublished, unpublished.join(", ")));
334 }
335 let critical = p.open.iter().filter(|&&x| x == Priority::Critical).count() as i64;
336 if critical > 0 {
337 out.push((State::Critical, count(critical, "open item", "open items")));
338 }
339 if let Some((n, age)) = p.deps.filter(|(n, _)| *n > 0) {
340 let when = match age {
341 0 => "today".to_string(),
342 1 => "1 day ago".to_string(),
343 d => format!("{d} days ago"),
344 };
345 out.push((State::StaleDeps, format!("{n} outdated, measured {when}")));
346 }
347 let horizon = cfg.activity_horizon[usize::from(p.tier) - 1];
348 match p.idle_days {
349 Some(d) if d > horizon => out.push((
350 State::Idle,
351 format!("no code commit in {d} days, horizon {horizon}"),
352 )),
353 None => out.push((State::Idle, "no code commits found".into())),
354 Some(_) => {}
355 }
356 if out.is_empty() {
357 out.push((State::Clear, String::new()));
358 }
359 out
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 const TODAY: i64 = 20_000;
367
368 fn task(tier: u8, priority: Priority) -> Task {
369 Task {
370 project: "p".into(),
371 tier,
372 priority,
373 text: "t".into(),
374 line: Some(1),
375 key: Some("t".into()),
376 group: None,
377 due: None,
378 tagged_urgent: false,
379 signal_urgent: false,
380 age_days: 0,
381 eligible: false,
382 }
383 }
384
385 fn quadrant(t: Task) -> Quadrant {
386 place(&Config::default(), vec![t], TODAY)[0].quadrant
387 }
388
389 fn project(tier: u8) -> Project {
390 Project {
391 name: "p".into(),
392 tier,
393 open: vec![],
394 idle_days: Some(0),
395 ci: Ci::Passing,
396 dirty: 0,
397 leftover: 0,
398 ahead: Some(0),
399 deps: None,
400 }
401 }
402
403 #[test]
404 fn importance_follows_the_design_table() {
405 use Priority::*;
406 let important = |tier, p| quadrant(task(tier, p)) == Quadrant::Q2;
407 for tier in 1..=4 {
408 assert!(important(tier, Critical), "critical, tier {tier}");
409 }
410 assert!(!important(5, Critical));
411 assert!(
412 important(1, High) && important(2, High),
413 "0.8 * 0.5 meets 0.4 exactly"
414 );
415 assert!(!important(3, High));
416 for tier in 1..=5 {
417 assert!(!important(tier, Medium) && !important(tier, Low));
418 }
419 }
420
421 #[test]
422 fn urgency_sources() {
423 let cfg = Config::default();
424 let mut t = task(4, Priority::Low);
425
426 t.due = Some(TODAY + 7);
427 assert_eq!(urgency(&cfg, &t, TODAY), Some(Urgency::Due(7)));
428 t.due = Some(TODAY - 2);
429 assert_eq!(urgency(&cfg, &t, TODAY), Some(Urgency::Due(-2)));
430 t.due = Some(TODAY + 8);
431 assert_eq!(urgency(&cfg, &t, TODAY), None);
432
433 t.tagged_urgent = true;
434 assert_eq!(urgency(&cfg, &t, TODAY), Some(Urgency::Tagged));
435 t.tagged_urgent = false;
436 t.signal_urgent = true;
437 assert_eq!(urgency(&cfg, &t, TODAY), Some(Urgency::Signal));
438 }
439
440 #[test]
445 fn age_alone_is_not_urgency() {
446 let cfg = Config::default();
447 let aged = |tier, age_days| Task {
448 age_days,
449 ..task(tier, Priority::Low)
450 };
451 for (tier, age) in [(1, 31), (1, 400), (3, 91), (4, 5000)] {
452 assert_eq!(urgency(&cfg, &aged(tier, age), TODAY), None, "{tier} {age}");
453 }
454 let placed = place(&cfg, vec![aged(1, 10), aged(1, 90)], TODAY);
456 assert_eq!(
457 placed.iter().map(|p| p.task.age_days).collect::<Vec<_>>(),
458 [90, 10]
459 );
460 }
461
462 #[test]
463 fn ordering_within_and_across_quadrants() {
464 let mut crit = task(1, Priority::Critical);
465 crit.text = "crit".into();
466 let mut high_due_late = task(1, Priority::High);
467 high_due_late.text = "late".into();
468 high_due_late.due = Some(TODAY + 60);
469 let mut high_due_soon = task(1, Priority::High);
470 high_due_soon.text = "soon".into();
471 high_due_soon.due = Some(TODAY + 30);
472 let mut high_old = task(1, Priority::High);
473 high_old.text = "old".into();
474 high_old.age_days = 20;
475 let mut urgent_low = task(1, Priority::Low);
476 urgent_low.text = "q3".into();
477 urgent_low.tagged_urgent = true;
478 let mut fire = task(2, Priority::Critical);
479 fire.text = "q1".into();
480 fire.due = Some(TODAY);
481
482 let order: Vec<_> = place(
483 &Config::default(),
484 vec![
485 urgent_low,
486 high_old,
487 high_due_late,
488 crit,
489 high_due_soon,
490 fire,
491 ],
492 TODAY,
493 )
494 .into_iter()
495 .map(|p| (p.quadrant, p.task.text))
496 .collect();
497 assert_eq!(
498 order,
499 [
500 (Quadrant::Q1, "q1".to_string()),
501 (Quadrant::Q2, "crit".to_string()),
502 (Quadrant::Q2, "soon".to_string()),
503 (Quadrant::Q2, "late".to_string()),
504 (Quadrant::Q2, "old".to_string()),
505 (Quadrant::Q3, "q3".to_string()),
506 ]
507 );
508 }
509
510 #[test]
511 fn signal_tasks_for_ci_local_changes_and_idleness() {
512 let cfg = Config::default();
513 assert!(signal_tasks(&cfg, &project(1)).is_empty());
514
515 let p = Project {
516 ci: Ci::Failing(vec!["test".into(), "wheels".into()]),
517 dirty: 1,
518 ahead: Some(2),
519 leftover: 1,
520 idle_days: Some(31),
521 deps: Some((3, 2)),
522 ..project(1)
523 };
524 let tasks = signal_tasks(&cfg, &p);
525 let ages: Vec<i64> = tasks.iter().map(|t| t.age_days).collect();
526 assert_eq!(
527 ages,
528 [0, 0, 0, 1],
529 "an idle task is as old as the time past its horizon"
530 );
531 let got: Vec<_> = tasks
532 .iter()
533 .map(|t| (t.priority, t.text.as_str(), t.signal_urgent))
534 .collect();
535 assert_eq!(
536 got,
537 [
538 (Priority::High, "fix CI: test, wheels", true),
539 (Priority::Medium, "update dependencies: 3 outdated", false),
540 (
541 Priority::Medium,
542 "resolve local changes: 1 changed file, 2 unpushed commits, 1 leftover pma branch",
543 false
544 ),
545 (
546 Priority::Low,
547 "review project: no code commits in 31 days",
548 false
549 ),
550 ]
551 );
552 let placed = place(&cfg, tasks, TODAY);
553 assert_eq!(
554 placed[0].quadrant,
555 Quadrant::Q1,
556 "failing CI in tier 1 is important and urgent"
557 );
558
559 let tier3 = Project {
560 ci: Ci::Failing(vec!["t".into()]),
561 ..project(3)
562 };
563 assert_eq!(
564 place(&cfg, signal_tasks(&cfg, &tier3), TODAY)[0].quadrant,
565 Quadrant::Q3
566 );
567
568 let quiet = Project {
569 idle_days: None,
570 ..project(5)
571 };
572 assert_eq!(
573 signal_tasks(&cfg, &quiet)[0].text,
574 "review project: no code commits found"
575 );
576 }
577
578 #[test]
579 fn a_project_is_in_each_state_it_meets_worst_first() {
580 let cfg = Config::default();
581 let p = Project {
582 open: vec![Priority::Critical, Priority::High, Priority::Critical],
583 idle_days: Some(31),
584 ci: Ci::Failing(vec!["test".into()]),
585 dirty: 4,
586 ahead: Some(1),
587 leftover: 2,
588 deps: Some((3, 1)),
589 ..project(1)
590 };
591 let facts = Facts {
592 lint_errors: 1,
593 scan_error: None,
594 };
595 let got = states(&cfg, &p, facts);
596 assert_eq!(
597 got,
598 [
599 (State::FailingCi, "test".to_string()),
600 (State::Broken, "1 lint error in TODO.md".to_string()),
601 (
602 State::Unpublished,
603 "1 unpushed commit, 2 leftover pma branches".to_string()
604 ),
605 (State::Critical, "2 open items".to_string()),
606 (
607 State::StaleDeps,
608 "3 outdated, measured 1 day ago".to_string()
609 ),
610 (
611 State::Idle,
612 "no code commit in 31 days, horizon 30".to_string()
613 ),
614 ]
615 );
616 assert!(got.windows(2).all(|w| w[0].0 < w[1].0), "worst first");
617 }
618
619 #[test]
622 fn what_was_not_measured_places_a_project_in_no_state() {
623 let cfg = Config::default();
624 let quiet = Project {
625 dirty: 3,
626 ci: Ci::Unknown("offline".into()),
627 ..project(1)
628 };
629 assert_eq!(
630 states(&cfg, &quiet, Facts::default()),
631 [(State::Clear, String::new())]
632 );
633 let scan = Facts {
634 lint_errors: 0,
635 scan_error: Some("git status failed"),
636 };
637 assert_eq!(
638 states(&cfg, &project(1), scan)[0],
639 (State::Broken, "scan error: git status failed".to_string())
640 );
641 let never = Project {
642 idle_days: None,
643 ..project(5)
644 };
645 assert_eq!(states(&cfg, &never, Facts::default())[0].0, State::Idle);
646 }
647}