Skip to main content

specfold_core/check/
scope.rs

1//! Which documents a run looks at: all of them, or the ones touched by a change and the
2//! documents that point at them.
3
4use crate::corpus::Corpus;
5use crate::graph::Graph;
6use crate::workspace::vcs;
7use anyhow::{Result, anyhow};
8use std::collections::BTreeSet;
9
10#[derive(Debug, Clone)]
11pub enum Range {
12    /// Everything.
13    All,
14    /// The index (pre-commit).
15    Staged,
16    /// Working tree and untracked files against a base revision (pre-push).
17    Changed(String),
18    /// What one commit changed (`<sha>^..<sha>`), judged on the files as they are now (C3).
19    Commit(String),
20}
21
22#[derive(Debug, Clone)]
23pub struct Scope {
24    pub range: Range,
25    /// Changed repository paths; empty for `All`.
26    pub paths: BTreeSet<String>,
27    /// The part of `paths` inside submodules whose gitlink the change moves; the outer index
28    /// cannot show them, so a staged run reads them from the submodule's checkout.
29    pub in_submodules: BTreeSet<String>,
30    /// Indices of documents whose own files changed.
31    pub touched: BTreeSet<usize>,
32    /// `touched` plus documents that link to them.
33    pub with_neighbours: BTreeSet<usize>,
34    /// The revision to compare against for transition checks.
35    pub base: Option<String>,
36    /// Paths the change deletes or renames away; they are not in `paths`.
37    pub gone: BTreeSet<String>,
38    /// Moved gitlinks whose submodule objects are missing; they stand for the whole directory.
39    pub submodules_missing: Vec<String>,
40}
41
42/// The slug a document file carries: `sot/cart.sot.md` and `sot/cart/index.md` give `cart`.
43fn slug_of(p: &str) -> Option<&str> {
44    let mut parts = p.rsplit('/');
45    let file = parts.next()?;
46    let stem = file.split('.').next()?;
47    if stem == "index" || stem == "README" { parts.next() } else { Some(stem) }
48}
49
50/// Documents with a relation or link that now resolves to nothing and names a deleted slug or path.
51fn dangling(c: &Corpus, g: &Graph, slugs: &BTreeSet<&str>) -> Vec<usize> {
52    use crate::graph::{DOC_RELATIONS, Resolution};
53    let names = |t: &str| {
54        let t = t.split('#').next().unwrap_or(t).trim_end_matches('/');
55        let last = t.rsplit(['/', ':']).next().unwrap_or(t);
56        slugs.contains(last) || slug_of(t).is_some_and(|s| slugs.contains(s))
57    };
58    let missing = |t: &str| names(t) && matches!(g.resolve(t), Resolution::Missing);
59    let mut out = vec![];
60    for (i, d) in c.docs.iter().enumerate() {
61        let rel = d.rel().into_iter().filter(|(k, _)| DOC_RELATIONS.contains(&k.as_str())).flat_map(|(_, ts)| ts);
62        let mut targets = rel.chain(d.links.iter().map(|l| l.item.clone()));
63        if targets.any(|t| missing(&t)) {
64            out.push(i);
65        }
66    }
67    out
68}
69
70impl Scope {
71    pub fn build(c: &Corpus, g: &Graph, range: Range) -> Result<Self> {
72        let root = &c.cfg.root;
73        let (mut paths, sub, base): (BTreeSet<String>, vcs::Expansion, _) = match &range {
74            Range::All => (BTreeSet::new(), vcs::Expansion::default(), None),
75            Range::Staged => {
76                let p = vcs::staged(root).ok_or_else(|| anyhow!("--staged needs a git repository"))?;
77                let sub = vcs::expand_submodules(root, vcs::Side::Staged);
78                (p.into_iter().collect(), sub, Some("HEAD".to_string()))
79            }
80            Range::Changed(base) => {
81                let p = vcs::changed_since(root, base).ok_or_else(|| anyhow!("cannot diff against {base}"))?;
82                let sub = vcs::expand_submodules(root, vcs::Side::Since(base));
83                (p.into_iter().collect(), sub, Some(base.clone()))
84            }
85            Range::Commit(sha) => {
86                let r = vcs::commit_diff(root, sha);
87                let p = vcs::changed_in_range(root, &r)
88                    .ok_or_else(|| anyhow!("cannot read commit {sha}; next: git log --oneline"))?;
89                (p.into_iter().collect(), vcs::Expansion::default(), Some(vcs::parent_or_empty(root, sha)))
90            }
91        };
92        // C1: a moved pointer whose inside was read is not itself a change.
93        for g in &sub.expanded {
94            paths.remove(g);
95        }
96        let in_submodules: BTreeSet<String> = sub.files.into_iter().collect();
97        paths.extend(in_submodules.iter().cloned());
98        let gone: BTreeSet<String> = match &range {
99            Range::All => BTreeSet::new(),
100            Range::Staged => vcs::gone(root, None).unwrap_or_default().into_iter().collect(),
101            Range::Changed(base) => vcs::gone(root, Some(base)).unwrap_or_default().into_iter().collect(),
102            Range::Commit(sha) => {
103                vcs::gone(root, Some(&vcs::commit_diff(root, sha))).unwrap_or_default().into_iter().collect()
104            }
105        };
106        let mut touched = BTreeSet::new();
107        if !matches!(range, Range::All) {
108            for (i, d) in c.docs.iter().enumerate() {
109                let own = std::iter::once(&d.path).chain(&d.chapters).chain(&d.bundle_files);
110                if own.into_iter().any(|p| paths.contains(p)) {
111                    touched.insert(i);
112                }
113            }
114        }
115        let mut with_neighbours = touched.clone();
116        for &i in &touched {
117            for e in g.edges_to(i) {
118                with_neighbours.insert(e.from);
119            }
120        }
121        // N2: a deleted document is no longer in the corpus, so its citers cannot be found
122        // through edges; one pass over every link finds the ones that now point nowhere.
123        let slugs: BTreeSet<&str> = gone.iter().filter(|p| p.ends_with(".md")).filter_map(|p| slug_of(p)).collect();
124        if !slugs.is_empty() {
125            with_neighbours.extend(dangling(c, g, &slugs));
126        }
127        Ok(Scope { range, paths, in_submodules, touched, with_neighbours, base, gone, submodules_missing: sub.missing })
128    }
129
130    pub fn is_all(&self) -> bool {
131        matches!(self.range, Range::All)
132    }
133
134    /// Whether checks about document `i` itself run.
135    pub fn covers(&self, i: usize) -> bool {
136        self.is_all() || self.touched.contains(&i)
137    }
138
139    /// Whether checks about links from document `i` run (they may break when a target moves).
140    pub fn covers_links(&self, i: usize) -> bool {
141        self.is_all() || self.with_neighbours.contains(&i)
142    }
143
144    pub fn covers_path(&self, p: &str) -> bool {
145        self.is_all() || self.paths.contains(p)
146    }
147}