Skip to main content

specfold_core/
remotes.rs

1//! Documents in other repositories. `[remotes.<name>] path = "../shared"` lets a reference or a
2//! relation name a document there as `<name>:<id>` (`shared:adr:org/20261030000000`). The other
3//! repository is read from a directory already on disk and never written. A remote whose
4//! directory is absent (a CI job without that checkout) is skipped, not reported; a remote that
5//! is present but lacks the id is an error like any missing document.
6
7use crate::config::Config;
8use crate::corpus::{Corpus, LoadOptions};
9use crate::graph::{Graph, Resolution};
10use crate::model::RefText;
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::{Arc, Mutex, OnceLock};
14
15/// What `<name>:<id>` names.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Remote {
18    /// Not a remote reference: no configured remote of that name.
19    Local,
20    /// The remote is configured but its directory is not on disk.
21    Absent {
22        name: String,
23        path: PathBuf,
24    },
25    /// One document: its id, type, title and absolute file path.
26    Found {
27        name: String,
28        id: String,
29        ty: String,
30        title: String,
31        file: PathBuf,
32    },
33    Missing {
34        name: String,
35        id: String,
36    },
37    MissingAnchor {
38        name: String,
39        id: String,
40        anchor: String,
41    },
42    Ambiguous {
43        name: String,
44        ids: Vec<String>,
45    },
46}
47
48pub struct Loaded {
49    pub corpus: Corpus,
50    pub graph: Graph,
51}
52
53type Cache = Mutex<HashMap<PathBuf, Option<Arc<Loaded>>>>;
54
55fn cache() -> &'static Cache {
56    static C: OnceLock<Cache> = OnceLock::new();
57    C.get_or_init(Default::default)
58}
59
60/// The remote a reference names, and the rest of the reference. A configured type name is never a
61/// remote, so `adr:x/y` stays a local id even with a remote called `adr`.
62pub fn split<'a>(cfg: &Config, text: &'a str) -> Option<(&'a str, &'a str)> {
63    let (name, rest) = text.trim().split_once(':')?;
64    (cfg.raw.remotes.contains_key(name) && cfg.registry.get(name).is_none()).then_some((name, rest))
65}
66
67/// The remote's directory: the first of `path` and `paths` on disk, else `path`.
68pub fn dir(cfg: &Config, name: &str) -> Option<PathBuf> {
69    let r = cfg.raw.remotes.get(name)?;
70    let first = cfg.expand_path(&r.path);
71    Some(r.paths.iter().map(|p| cfg.expand_path(p)).find(|p| p.is_dir()).filter(|_| !first.is_dir()).unwrap_or(first))
72}
73
74/// A governed path that belongs to another project: written `<remote>:<path>`, or inside a
75/// submodule (gitlink) or a remote checked out within this repository. P1 and L5 leave it out;
76/// D1 still matches changes under it.
77pub fn is_external(cfg: &Config, governs: &str) -> bool {
78    if split(cfg, governs).is_some() {
79        return true;
80    }
81    let g = governs.trim_start_matches("./").trim_end_matches('/');
82    let under = |p: &str| {
83        let p = p.trim_start_matches("./").trim_end_matches('/');
84        !p.is_empty() && (g == p || g.starts_with(&format!("{p}/")))
85    };
86    inner_projects(cfg).iter().any(|p| under(p))
87}
88
89/// A1: the auto-registered submodule remote whose directory holds `rel`, and the path inside it.
90pub fn submodule_at(cfg: &Config, rel: &str) -> Option<(String, String)> {
91    cfg.raw.remotes.iter().filter(|(_, r)| r.auto).find_map(|(name, r)| {
92        let p = r.path.trim_end_matches('/');
93        let inner = if rel == p { "" } else { rel.strip_prefix(p)?.strip_prefix('/')? };
94        Some((name.clone(), inner.to_string()))
95    })
96}
97
98/// Submodule paths and remotes checked out inside this repository, once per root.
99fn inner_projects(cfg: &Config) -> Vec<String> {
100    static C: OnceLock<Mutex<HashMap<PathBuf, Vec<String>>>> = OnceLock::new();
101    let mut c = C.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
102    c.entry(cfg.root.clone())
103        .or_insert_with(|| {
104            let mut v = crate::workspace::vcs::gitlinks(&cfg.root);
105            for r in cfg.raw.remotes.values() {
106                for p in std::iter::once(&r.path).chain(&r.paths) {
107                    if !p.starts_with("..") && !p.starts_with('/') && !p.starts_with('~') {
108                        v.push(p.clone());
109                    }
110                }
111            }
112            v
113        })
114        .clone()
115}
116
117/// Loads the remote corpus once per process; `None` when the directory is absent.
118pub fn load(cfg: &Config, name: &str) -> Option<Arc<Loaded>> {
119    let dir = dir(cfg, name)?;
120    let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
121    c.entry(dir.clone())
122        .or_insert_with(|| {
123            if !dir.is_dir() {
124                return None;
125            }
126            let start = std::time::Instant::now();
127            let rc = Config::discover(&dir).ok()?;
128            let corpus = Corpus::load(rc, &LoadOptions { include_local: false });
129            let graph = Graph::build(&corpus);
130            if crate::corpus::trace::enabled() {
131                let ms = start.elapsed().as_secs_f64() * 1000.0;
132                eprintln!("specfold:   remote {name:<12} {ms:>7.1} ms, {} documents", corpus.docs.len());
133            }
134            Some(Arc::new(Loaded { corpus, graph }))
135        })
136        .clone()
137}
138
139/// Resolves `<name>:<id>[#anchor]` against the remote repository.
140pub fn resolve(cfg: &Config, text: &str) -> Remote {
141    let Some((name, rest)) = split(cfg, text) else { return Remote::Local };
142    let name = name.to_string();
143    let Some(l) = load(cfg, &name) else {
144        return Remote::Absent { path: dir(cfg, &name).unwrap_or_default(), name };
145    };
146    let id = RefText::parse(rest).target;
147    match l.graph.resolve(rest) {
148        Resolution::Doc { idx, .. } => {
149            let d = &l.corpus.docs[idx];
150            Remote::Found {
151                name,
152                id: d.id.to_string(),
153                ty: d.id.ty.clone(),
154                title: d.title.clone().unwrap_or_default(),
155                file: l.corpus.cfg.root.join(&d.path),
156            }
157        }
158        Resolution::MissingAnchor { anchor, .. } => Remote::MissingAnchor { name, id, anchor },
159        Resolution::Ambiguous(list) => {
160            Remote::Ambiguous { name, ids: list.iter().map(|&i| l.corpus.docs[i].id.to_string()).collect() }
161        }
162        Resolution::Missing => Remote::Missing { name, id },
163    }
164}
165
166/// The id and source revision (see `translation::rev_of`) of the remote document `<name>:<id>`,
167/// or `None` when it is not a remote reference that resolves.
168pub fn rev(cfg: &Config, text: &str) -> Option<(String, String)> {
169    let (name, rest) = split(cfg, text)?;
170    let l = load(cfg, name)?;
171    match l.graph.resolve(rest) {
172        Resolution::Doc { idx, .. } => {
173            Some((format!("{name}:{}", l.corpus.docs[idx].id), crate::translation::rev_of(&l.corpus, idx)?))
174        }
175        _ => None,
176    }
177}
178/// F2: the status of the remote document `<name>:<id>` and, when another document there
179/// supersedes it, that document's id (`<name>:<id>`).
180pub fn status(cfg: &Config, text: &str) -> Option<(String, Option<String>)> {
181    let (name, rest) = split(cfg, text)?;
182    let l = load(cfg, name)?;
183    let Resolution::Doc { idx, .. } = l.graph.resolve(rest) else { return None };
184    let me = l.corpus.docs[idx].id.to_string();
185    let by = l.corpus.docs.iter().find(|d| d.rel().get("supersedes").is_some_and(|v| v.contains(&me)));
186    Some((l.corpus.docs[idx].status().unwrap_or_default(), by.map(|d| format!("{name}:{}", d.id))))
187}
188
189/// A document in this repository or a remote that names another repository's document.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct Consumer {
192    /// `.` for this repository, else the remote's name.
193    pub repo: String,
194    pub id: String,
195    pub path: String,
196    /// `rel.<kind>` or `pins`.
197    pub via: String,
198}
199
200/// The documents, here and in every remote on disk, whose relations or pins name the document at
201/// `target` (an absolute file path) through one of their own remotes. Only frontmatter is read;
202/// a remote's own remotes are resolved from directories already on disk, never fetched.
203pub fn consumers(cfg: &Config, here: &Corpus, target: &std::path::Path) -> Vec<Consumer> {
204    let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
205    let target = canon(target);
206    let mut repos: Vec<(String, Arc<Loaded>)> = vec![];
207    for name in cfg.raw.remotes.keys() {
208        if let Some(l) = load(cfg, name) {
209            repos.push((name.clone(), l));
210        }
211    }
212    let mut out = vec![];
213    let mut scan = |repo: &str, c: &Corpus| {
214        for d in &c.docs {
215            let mut refs: Vec<(String, String)> =
216                d.rel().into_iter().flat_map(|(k, vs)| vs.into_iter().map(move |v| (format!("rel.{k}"), v))).collect();
217            if let Some(serde_json::Value::Object(m)) = d.fm().and_then(|m| m.get("pins")) {
218                refs.extend(m.keys().map(|k| ("pins".to_string(), k.clone())));
219            }
220            for (via, v) in refs {
221                if let Remote::Found { file, .. } = resolve(&c.cfg, &v)
222                    && canon(&file) == target
223                {
224                    let u = Consumer { repo: repo.to_string(), id: d.id.to_string(), path: d.path.clone(), via };
225                    if !out.contains(&u) {
226                        out.push(u);
227                    }
228                }
229            }
230        }
231    };
232    scan(".", here);
233    for (name, l) in &repos {
234        scan(name, &l.corpus);
235    }
236    out
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::config::types::RawConfig;
243
244    fn write(p: &std::path::Path, s: &str) {
245        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
246        std::fs::write(p, s).unwrap();
247    }
248
249    #[test]
250    fn resolves_ids_in_a_sibling_repository() {
251        let tmp = tempfile::tempdir().unwrap();
252        let shared = tmp.path().join("shared");
253        write(&shared.join("spec.toml"), "[spec]\nschema = 1\n");
254        write(
255            &shared.join(".spec/guide/setup.guide.md"),
256            "---\nid: guide:root/setup\ntitle: Setup\nstatus: active\n---\n\n# Setup\n\n## Install {#install}\n",
257        );
258        let mut raw = RawConfig::default();
259        raw.remotes.insert(
260            "org".into(),
261            crate::config::types::RemoteRoot { path: shared.to_string_lossy().into(), paths: vec![], auto: false },
262        );
263        raw.remotes.insert(
264            "gone".into(),
265            crate::config::types::RemoteRoot {
266                path: "/nonexistent/x".into(),
267                paths: vec![shared.to_string_lossy().into()],
268                auto: false,
269            },
270        );
271        let cfg = Config::from_raw(tmp.path().join("app"), raw, false);
272        assert_eq!(resolve(&cfg, "guide:x/y"), Remote::Local);
273        match resolve(&cfg, "org:guide:root/setup#install") {
274            Remote::Found { id, ty, .. } => assert_eq!((id.as_str(), ty.as_str()), ("guide:root/setup", "guide")),
275            other => panic!("{other:?}"),
276        }
277        assert!(matches!(resolve(&cfg, "org:guide:root/nope"), Remote::Missing { .. }));
278        assert!(matches!(resolve(&cfg, "org:guide:root/setup#zzz"), Remote::MissingAnchor { .. }));
279        assert!(matches!(resolve(&cfg, "gone:guide:root/setup"), Remote::Found { .. }), "a later candidate path");
280        assert!(is_external(&cfg, "org:src/lib.rs") && !is_external(&cfg, "src/lib.rs"));
281    }
282}