Skip to main content

pma_core/
scan.rs

1//! Collects facts about each project: open TODO.md items with their age,
2//! local git state, the last commit that counts as activity, and CI status.
3//!
4//! Runs the `git` and `gh` binaries, so the user's own configuration and
5//! GitHub authentication apply.
6
7use 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    /// `owner/name` from the origin URL; `None` without a GitHub origin.
21    pub slug: Option<String>,
22    /// `None` when the project has no TODO.md, or when it could not be read.
23    pub todo: Option<TodoFacts>,
24    /// TODO.md exists but could not be read. The store keeps the tasks the
25    /// last good scan recorded rather than taking the file as empty.
26    pub todo_unread: bool,
27    pub dirty: i64,
28    /// Local branches under `pma/`, each made for a run's worktree.
29    pub pma_branches: Vec<String>,
30    /// Commits not on the upstream; `None` without an upstream.
31    pub ahead: Option<i64>,
32    /// Unix time of the newest commit touching a path outside `activity.ignore`.
33    pub last_activity: Option<i64>,
34    pub ci: Ci,
35    /// `None` when this scan did not measure dependencies.
36    pub deps: Option<DepsFacts>,
37    pub error: Option<String>,
38    /// Not read, because git there would run a hook or setting a failed run
39    /// found added. The store keeps what the last scan recorded, and `error`.
40    pub held: bool,
41}
42
43impl Facts {
44    /// A project left unread, with the reason.
45    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    /// Outdated dependencies; `None` when no ecosystem applies or every
75    /// tool failed.
76    pub outdated: Option<i64>,
77    /// One `ecosystem: name current -> latest`, or `ecosystem: error: ...`, per line.
78    pub detail: String,
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub struct TodoFacts {
83    pub lint_errors: i64,
84    /// Open items in priority sections.
85    pub items: Vec<ScannedItem>,
86}
87
88#[derive(Debug, Clone, PartialEq)]
89pub struct ScannedItem {
90    /// Identity across scans: `gh:N`, or the lowercased text.
91    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    /// The nearest `###` heading above the item.
99    pub group: Option<String>,
100    /// Commit time at which the item's text first appeared in TODO.md.
101    pub added_at: Option<i64>,
102}
103
104#[derive(Debug, Clone, PartialEq)]
105pub enum Ci {
106    Unknown(String),
107    NoRuns,
108    Passing,
109    /// Workflows whose latest completed run on the default branch failed.
110    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
133/// Git repositories directly under each root, or the root itself when it is
134/// one. Names are directory names; a repeated name is reported and skipped.
135pub 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
176/// Scans projects on up to 8 threads, returning facts in input order.
177/// Dependencies are measured only with `deps`, since it takes seconds per
178/// project. `finished` is called with each project's name as it lands, from
179/// whichever thread scanned it.
180pub 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
262/// How long one `git` or `gh` call in a scan may take. One hung call would
263/// otherwise hold the whole scan.
264pub const CALL_TIMEOUT: Duration = Duration::from_secs(60);
265
266/// Why a bounded call produced no output.
267#[derive(Debug, Clone, PartialEq)]
268pub enum CallError {
269    TimedOut(Duration),
270    /// The first line of stderr, or why the program could not start.
271    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
283/// Runs `cmd` with no input and returns its stdout when it succeeds. At
284/// `timeout` its process group is killed, so a helper it started goes too.
285pub 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    // Read on threads, so a child that fills a pipe is not stalled by it.
296    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
347/// `git -C dir` for one project's scan. A call that fails reads as absent, as
348/// it always has; one that times out is also remembered, so the project's
349/// detail says the facts are incomplete rather than that they are empty.
350struct 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    /// `first`, then every timeout, joined for the project's detail.
385    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            // Duplicates are lint errors; the first occurrence stands.
412            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
435/// Runs `git -C dir`, bounded by `CALL_TIMEOUT`; stdout when it succeeds.
436pub fn git(dir: &Path, args: &[&str]) -> Option<String> {
437    Git::new(dir).run(args)
438}
439
440/// Commit time at which each item text first appeared in TODO.md, keyed by
441/// `normalise`. Matching on text rather than on lines keeps an item's age when
442/// a later commit moves it, retags it, or changes the file's format, all of
443/// which `git blame` reports as a new line.
444fn 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
482/// Reduces a raw TODO.md line to the text `normal_text` gives for the item it
483/// holds: no heading marks, list marker, checkbox or trailing tokens.
484fn normalise(line: &str) -> String {
485    let mut s = line.trim_start();
486    if s.starts_with('#') {
487        s = s.trim_start_matches('#');
488    }
489    // A marker counts only when a space follows, so `**bold**` keeps its stars.
490    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
539/// `*` matches within one path segment, `**` any number of segments. A
540/// pattern without `/` matches the file name in any directory.
541pub 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
589/// The branch `origin/HEAD` points at, without the remote prefix.
590pub 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
598/// Judged over the workflows that still exist and are enabled. `gh run list`
599/// keeps a deleted workflow's runs, whose last failure would otherwise read as
600/// failing CI until newer runs pushed it out of the window.
601fn 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        // A workflow that runs rarely can fall outside that window, so it is
641        // asked about alone rather than read as having no runs.
642        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
655/// Enabled workflows as `(id, name)`, from `gh workflow list --json id,name,state`.
656fn 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
669/// The first line of `gh`'s stderr. It names the cause; later lines add
670/// alternatives or an update notice.
671fn 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
680/// Whether a run failed: `None` for a run that is unfinished, cancelled or
681/// skipped, which does not decide a workflow's state.
682fn 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
693/// The id of a workflow's latest decisive run when that run failed, from
694/// `gh run list --json databaseId,status,conclusion`, newest first. `None`
695/// when the workflow now passes or has no decisive run.
696pub 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
709/// Reads `gh run list` rows, newest first, as `workflow \t status \t conclusion`.
710/// Each workflow is judged by its latest run that succeeded or failed; the value
711/// is whether that run failed.
712fn 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
726/// The project's CI state over its active workflows. A verdict for a workflow
727/// that is not active is ignored.
728fn 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
747/// `owner/name` when the URL's host is exactly `github.com`, in the forms git
748/// accepts: `https://[user@]github.com/o/r`, `ssh://git@github.com[:port]/o/r`
749/// and `git@github.com:o/r`.
750pub fn github_slug(url: &str) -> Option<String> {
751    let rest = url.split_once("://").map_or(url, |(_, r)| r);
752    // Drop userinfo: an `@` before the path's first `/`.
753    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    // An explicit port, as `ssh://git@github.com:22/o/r` writes it.
760    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    /// A deleted or disabled workflow keeps its runs in `gh run list`; its last
854    /// failure is not the project's CI state.
855    #[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    /// A hung `git` or `gh` would hold the whole scan. It is killed at the
897    /// deadline, with anything it started, and the call reads as timed out.
898    #[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}