1use 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#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Remote {
18 Local,
20 Absent {
22 name: String,
23 path: PathBuf,
24 },
25 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
60pub 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
67pub 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
74pub 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
89pub 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
98fn 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
117pub 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 rc = Config::discover(&dir).ok()?;
127 let corpus = Corpus::load(rc, &LoadOptions { include_local: false });
128 let graph = Graph::build(&corpus);
129 Some(Arc::new(Loaded { corpus, graph }))
130 })
131 .clone()
132}
133
134pub fn resolve(cfg: &Config, text: &str) -> Remote {
136 let Some((name, rest)) = split(cfg, text) else { return Remote::Local };
137 let name = name.to_string();
138 let Some(l) = load(cfg, &name) else {
139 return Remote::Absent { path: dir(cfg, &name).unwrap_or_default(), name };
140 };
141 let id = RefText::parse(rest).target;
142 match l.graph.resolve(rest) {
143 Resolution::Doc { idx, .. } => {
144 let d = &l.corpus.docs[idx];
145 Remote::Found {
146 name,
147 id: d.id.to_string(),
148 ty: d.id.ty.clone(),
149 title: d.title.clone().unwrap_or_default(),
150 file: l.corpus.cfg.root.join(&d.path),
151 }
152 }
153 Resolution::MissingAnchor { anchor, .. } => Remote::MissingAnchor { name, id, anchor },
154 Resolution::Ambiguous(list) => {
155 Remote::Ambiguous { name, ids: list.iter().map(|&i| l.corpus.docs[i].id.to_string()).collect() }
156 }
157 Resolution::Missing => Remote::Missing { name, id },
158 }
159}
160
161pub fn rev(cfg: &Config, text: &str) -> Option<(String, String)> {
164 let (name, rest) = split(cfg, text)?;
165 let l = load(cfg, name)?;
166 match l.graph.resolve(rest) {
167 Resolution::Doc { idx, .. } => {
168 Some((format!("{name}:{}", l.corpus.docs[idx].id), crate::translation::rev_of(&l.corpus, idx)?))
169 }
170 _ => None,
171 }
172}
173pub fn status(cfg: &Config, text: &str) -> Option<(String, Option<String>)> {
176 let (name, rest) = split(cfg, text)?;
177 let l = load(cfg, name)?;
178 let Resolution::Doc { idx, .. } = l.graph.resolve(rest) else { return None };
179 let me = l.corpus.docs[idx].id.to_string();
180 let by = l.corpus.docs.iter().find(|d| d.rel().get("supersedes").is_some_and(|v| v.contains(&me)));
181 Some((l.corpus.docs[idx].status().unwrap_or_default(), by.map(|d| format!("{name}:{}", d.id))))
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Consumer {
187 pub repo: String,
189 pub id: String,
190 pub path: String,
191 pub via: String,
193}
194
195pub fn consumers(cfg: &Config, here: &Corpus, target: &std::path::Path) -> Vec<Consumer> {
199 let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
200 let target = canon(target);
201 let mut repos: Vec<(String, Arc<Loaded>)> = vec![];
202 for name in cfg.raw.remotes.keys() {
203 if let Some(l) = load(cfg, name) {
204 repos.push((name.clone(), l));
205 }
206 }
207 let mut out = vec![];
208 let mut scan = |repo: &str, c: &Corpus| {
209 for d in &c.docs {
210 let mut refs: Vec<(String, String)> =
211 d.rel().into_iter().flat_map(|(k, vs)| vs.into_iter().map(move |v| (format!("rel.{k}"), v))).collect();
212 if let Some(serde_json::Value::Object(m)) = d.fm().and_then(|m| m.get("pins")) {
213 refs.extend(m.keys().map(|k| ("pins".to_string(), k.clone())));
214 }
215 for (via, v) in refs {
216 if let Remote::Found { file, .. } = resolve(&c.cfg, &v)
217 && canon(&file) == target
218 {
219 let u = Consumer { repo: repo.to_string(), id: d.id.to_string(), path: d.path.clone(), via };
220 if !out.contains(&u) {
221 out.push(u);
222 }
223 }
224 }
225 }
226 };
227 scan(".", here);
228 for (name, l) in &repos {
229 scan(name, &l.corpus);
230 }
231 out
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::config::types::RawConfig;
238
239 fn write(p: &std::path::Path, s: &str) {
240 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
241 std::fs::write(p, s).unwrap();
242 }
243
244 #[test]
245 fn resolves_ids_in_a_sibling_repository() {
246 let tmp = tempfile::tempdir().unwrap();
247 let shared = tmp.path().join("shared");
248 write(&shared.join("spec.toml"), "[spec]\nschema = 1\n");
249 write(
250 &shared.join(".spec/guide/setup.guide.md"),
251 "---\nid: guide:root/setup\ntitle: Setup\nstatus: active\n---\n\n# Setup\n\n## Install {#install}\n",
252 );
253 let mut raw = RawConfig::default();
254 raw.remotes.insert(
255 "org".into(),
256 crate::config::types::RemoteRoot { path: shared.to_string_lossy().into(), paths: vec![], auto: false },
257 );
258 raw.remotes.insert(
259 "gone".into(),
260 crate::config::types::RemoteRoot {
261 path: "/nonexistent/x".into(),
262 paths: vec![shared.to_string_lossy().into()],
263 auto: false,
264 },
265 );
266 let cfg = Config::from_raw(tmp.path().join("app"), raw, false);
267 assert_eq!(resolve(&cfg, "guide:x/y"), Remote::Local);
268 match resolve(&cfg, "org:guide:root/setup#install") {
269 Remote::Found { id, ty, .. } => assert_eq!((id.as_str(), ty.as_str()), ("guide:root/setup", "guide")),
270 other => panic!("{other:?}"),
271 }
272 assert!(matches!(resolve(&cfg, "org:guide:root/nope"), Remote::Missing { .. }));
273 assert!(matches!(resolve(&cfg, "org:guide:root/setup#zzz"), Remote::MissingAnchor { .. }));
274 assert!(matches!(resolve(&cfg, "gone:guide:root/setup"), Remote::Found { .. }), "a later candidate path");
275 assert!(is_external(&cfg, "org:src/lib.rs") && !is_external(&cfg, "src/lib.rs"));
276 }
277}