use crate::config::Config;
use crate::corpus::{Corpus, LoadOptions};
use crate::graph::{Graph, Resolution};
use crate::model::RefText;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Remote {
Local,
Absent {
name: String,
path: PathBuf,
},
Found {
name: String,
id: String,
ty: String,
title: String,
file: PathBuf,
},
Missing {
name: String,
id: String,
},
MissingAnchor {
name: String,
id: String,
anchor: String,
},
Ambiguous {
name: String,
ids: Vec<String>,
},
}
pub struct Loaded {
pub corpus: Corpus,
pub graph: Graph,
}
type Cache = Mutex<HashMap<PathBuf, Option<Arc<Loaded>>>>;
fn cache() -> &'static Cache {
static C: OnceLock<Cache> = OnceLock::new();
C.get_or_init(Default::default)
}
pub fn split<'a>(cfg: &Config, text: &'a str) -> Option<(&'a str, &'a str)> {
let (name, rest) = text.trim().split_once(':')?;
(cfg.raw.remotes.contains_key(name) && cfg.registry.get(name).is_none()).then_some((name, rest))
}
pub fn dir(cfg: &Config, name: &str) -> Option<PathBuf> {
cfg.raw.remotes.get(name).map(|r| cfg.expand_path(&r.path))
}
pub fn load(cfg: &Config, name: &str) -> Option<Arc<Loaded>> {
let dir = dir(cfg, name)?;
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
c.entry(dir.clone())
.or_insert_with(|| {
if !dir.is_dir() {
return None;
}
let rc = Config::discover(&dir).ok()?;
let corpus = Corpus::load(rc, &LoadOptions { include_local: false });
let graph = Graph::build(&corpus);
Some(Arc::new(Loaded { corpus, graph }))
})
.clone()
}
pub fn resolve(cfg: &Config, text: &str) -> Remote {
let Some((name, rest)) = split(cfg, text) else { return Remote::Local };
let name = name.to_string();
let Some(l) = load(cfg, &name) else {
return Remote::Absent { path: dir(cfg, &name).unwrap_or_default(), name };
};
let id = RefText::parse(rest).target;
match l.graph.resolve(rest) {
Resolution::Doc { idx, .. } => {
let d = &l.corpus.docs[idx];
Remote::Found {
name,
id: d.id.to_string(),
ty: d.id.ty.clone(),
title: d.title.clone().unwrap_or_default(),
file: l.corpus.cfg.root.join(&d.path),
}
}
Resolution::MissingAnchor { anchor, .. } => Remote::MissingAnchor { name, id, anchor },
Resolution::Ambiguous(list) => {
Remote::Ambiguous { name, ids: list.iter().map(|&i| l.corpus.docs[i].id.to_string()).collect() }
}
Resolution::Missing => Remote::Missing { name, id },
}
}
pub fn rev(cfg: &Config, text: &str) -> Option<(String, String)> {
let (name, rest) = split(cfg, text)?;
let l = load(cfg, name)?;
match l.graph.resolve(rest) {
Resolution::Doc { idx, .. } => {
Some((format!("{name}:{}", l.corpus.docs[idx].id), crate::translation::rev_of(&l.corpus, idx)?))
}
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Consumer {
pub repo: String,
pub id: String,
pub path: String,
pub via: String,
}
pub fn consumers(cfg: &Config, here: &Corpus, target: &std::path::Path) -> Vec<Consumer> {
let canon = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let target = canon(target);
let mut repos: Vec<(String, Arc<Loaded>)> = vec![];
for name in cfg.raw.remotes.keys() {
if let Some(l) = load(cfg, name) {
repos.push((name.clone(), l));
}
}
let mut out = vec![];
let mut scan = |repo: &str, c: &Corpus| {
for d in &c.docs {
let mut refs: Vec<(String, String)> =
d.rel().into_iter().flat_map(|(k, vs)| vs.into_iter().map(move |v| (format!("rel.{k}"), v))).collect();
if let Some(serde_json::Value::Object(m)) = d.fm().and_then(|m| m.get("pins")) {
refs.extend(m.keys().map(|k| ("pins".to_string(), k.clone())));
}
for (via, v) in refs {
if let Remote::Found { file, .. } = resolve(&c.cfg, &v)
&& canon(&file) == target
{
let u = Consumer { repo: repo.to_string(), id: d.id.to_string(), path: d.path.clone(), via };
if !out.contains(&u) {
out.push(u);
}
}
}
}
};
scan(".", here);
for (name, l) in &repos {
scan(name, &l.corpus);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::types::RawConfig;
fn write(p: &std::path::Path, s: &str) {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, s).unwrap();
}
#[test]
fn resolves_ids_in_a_sibling_repository() {
let tmp = tempfile::tempdir().unwrap();
let shared = tmp.path().join("shared");
write(&shared.join("spec.toml"), "[spec]\nschema = 1\n");
write(
&shared.join(".spec/guide/setup.guide.md"),
"---\nid: guide:root/setup\ntitle: Setup\nstatus: active\n---\n\n# Setup\n\n## Install {#install}\n",
);
let mut raw = RawConfig::default();
raw.remotes.insert("org".into(), crate::config::types::RemoteRoot { path: shared.to_string_lossy().into() });
raw.remotes.insert("gone".into(), crate::config::types::RemoteRoot { path: "/nonexistent/x".into() });
let cfg = Config::from_raw(tmp.path().join("app"), raw, false);
assert_eq!(resolve(&cfg, "guide:x/y"), Remote::Local);
match resolve(&cfg, "org:guide:root/setup#install") {
Remote::Found { id, ty, .. } => assert_eq!((id.as_str(), ty.as_str()), ("guide:root/setup", "guide")),
other => panic!("{other:?}"),
}
assert!(matches!(resolve(&cfg, "org:guide:root/nope"), Remote::Missing { .. }));
assert!(matches!(resolve(&cfg, "org:guide:root/setup#zzz"), Remote::MissingAnchor { .. }));
assert!(matches!(resolve(&cfg, "gone:guide:a/b"), Remote::Absent { .. }));
}
}