use crate::corpus::Corpus;
use crate::graph::Graph;
use crate::workspace::vcs;
use anyhow::{Result, anyhow};
use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub enum Range {
All,
Staged,
Changed(String),
Commit(String),
}
#[derive(Debug, Clone)]
pub struct Scope {
pub range: Range,
pub paths: BTreeSet<String>,
pub in_submodules: BTreeSet<String>,
pub touched: BTreeSet<usize>,
pub with_neighbours: BTreeSet<usize>,
pub base: Option<String>,
pub gone: BTreeSet<String>,
pub submodules_missing: Vec<String>,
}
fn slug_of(p: &str) -> Option<&str> {
let mut parts = p.rsplit('/');
let file = parts.next()?;
let stem = file.split('.').next()?;
if stem == "index" || stem == "README" { parts.next() } else { Some(stem) }
}
fn dangling(c: &Corpus, g: &Graph, slugs: &BTreeSet<&str>) -> Vec<usize> {
use crate::graph::{DOC_RELATIONS, Resolution};
let names = |t: &str| {
let t = t.split('#').next().unwrap_or(t).trim_end_matches('/');
let last = t.rsplit(['/', ':']).next().unwrap_or(t);
slugs.contains(last) || slug_of(t).is_some_and(|s| slugs.contains(s))
};
let missing = |t: &str| names(t) && matches!(g.resolve(t), Resolution::Missing);
let mut out = vec![];
for (i, d) in c.docs.iter().enumerate() {
let rel = d.rel().into_iter().filter(|(k, _)| DOC_RELATIONS.contains(&k.as_str())).flat_map(|(_, ts)| ts);
let mut targets = rel.chain(d.links.iter().map(|l| l.item.clone()));
if targets.any(|t| missing(&t)) {
out.push(i);
}
}
out
}
impl Scope {
pub fn build(c: &Corpus, g: &Graph, range: Range) -> Result<Self> {
let root = &c.cfg.root;
let (mut paths, sub, base): (BTreeSet<String>, vcs::Expansion, _) = match &range {
Range::All => (BTreeSet::new(), vcs::Expansion::default(), None),
Range::Staged => {
let p = vcs::staged(root).ok_or_else(|| anyhow!("--staged needs a git repository"))?;
let sub = vcs::expand_submodules(root, vcs::Side::Staged);
(p.into_iter().collect(), sub, Some("HEAD".to_string()))
}
Range::Changed(base) => {
let p = vcs::changed_since(root, base).ok_or_else(|| anyhow!("cannot diff against {base}"))?;
let sub = vcs::expand_submodules(root, vcs::Side::Since(base));
(p.into_iter().collect(), sub, Some(base.clone()))
}
Range::Commit(sha) => {
let r = vcs::commit_diff(root, sha);
let p = vcs::changed_in_range(root, &r)
.ok_or_else(|| anyhow!("cannot read commit {sha}; next: git log --oneline"))?;
(p.into_iter().collect(), vcs::Expansion::default(), Some(vcs::parent_or_empty(root, sha)))
}
};
for g in &sub.expanded {
paths.remove(g);
}
let in_submodules: BTreeSet<String> = sub.files.into_iter().collect();
paths.extend(in_submodules.iter().cloned());
let gone: BTreeSet<String> = match &range {
Range::All => BTreeSet::new(),
Range::Staged => vcs::gone(root, None).unwrap_or_default().into_iter().collect(),
Range::Changed(base) => vcs::gone(root, Some(base)).unwrap_or_default().into_iter().collect(),
Range::Commit(sha) => {
vcs::gone(root, Some(&vcs::commit_diff(root, sha))).unwrap_or_default().into_iter().collect()
}
};
let mut touched = BTreeSet::new();
if !matches!(range, Range::All) {
for (i, d) in c.docs.iter().enumerate() {
let own = std::iter::once(&d.path).chain(&d.chapters).chain(&d.bundle_files);
if own.into_iter().any(|p| paths.contains(p)) {
touched.insert(i);
}
}
}
let mut with_neighbours = touched.clone();
for &i in &touched {
for e in g.edges_to(i) {
with_neighbours.insert(e.from);
}
}
let slugs: BTreeSet<&str> = gone.iter().filter(|p| p.ends_with(".md")).filter_map(|p| slug_of(p)).collect();
if !slugs.is_empty() {
with_neighbours.extend(dangling(c, g, &slugs));
}
Ok(Scope { range, paths, in_submodules, touched, with_neighbours, base, gone, submodules_missing: sub.missing })
}
pub fn is_all(&self) -> bool {
matches!(self.range, Range::All)
}
pub fn covers(&self, i: usize) -> bool {
self.is_all() || self.touched.contains(&i)
}
pub fn covers_links(&self, i: usize) -> bool {
self.is_all() || self.with_neighbours.contains(&i)
}
pub fn covers_path(&self, p: &str) -> bool {
self.is_all() || self.paths.contains(p)
}
}