specfold_core/check/
scope.rs1use 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 All,
14 Staged,
16 Changed(String),
18 Commit(String),
20}
21
22#[derive(Debug, Clone)]
23pub struct Scope {
24 pub range: Range,
25 pub paths: BTreeSet<String>,
27 pub in_submodules: BTreeSet<String>,
30 pub touched: BTreeSet<usize>,
32 pub with_neighbours: BTreeSet<usize>,
34 pub base: Option<String>,
36 pub gone: BTreeSet<String>,
38 pub submodules_missing: Vec<String>,
40}
41
42fn 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
50fn 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 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 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 pub fn covers(&self, i: usize) -> bool {
136 self.is_all() || self.touched.contains(&i)
137 }
138
139 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}