Skip to main content

specfold_core/workspace/
exposure.rs

1//! Which paths of the repository get published, and why: configured public paths, public
2//! submodules from an offline visibility manifest, and what `[public] detect` finds (a docs/
3//! site, a static-site generator, an npm package or a crate that would ship a spec directory).
4
5use crate::config::Config;
6use crate::files::hash;
7use crate::workspace::owners::matcher;
8use ignore::WalkBuilder;
9use serde::Deserialize;
10use std::collections::BTreeMap;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Exposed {
14    pub path: String,
15    pub reason: String,
16}
17
18/// What a `[public] detect` entry looks for.
19pub const DETECTORS: &[&str] = &["pages", "site-generators", "npm-files", "cargo-include"];
20
21const SITE_FILES: &[&str] = &[
22    "mkdocs.yml",
23    "docusaurus.config.js",
24    "docusaurus.config.ts",
25    "_config.yml",
26    "netlify.toml",
27    "vercel.json",
28    "astro.config.mjs",
29    "hugo.toml",
30    "book.toml",
31];
32
33#[derive(Deserialize)]
34struct Manifest {
35    #[serde(rename = "gitmodulesSha256")]
36    gitmodules_sha256: String,
37    submodules: BTreeMap<String, ManifestEntry>,
38}
39
40#[derive(Deserialize)]
41struct ManifestEntry {
42    visibility: String,
43}
44
45#[derive(Debug, Default)]
46pub struct Report {
47    pub exposed: Vec<Exposed>,
48    /// Problems with the inputs themselves, as (path, message).
49    pub problems: Vec<(String, String)>,
50}
51
52fn join(dir: &str, name: &str) -> String {
53    if dir.is_empty() { name.to_string() } else { format!("{dir}/{name}") }
54}
55
56fn manifest(cfg: &Config, rel: &str, out: &mut Report) {
57    let read = std::fs::read_to_string(cfg.root.join(rel));
58    let parsed = read.ok().and_then(|t| serde_json::from_str::<Manifest>(&t).ok());
59    let Some(m) = parsed else {
60        out.problems.push((rel.to_string(), format!("{rel} is missing or not a visibility manifest")));
61        return;
62    };
63    let actual = hash::file(&cfg.root.join(".gitmodules")).map(|(sha, _)| sha).unwrap_or_default();
64    if actual != m.gitmodules_sha256 {
65        out.problems.push((rel.to_string(), format!("{rel} is older than .gitmodules; write it again")));
66    }
67    for (path, e) in m.submodules {
68        if e.visibility.eq_ignore_ascii_case("public") {
69            out.exposed.push(Exposed { path, reason: format!("a public submodule in {rel}") });
70        }
71    }
72}
73
74/// Whether an include-style list (npm `files`, Cargo `include`) takes in `spec_dir`.
75fn list_takes(list: &[String], spec_dir: &str) -> bool {
76    let probe = format!("{spec_dir}/x.md");
77    let hit = |e: &str| {
78        let e = e.trim_start_matches("./").trim_end_matches('/');
79        e == "." || e == spec_dir || matcher(e).is_some_and(|m| m.is_match(&probe))
80    };
81    let taken = list.iter().filter(|e| !e.starts_with('!')).any(|e| hit(e));
82    let dropped = list.iter().filter_map(|e| e.strip_prefix('!')).any(hit);
83    taken && !dropped
84}
85
86fn npm(cfg: &Config, dir: &str, text: &str, out: &mut Report) {
87    let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else { return };
88    if v.get("private").and_then(|p| p.as_bool()) == Some(true) {
89        return;
90    }
91    let spec = cfg.spec_dir();
92    match v.get("files").and_then(|f| f.as_array()) {
93        Some(files) => {
94            let list: Vec<String> = files.iter().filter_map(|f| f.as_str().map(str::to_string)).collect();
95            if list_takes(&list, spec) {
96                out.exposed.push(Exposed {
97                    path: join(dir, spec),
98                    reason: format!("npm files in {}", join(dir, "package.json")),
99                });
100            }
101        }
102        None => {
103            let ignore = std::fs::read_to_string(cfg.root.join(join(dir, ".npmignore"))).unwrap_or_default();
104            if !ignore.lines().any(|l| l.trim().trim_matches('/') == spec) {
105                out.exposed.push(Exposed {
106                    path: dir.to_string(),
107                    reason: format!("{} has no files list", join(dir, "package.json")),
108                });
109            }
110        }
111    }
112}
113
114fn cargo(cfg: &Config, dir: &str, text: &str, out: &mut Report) {
115    let Ok(v) = text.parse::<toml::Table>() else { return };
116    let Some(pkg) = v.get("package").and_then(|p| p.as_table()) else { return };
117    if pkg.get("publish").and_then(|p| p.as_bool()) == Some(false) {
118        return;
119    }
120    let list = |k: &str| -> Option<Vec<String>> {
121        pkg.get(k).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect())
122    };
123    let spec = cfg.spec_dir();
124    let shipped = match (list("include"), list("exclude")) {
125        (Some(inc), _) => list_takes(&inc, spec),
126        (None, Some(exc)) => !list_takes(&exc, spec),
127        (None, None) => true,
128    };
129    if shipped {
130        out.exposed
131            .push(Exposed { path: join(dir, spec), reason: format!("a published crate, {}", join(dir, "Cargo.toml")) });
132    }
133}
134
135fn detect(cfg: &Config, out: &mut Report) {
136    let on = |d: &str| cfg.raw.public.detect.iter().any(|x| x == d);
137    if on("pages") && cfg.root.join("docs").is_dir() {
138        out.exposed.push(Exposed { path: "docs".into(), reason: "docs/ is what GitHub Pages publishes".into() });
139    }
140    if !(on("site-generators") || on("npm-files") || on("cargo-include")) {
141        return;
142    }
143    for e in WalkBuilder::new(&cfg.root).hidden(false).build().flatten() {
144        if !e.file_type().is_some_and(|t| t.is_file()) {
145            continue;
146        }
147        let name = e.file_name().to_string_lossy().to_string();
148        let Some(dir) = e.path().parent().and_then(|p| p.strip_prefix(&cfg.root).ok()) else { continue };
149        let dir = dir.to_string_lossy().replace('\\', "/");
150        if on("site-generators") && SITE_FILES.contains(&name.as_str()) {
151            let path = if dir.is_empty() { ".".to_string() } else { dir.clone() };
152            out.exposed.push(Exposed { path, reason: format!("a site generator ({name})") });
153        }
154        let read = || std::fs::read_to_string(e.path()).unwrap_or_default();
155        if on("npm-files") && name == "package.json" {
156            npm(cfg, &dir, &read(), out);
157        }
158        if on("cargo-include") && name == "Cargo.toml" {
159            cargo(cfg, &dir, &read(), out);
160        }
161    }
162}
163
164pub fn scan(cfg: &Config) -> Report {
165    let mut out = Report::default();
166    for p in &cfg.raw.public.paths {
167        out.exposed.push(Exposed { path: p.clone(), reason: "listed in [public] paths".into() });
168    }
169    if let Some(rel) = &cfg.raw.public.submodules_manifest {
170        manifest(cfg, rel, &mut out);
171    }
172    for d in cfg.raw.public.detect.iter().filter(|d| !DETECTORS.contains(&d.as_str())) {
173        out.problems
174            .push(("spec.toml".into(), format!("[public] detect: no detector '{d}' ({})", DETECTORS.join(", "))));
175    }
176    detect(cfg, &mut out);
177    out.exposed.sort_by(|a, b| a.path.cmp(&b.path));
178    out.exposed.dedup_by(|a, b| a.path == b.path);
179    out
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn include_lists() {
188        let l = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
189        assert!(list_takes(&l(&["."]), ".spec"));
190        assert!(list_takes(&l(&[".spec"]), ".spec"));
191        assert!(list_takes(&l(&["**/*.md"]), ".spec"));
192        assert!(!list_takes(&l(&["dist"]), ".spec"));
193        assert!(!list_takes(&l(&[".", "!.spec"]), ".spec"));
194    }
195}