1use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::sync::Mutex;
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::time::{Duration, Instant};
13
14use crate::todo::{self, Priority, normal_text};
15
16#[derive(Debug, Clone, PartialEq)]
17pub struct Facts {
18 pub name: String,
19 pub path: PathBuf,
20 pub slug: Option<String>,
22 pub todo: Option<TodoFacts>,
24 pub todo_unread: bool,
27 pub dirty: i64,
28 pub pma_branches: Vec<String>,
30 pub ahead: Option<i64>,
32 pub last_activity: Option<i64>,
34 pub ci: Ci,
35 pub deps: Option<DepsFacts>,
37 pub error: Option<String>,
38 pub held: bool,
41}
42
43impl Facts {
44 pub fn held(name: &str, path: &Path, error: String) -> Facts {
46 Facts {
47 held: true,
48 error: Some(error),
49 ..Facts::blank(name, path)
50 }
51 }
52
53 fn blank(name: &str, path: &Path) -> Facts {
54 Facts {
55 name: name.into(),
56 path: path.into(),
57 slug: None,
58 todo: None,
59 todo_unread: false,
60 dirty: 0,
61 pma_branches: Vec::new(),
62 ahead: None,
63 last_activity: None,
64 ci: Ci::Unknown("offline".into()),
65 deps: None,
66 error: None,
67 held: false,
68 }
69 }
70}
71
72#[derive(Debug, Clone, PartialEq)]
73pub struct DepsFacts {
74 pub outdated: Option<i64>,
77 pub detail: String,
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub struct TodoFacts {
83 pub lint_errors: i64,
84 pub items: Vec<ScannedItem>,
86}
87
88#[derive(Debug, Clone, PartialEq)]
89pub struct ScannedItem {
90 pub key: String,
92 pub line: i64,
93 pub priority: Priority,
94 pub text: String,
95 pub tags: Vec<String>,
96 pub due: Option<String>,
97 pub gh: Option<i64>,
98 pub group: Option<String>,
100 pub added_at: Option<i64>,
102}
103
104#[derive(Debug, Clone, PartialEq)]
105pub enum Ci {
106 Unknown(String),
107 NoRuns,
108 Passing,
109 Failing(Vec<String>),
111}
112
113impl Ci {
114 pub fn to_columns(&self) -> (&'static str, String) {
115 match self {
116 Ci::Unknown(why) => ("unknown", why.clone()),
117 Ci::NoRuns => ("none", String::new()),
118 Ci::Passing => ("passing", String::new()),
119 Ci::Failing(names) => ("failing", names.join("\n")),
120 }
121 }
122
123 pub fn from_columns(state: &str, detail: String) -> Ci {
124 match state {
125 "none" => Ci::NoRuns,
126 "passing" => Ci::Passing,
127 "failing" => Ci::Failing(detail.lines().map(String::from).collect()),
128 _ => Ci::Unknown(detail),
129 }
130 }
131}
132
133pub fn discover(roots: &[PathBuf]) -> (Vec<(String, PathBuf)>, Vec<String>) {
136 let mut found: Vec<(String, PathBuf)> = Vec::new();
137 let mut warnings = Vec::new();
138 for root in roots {
139 let candidates: Vec<PathBuf> = if root.join(".git").exists() {
140 vec![root.clone()]
141 } else {
142 match std::fs::read_dir(root) {
143 Ok(entries) => {
144 let mut dirs: Vec<PathBuf> = entries
145 .filter_map(|e| e.ok().map(|e| e.path()))
146 .filter(|p| p.join(".git").exists())
147 .collect();
148 dirs.sort();
149 dirs
150 }
151 Err(e) => {
152 warnings.push(format!("{}: {e}", root.display()));
153 continue;
154 }
155 }
156 };
157 for path in candidates {
158 let Some(name) = path.file_name().and_then(|n| n.to_str()).map(String::from) else {
159 warnings.push(format!("{}: name is not valid UTF-8", path.display()));
160 continue;
161 };
162 if let Some((_, first)) = found.iter().find(|(n, _)| *n == name) {
163 warnings.push(format!(
164 "{}: skipped, same name as {}",
165 path.display(),
166 first.display()
167 ));
168 continue;
169 }
170 found.push((name, path));
171 }
172 }
173 (found, warnings)
174}
175
176pub fn scan_all(
181 projects: &[(String, PathBuf)],
182 ignore: &[String],
183 offline: bool,
184 deps: bool,
185 finished: &(dyn Fn(&str) + Sync),
186) -> Vec<Facts> {
187 let next = AtomicUsize::new(0);
188 let results = Mutex::new(Vec::with_capacity(projects.len()));
189 std::thread::scope(|s| {
190 for _ in 0..projects.len().clamp(1, 8) {
191 s.spawn(|| {
192 loop {
193 let i = next.fetch_add(1, Ordering::Relaxed);
194 let Some((name, path)) = projects.get(i) else {
195 break;
196 };
197 let facts = scan_project(name, path, ignore, offline, deps);
198 results.lock().unwrap().push((i, facts));
199 finished(name);
200 }
201 });
202 }
203 });
204 let mut results = results.into_inner().unwrap();
205 results.sort_by_key(|(i, _)| *i);
206 results.into_iter().map(|(_, f)| f).collect()
207}
208
209pub fn scan_project(
210 name: &str,
211 path: &Path,
212 ignore: &[String],
213 offline: bool,
214 deps: bool,
215) -> Facts {
216 let mut facts = Facts::blank(name, path);
217
218 let git = Git::new(path);
219 match std::fs::read_to_string(path.join("TODO.md")) {
220 Ok(text) => facts.todo = Some(todo_facts(&text, &added_times(&git))),
221 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
222 Err(e) => {
223 facts.todo_unread = true;
224 facts.error = Some(format!("TODO.md: {e}"));
225 }
226 }
227
228 facts.slug = git
229 .run(&["remote", "get-url", "origin"])
230 .and_then(|url| github_slug(url.trim()));
231
232 match call(git.command(&["status", "--porcelain"]), CALL_TIMEOUT) {
233 Ok(out) => facts.dirty = out.lines().count() as i64,
234 Err(e) => {
235 facts.error = Some(git.noted(Some(format!("git status: {e}"))));
236 return facts;
237 }
238 }
239 facts.pma_branches = git
240 .run(&[
241 "for-each-ref",
242 "--format=%(refname:lstrip=2)",
243 "refs/heads/pma/",
244 ])
245 .map(|out| out.lines().map(String::from).collect())
246 .unwrap_or_default();
247 facts.ahead = git
248 .run(&["rev-list", "--count", "@{u}..HEAD"])
249 .and_then(|s| s.trim().parse().ok());
250 facts.last_activity = last_activity(&git, ignore);
251 if !offline {
252 facts.ci = ci_state(path);
253 if deps {
254 facts.deps = Some(crate::deps::measure(path));
255 }
256 }
257 let noted = git.noted(facts.error.take());
258 facts.error = (!noted.is_empty()).then_some(noted);
259 facts
260}
261
262pub const CALL_TIMEOUT: Duration = Duration::from_secs(60);
265
266#[derive(Debug, Clone, PartialEq)]
268pub enum CallError {
269 TimedOut(Duration),
270 Failed(String),
272}
273
274impl std::fmt::Display for CallError {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 match self {
277 CallError::TimedOut(d) => write!(f, "timed out after {}s", d.as_secs()),
278 CallError::Failed(why) => f.write_str(why),
279 }
280 }
281}
282
283pub fn call(mut cmd: Command, timeout: Duration) -> Result<String, CallError> {
286 use std::io::Read;
287 use std::os::unix::process::CommandExt;
288 let mut child = cmd
289 .stdin(Stdio::null())
290 .stdout(Stdio::piped())
291 .stderr(Stdio::piped())
292 .process_group(0)
293 .spawn()
294 .map_err(|e| CallError::Failed(format!("{}: {e}", cmd.get_program().display())))?;
295 let drain = |pipe: Option<Box<dyn Read + Send>>| {
297 std::thread::spawn(move || {
298 let mut buf = Vec::new();
299 if let Some(mut p) = pipe {
300 let _ = p.read_to_end(&mut buf);
301 }
302 buf
303 })
304 };
305 let out = drain(
306 child
307 .stdout
308 .take()
309 .map(|p| Box::new(p) as Box<dyn Read + Send>),
310 );
311 let err = drain(
312 child
313 .stderr
314 .take()
315 .map(|p| Box::new(p) as Box<dyn Read + Send>),
316 );
317 let started = Instant::now();
318 let mut pause = Duration::from_millis(1);
319 let status = loop {
320 match child.try_wait() {
321 Ok(Some(status)) => break Some(status),
322 Ok(None) if started.elapsed() >= timeout => {
323 let group = format!("-{}", child.id());
324 let _ = Command::new("kill").args(["-KILL", "--", &group]).status();
325 let _ = child.kill();
326 let _ = child.wait();
327 break None;
328 }
329 Ok(None) => {
330 std::thread::sleep(pause);
331 pause = (pause * 2).min(Duration::from_millis(20));
332 }
333 Err(e) => return Err(CallError::Failed(e.to_string())),
334 }
335 };
336 let stdout = out.join().unwrap_or_default();
337 let stderr = err.join().unwrap_or_default();
338 match status {
339 None => Err(CallError::TimedOut(timeout)),
340 Some(s) if s.success() => Ok(String::from_utf8_lossy(&stdout).into_owned()),
341 Some(_) => Err(CallError::Failed(gh_error(&String::from_utf8_lossy(
342 &stderr,
343 )))),
344 }
345}
346
347struct Git<'a> {
351 dir: &'a Path,
352 timed_out: Mutex<Vec<String>>,
353}
354
355impl<'a> Git<'a> {
356 fn new(dir: &'a Path) -> Git<'a> {
357 Git {
358 dir,
359 timed_out: Mutex::new(Vec::new()),
360 }
361 }
362
363 fn command(&self, args: &[&str]) -> Command {
364 let mut cmd = Command::new("git");
365 cmd.arg("-C").arg(self.dir).args(args);
366 cmd
367 }
368
369 fn run(&self, args: &[&str]) -> Option<String> {
370 match call(self.command(args), CALL_TIMEOUT) {
371 Ok(out) => Some(out),
372 Err(e @ CallError::TimedOut(_)) => {
373 let what = args.first().copied().unwrap_or_default();
374 self.timed_out
375 .lock()
376 .unwrap()
377 .push(format!("git {what}: {e}"));
378 None
379 }
380 Err(CallError::Failed(_)) => None,
381 }
382 }
383
384 fn noted(&self, first: Option<String>) -> String {
386 first
387 .into_iter()
388 .chain(self.timed_out.lock().unwrap().iter().cloned())
389 .collect::<Vec<_>>()
390 .join("; ")
391 }
392}
393
394fn todo_facts(text: &str, added: &HashMap<String, i64>) -> TodoFacts {
395 let parsed = todo::parse(text);
396 let lint_errors = parsed
397 .diagnostics
398 .iter()
399 .filter(|d| d.severity == todo::Severity::Error)
400 .count();
401 let mut keys = HashSet::new();
402 let items = parsed
403 .items
404 .into_iter()
405 .filter_map(|item| {
406 if item.done {
407 return None;
408 }
409 let normal = normal_text(&item.text);
410 let key = item.key();
411 if !keys.insert(key.clone()) {
413 return None;
414 }
415 let line = item.line as i64;
416 Some(ScannedItem {
417 key,
418 line,
419 priority: item.priority,
420 text: item.text,
421 tags: item.tags,
422 due: item.due,
423 gh: item.gh.map(|n| n as i64),
424 group: item.group,
425 added_at: added.get(&normal).copied(),
426 })
427 })
428 .collect();
429 TodoFacts {
430 lint_errors: lint_errors as i64,
431 items,
432 }
433}
434
435pub fn git(dir: &Path, args: &[&str]) -> Option<String> {
437 Git::new(dir).run(args)
438}
439
440fn added_times(git: &Git) -> HashMap<String, i64> {
445 git.run(&[
446 "log",
447 "--reverse",
448 "--no-merges",
449 "--no-color",
450 "--format=%x00%ct",
451 "-p",
452 "--unified=0",
453 "--",
454 "TODO.md",
455 ])
456 .map(|log| parse_history(&log))
457 .unwrap_or_default()
458}
459
460fn parse_history(log: &str) -> HashMap<String, i64> {
461 let mut first = HashMap::new();
462 for record in log.split('\0').skip(1) {
463 let mut lines = record.lines();
464 let Some(time) = lines.next().and_then(|l| l.trim().parse::<i64>().ok()) else {
465 continue;
466 };
467 for line in lines {
468 if line.starts_with("+++ ") {
469 continue;
470 }
471 if let Some(added) = line.strip_prefix('+') {
472 let key = normalise(added);
473 if !key.is_empty() {
474 first.entry(key).or_insert(time);
475 }
476 }
477 }
478 }
479 first
480}
481
482fn normalise(line: &str) -> String {
485 let mut s = line.trim_start();
486 if s.starts_with('#') {
487 s = s.trim_start_matches('#');
488 }
489 if let Some(rest) = ["- ", "* ", "+ "].iter().find_map(|m| s.strip_prefix(m)) {
491 s = rest;
492 } else {
493 let digits = s.trim_start_matches(|c: char| c.is_ascii_digit());
494 if digits.len() < s.len()
495 && let Some(rest) = digits
496 .strip_prefix(". ")
497 .or_else(|| digits.strip_prefix(") "))
498 {
499 s = rest;
500 }
501 }
502 s = s.trim_start();
503 for mark in ["[ ]", "[x]", "[X]"] {
504 if let Some(rest) = s.strip_prefix(mark) {
505 s = rest.trim_start();
506 break;
507 }
508 }
509 let mut words: Vec<&str> = s.split_whitespace().collect();
510 while words.last().is_some_and(|w| todo::is_token(w)) {
511 words.pop();
512 }
513 normal_text(&words.join(" "))
514}
515
516fn last_activity(git: &Git, ignore: &[String]) -> Option<i64> {
517 let log = git.run(&[
518 "log",
519 "-n",
520 "1000",
521 "--no-merges",
522 "--format=%x00%ct",
523 "--name-only",
524 ])?;
525 newest_counted_commit(&log, ignore)
526}
527
528fn newest_counted_commit(log: &str, ignore: &[String]) -> Option<i64> {
529 log.split('\0').skip(1).find_map(|record| {
530 let mut lines = record.lines();
531 let time: i64 = lines.next()?.trim().parse().ok()?;
532 lines
533 .filter(|p| !p.is_empty())
534 .any(|p| !ignore.iter().any(|g| glob_match(g, p)))
535 .then_some(time)
536 })
537}
538
539pub fn glob_match(pattern: &str, path: &str) -> bool {
542 if !pattern.contains('/') {
543 return segment_match(
544 pattern.as_bytes(),
545 path.rsplit('/').next().unwrap_or(path).as_bytes(),
546 );
547 }
548 let p: Vec<&str> = pattern.split('/').collect();
549 let s: Vec<&str> = path.split('/').collect();
550 parts_match(&p, &s)
551}
552
553fn parts_match(p: &[&str], s: &[&str]) -> bool {
554 match p.split_first() {
555 None => s.is_empty(),
556 Some((&"**", rest)) => (0..=s.len()).any(|i| parts_match(rest, &s[i..])),
557 Some((first, rest)) => {
558 !s.is_empty()
559 && segment_match(first.as_bytes(), s[0].as_bytes())
560 && parts_match(rest, &s[1..])
561 }
562 }
563}
564
565fn segment_match(p: &[u8], s: &[u8]) -> bool {
566 let (mut pi, mut si, mut star, mut mark) = (0, 0, None, 0);
567 while si < s.len() {
568 if pi < p.len() && p[pi] == b'*' {
569 star = Some(pi);
570 mark = si;
571 pi += 1;
572 } else if pi < p.len() && p[pi] == s[si] {
573 pi += 1;
574 si += 1;
575 } else if let Some(st) = star {
576 pi = st + 1;
577 mark += 1;
578 si = mark;
579 } else {
580 return false;
581 }
582 }
583 p[pi..].iter().all(|&b| b == b'*')
584}
585
586pub const DEFAULT_BRANCH_UNKNOWN: &str =
587 "default branch unknown; run `git remote set-head origin -a`";
588
589pub fn default_branch(dir: &Path) -> Option<String> {
591 let full = git(
592 dir,
593 &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
594 )?;
595 Some(full.trim().trim_start_matches("origin/").to_string())
596}
597
598fn ci_state(dir: &Path) -> Ci {
602 let Some(url) = git(dir, &["remote", "get-url", "origin"]) else {
603 return Ci::Unknown("no origin remote".into());
604 };
605 let Some(slug) = github_slug(url.trim()) else {
606 return Ci::Unknown("origin is not on GitHub".into());
607 };
608 let Some(branch) = default_branch(dir) else {
609 return Ci::Unknown(DEFAULT_BRANCH_UNKNOWN.into());
610 };
611 let gh = |args: &[&str]| {
612 let mut cmd = Command::new("gh");
613 cmd.args(args);
614 call(cmd, CALL_TIMEOUT).map_err(|e| Ci::Unknown(format!("gh {}: {e}", args[..2].join(" "))))
615 };
616 let runs = |extra: &[&str]| {
617 let mut args = vec!["run", "list", "-R", &slug, "--branch", &branch];
618 args.extend_from_slice(extra);
619 args.extend([
620 "--json",
621 "workflowName,status,conclusion",
622 "--jq",
623 ".[] | [.workflowName, .status, .conclusion] | @tsv",
624 ]);
625 gh(&args).map(|tsv| latest_verdicts(&tsv))
626 };
627 let judged = (|| {
628 let listed = gh(&[
629 "workflow",
630 "list",
631 "-R",
632 &slug,
633 "--limit",
634 "200",
635 "--json",
636 "id,name,state",
637 ])?;
638 let active = active_workflows(&listed).map_err(Ci::Unknown)?;
639 let mut verdicts = runs(&["--limit", "100"])?;
640 for (id, name) in &active {
643 if !verdicts.contains_key(name) {
644 let id = id.to_string();
645 if let Some(&failed) = runs(&["--workflow", &id, "--limit", "20"])?.get(name) {
646 verdicts.insert(name.clone(), failed);
647 }
648 }
649 }
650 Ok(ci_of(&active, &verdicts))
651 })();
652 judged.unwrap_or_else(|unknown| unknown)
653}
654
655fn active_workflows(json: &str) -> Result<Vec<(u64, String)>, String> {
657 let value: serde_json::Value =
658 serde_json::from_str(json).map_err(|e| format!("gh workflow list: {e}"))?;
659 let rows = value
660 .as_array()
661 .ok_or("gh workflow list: expected an array")?;
662 Ok(rows
663 .iter()
664 .filter(|w| w["state"].as_str() == Some("active"))
665 .filter_map(|w| Some((w["id"].as_u64()?, w["name"].as_str()?.to_string())))
666 .collect())
667}
668
669fn gh_error(stderr: &str) -> String {
672 stderr
673 .lines()
674 .map(str::trim)
675 .find(|l| !l.is_empty())
676 .unwrap_or("gh failed")
677 .to_string()
678}
679
680fn failed(status: &str, conclusion: &str) -> Option<bool> {
683 if status != "completed" {
684 return None;
685 }
686 match conclusion {
687 "success" => Some(false),
688 "failure" | "timed_out" | "startup_failure" => Some(true),
689 _ => None,
690 }
691}
692
693pub fn latest_failed_run(json: &str) -> Result<Option<u64>, String> {
697 let value: serde_json::Value =
698 serde_json::from_str(json).map_err(|e| format!("gh run list: {e}"))?;
699 let runs = value.as_array().ok_or("gh run list: expected an array")?;
700 Ok(runs
701 .iter()
702 .find_map(|r| {
703 failed(r["status"].as_str()?, r["conclusion"].as_str()?)
704 .map(|f| f.then(|| r["databaseId"].as_u64()).flatten())
705 })
706 .flatten())
707}
708
709fn latest_verdicts(tsv: &str) -> HashMap<String, bool> {
713 let mut verdicts = HashMap::new();
714 for line in tsv.lines() {
715 let mut f = line.split('\t');
716 let (Some(name), Some(status), Some(conclusion)) = (f.next(), f.next(), f.next()) else {
717 continue;
718 };
719 if let Some(failed) = failed(status, conclusion) {
720 verdicts.entry(name.to_string()).or_insert(failed);
721 }
722 }
723 verdicts
724}
725
726fn ci_of(active: &[(u64, String)], verdicts: &HashMap<String, bool>) -> Ci {
729 let judged: Vec<(&String, bool)> = active
730 .iter()
731 .filter_map(|(_, name)| verdicts.get(name).map(|f| (name, *f)))
732 .collect();
733 let mut failing: Vec<String> = judged
734 .iter()
735 .filter(|(_, failed)| *failed)
736 .map(|(name, _)| (*name).clone())
737 .collect();
738 failing.sort();
739 failing.dedup();
740 match (failing.is_empty(), judged.is_empty()) {
741 (false, _) => Ci::Failing(failing),
742 (true, false) => Ci::Passing,
743 (true, true) => Ci::NoRuns,
744 }
745}
746
747pub fn github_slug(url: &str) -> Option<String> {
751 let rest = url.split_once("://").map_or(url, |(_, r)| r);
752 let path_at = rest.find('/').unwrap_or(rest.len());
754 let rest = match rest[..path_at].rfind('@') {
755 Some(at) => &rest[at + 1..],
756 None => rest,
757 };
758 let rest = rest.strip_prefix("github.com")?;
759 let rest = match rest.strip_prefix(':') {
761 Some(r)
762 if r.split('/')
763 .next()
764 .is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) =>
765 {
766 &r[r.find('/').unwrap_or(r.len())..]
767 }
768 Some(r) => r,
769 None => rest.strip_prefix('/')?,
770 };
771 let rest = rest.strip_prefix('/').unwrap_or(rest);
772 let rest = rest.trim_end_matches('/');
773 let rest = rest.strip_suffix(".git").unwrap_or(rest);
774 let mut parts = rest.split('/');
775 let (Some(owner), Some(repo), None) = (parts.next(), parts.next(), parts.next()) else {
776 return None;
777 };
778 (!owner.is_empty() && !repo.is_empty()).then(|| format!("{owner}/{repo}"))
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 #[test]
786 fn globs() {
787 for (pattern, path, want) in [
788 (".github/**", ".github/workflows/test.yml", true),
789 (".github/**", ".github", true),
790 (".github/**", "src/.github/x", false),
791 ("*.lock", "Cargo.lock", true),
792 ("*.lock", "sub/dir/uv.lock", true),
793 ("*.lock", "lock", false),
794 ("TODO.md", "TODO.md", true),
795 ("TODO.md", "docs/TODO.md", true),
796 ("docs/*.md", "docs/a.md", true),
797 ("docs/*.md", "docs/sub/a.md", false),
798 ("docs/**/*.md", "docs/sub/a.md", true),
799 ("docs/**/*.md", "docs/a.md", true),
800 ("a*b*c", "aXXbYYc", true),
801 ("a*b*c", "aXXbYY", false),
802 ] {
803 assert_eq!(glob_match(pattern, path), want, "{pattern} vs {path}");
804 }
805 }
806
807 #[test]
808 fn activity_skips_commits_that_only_touch_ignored_paths() {
809 let ignore = [".github/**".to_string(), "TODO.md".to_string()];
810 let log = "\x00300\n\nTODO.md\n\x00200\n\n.github/workflows/a.yml\n.github/workflows/b.yml\n\x00100\n\nsrc/main.rs\nTODO.md\n";
811 assert_eq!(newest_counted_commit(log, &ignore), Some(100));
812 assert_eq!(newest_counted_commit("\x00300\n\nTODO.md\n", &ignore), None);
813 assert_eq!(newest_counted_commit("", &ignore), None);
814 }
815
816 #[test]
817 fn ci_is_judged_by_each_workflows_latest_decisive_run() {
818 let active = |names: &[&str]| -> Vec<(u64, String)> {
819 names
820 .iter()
821 .enumerate()
822 .map(|(i, n)| (i as u64, n.to_string()))
823 .collect()
824 };
825 let tsv = "test\tin_progress\t\n\
826 test\tcompleted\tcancelled\n\
827 test\tcompleted\tfailure\n\
828 wheels\tcompleted\tsuccess\n\
829 wheels\tcompleted\tfailure\n\
830 test\tcompleted\tsuccess\n";
831 let verdicts = latest_verdicts(tsv);
832 assert_eq!(
833 ci_of(&active(&["test", "wheels"]), &verdicts),
834 Ci::Failing(vec!["test".into()])
835 );
836 assert_eq!(
837 ci_of(
838 &active(&["a", "b"]),
839 &latest_verdicts("a\tcompleted\tsuccess\nb\tcompleted\tskipped\n")
840 ),
841 Ci::Passing
842 );
843 assert_eq!(
844 ci_of(
845 &active(&["a"]),
846 &latest_verdicts("a\tcompleted\tcancelled\n")
847 ),
848 Ci::NoRuns
849 );
850 assert_eq!(ci_of(&active(&["a"]), &latest_verdicts("")), Ci::NoRuns);
851 }
852
853 #[test]
856 fn only_active_workflows_are_judged() {
857 let listed = r#"[{"id": 1, "name": "test", "state": "active"},
858 {"id": 2, "name": "nightly", "state": "disabled_manually"}]"#;
859 let active = active_workflows(listed).unwrap();
860 assert_eq!(active, [(1, "test".to_string())]);
861 let verdicts = latest_verdicts(
862 "old\tcompleted\tfailure\nnightly\tcompleted\tfailure\ntest\tcompleted\tsuccess\n",
863 );
864 assert_eq!(ci_of(&active, &verdicts), Ci::Passing);
865 assert!(active_workflows("{}").is_err());
866 }
867
868 #[test]
869 fn a_workflow_failed_when_its_latest_decisive_run_failed() {
870 let json = |rows: &str| format!("[{rows}]");
871 let run = |id: u64, status: &str, conclusion: &str| {
872 format!(r#"{{"databaseId":{id},"status":"{status}","conclusion":"{conclusion}"}}"#)
873 };
874 let failing = json(
875 &[
876 run(9, "in_progress", ""),
877 run(8, "completed", "cancelled"),
878 run(7, "completed", "timed_out"),
879 run(6, "completed", "success"),
880 ]
881 .join(","),
882 );
883 assert_eq!(latest_failed_run(&failing), Ok(Some(7)));
884 let fixed = json(
885 &[
886 run(5, "completed", "success"),
887 run(4, "completed", "failure"),
888 ]
889 .join(","),
890 );
891 assert_eq!(latest_failed_run(&fixed), Ok(None), "fixed since");
892 assert_eq!(latest_failed_run("[]"), Ok(None));
893 assert!(latest_failed_run("{}").is_err());
894 }
895
896 #[test]
899 fn a_call_is_killed_at_its_deadline() {
900 let sh = |script: &str| {
901 let mut cmd = Command::new("sh");
902 cmd.args(["-c", script]);
903 cmd
904 };
905 let started = Instant::now();
906 let limit = Duration::from_millis(300);
907 assert_eq!(
908 call(sh("sleep 30 & sleep 30"), limit),
909 Err(CallError::TimedOut(limit))
910 );
911 assert!(
912 started.elapsed() < Duration::from_secs(5),
913 "{:?}",
914 started.elapsed()
915 );
916 assert_eq!(call(sh("echo out"), limit), Ok("out\n".into()));
917 assert_eq!(
918 call(sh("echo why >&2; exit 3"), limit),
919 Err(CallError::Failed("why".into()))
920 );
921 assert!(matches!(
922 call(Command::new("/no/such/program"), limit),
923 Err(CallError::Failed(_))
924 ));
925 }
926
927 #[test]
928 fn gh_errors_keep_the_first_line() {
929 let unauthenticated = "To get started with GitHub CLI, please run: gh auth login\n\
930 Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.\n";
931 assert_eq!(
932 gh_error(unauthenticated),
933 "To get started with GitHub CLI, please run: gh auth login"
934 );
935 assert_eq!(gh_error("\n HTTP 404 \n"), "HTTP 404");
936 assert_eq!(gh_error(""), "gh failed");
937 }
938
939 #[test]
940 fn ci_columns_round_trip() {
941 for ci in [
942 Ci::Unknown("offline".into()),
943 Ci::NoRuns,
944 Ci::Passing,
945 Ci::Failing(vec!["a, b".into(), "c".into()]),
946 ] {
947 let (state, detail) = ci.to_columns();
948 assert_eq!(Ci::from_columns(state, detail), ci);
949 }
950 }
951
952 #[test]
953 fn github_slugs() {
954 for (url, want) in [
955 ("git@github.com:shakfu/pma.git", Some("shakfu/pma")),
956 ("https://github.com/shakfu/pma", Some("shakfu/pma")),
957 ("https://github.com/shakfu/pma.git/", Some("shakfu/pma")),
958 ("ssh://git@github.com/shakfu/pma.git", Some("shakfu/pma")),
959 ("git@gitlab.com:shakfu/pma.git", None),
960 ("https://notgithub.com/shakfu/pma", None),
961 ("https://github.com.evil.io/shakfu/pma", None),
962 ("https://me@github.com/shakfu/pma.git", Some("shakfu/pma")),
963 ("ssh://git@github.com:22/shakfu/pma.git", Some("shakfu/pma")),
964 ("https://github.com/shakfu", None),
965 ] {
966 assert_eq!(github_slug(url).as_deref(), want, "{url}");
967 }
968 }
969
970 #[test]
971 fn normalise_strips_structure_and_tokens() {
972 for (line, want) in [
973 (
974 "- [ ] Fix the Lexer #bug due:2026-01-01 gh:3",
975 "fix the lexer",
976 ),
977 ("### Phrase arrays leaked", "phrase arrays leaked"),
978 ("* [X] done thing", "done thing"),
979 ("12. [ ] numbered", "numbered"),
980 (" - plain bullet", "plain bullet"),
981 ("port C# #bindings", "port c#"),
982 ("- [ ] **Bold** lead #bugs", "**bold** lead"),
983 ("- [ ] with an id #bug ^k3f9q", "with an id"),
984 ("- [ ] bump to ^1.2", "bump to ^1.2"),
985 ("**Bold** paragraph", "**bold** paragraph"),
986 ("-dash start", "-dash start"),
987 ("", ""),
988 ] {
989 assert_eq!(normalise(line), want, "{line:?}");
990 }
991 }
992
993 #[test]
994 fn history_keeps_the_first_appearance_of_each_text() {
995 let log = "\x00100\n\ndiff --git a/TODO.md b/TODO.md\n--- /dev/null\n+++ b/TODO.md\n@@ -0,0 +1,3 @@\n+# TODO\n+### Phrase arrays leaked\n+- [ ] other\n\
996 \x00200\n\ndiff --git a/TODO.md b/TODO.md\n--- a/TODO.md\n+++ b/TODO.md\n@@ -2 +2,2 @@\n-### Phrase arrays leaked\n+- [ ] Phrase arrays leaked #tracker\n+- [ ] new\n";
997 let first = parse_history(log);
998 assert_eq!(
999 first.get("phrase arrays leaked"),
1000 Some(&100),
1001 "migrating the line keeps its age"
1002 );
1003 assert_eq!(first.get("new"), Some(&200));
1004 assert_eq!(first.get("other"), Some(&100));
1005 assert!(
1006 !first.contains_key("b/todo.md"),
1007 "diff headers are not items"
1008 );
1009 }
1010
1011 #[test]
1012 fn items_match_their_history_lines() {
1013 for line in [
1014 "- [ ] **Ctrl-C** stops `sd` #bugs",
1015 "### Phrase *arrays* leaked",
1016 "* [X] 1. odd",
1017 ] {
1018 let parsed = todo::parse(&format!("# TODO\n\n## High\n\n- [ ] {}\n", normalise(line)));
1019 let text = &parsed.items[0].text;
1020 assert_eq!(normal_text(text), normalise(line), "{line:?}");
1021 }
1022 let history = "\x00100\n+- [ ] **Ctrl-C** stops `sd`\n";
1023 let facts = todo_facts(
1024 "# TODO\n\n## High\n\n- [ ] **Ctrl-C** stops `sd` #bugs\n",
1025 &parse_history(history),
1026 );
1027 assert_eq!(facts.items[0].added_at, Some(100));
1028 }
1029
1030 #[test]
1031 fn todo_facts_keep_open_prioritised_items_once() {
1032 let text = "# TODO\n\n## High\n\n- [ ] one #a due:2026-10-01\n- [ ] linked gh:7\n- [ ] One\n- [x] finished\n";
1033 let facts = todo_facts(text, &HashMap::from([("one".to_string(), 50)]));
1034 assert_eq!(facts.lint_errors, 1, "the duplicate is a lint error");
1035 let got: Vec<_> = facts
1036 .items
1037 .iter()
1038 .map(|i| (i.key.as_str(), i.line, i.added_at))
1039 .collect();
1040 assert_eq!(got, [("one", 5, Some(50)), ("gh:7", 6, None)]);
1041 assert_eq!(facts.items[0].tags, ["a"]);
1042 assert_eq!(facts.items[0].due.as_deref(), Some("2026-10-01"));
1043 }
1044}