Skip to main content

grove_core/
registry.rs

1//! The grammar registry — Phase 2 spine.
2//!
3//! Resolves a language id (or a file path) to its wasm grammar + tags query,
4//! loaded at runtime. The local-directory stub here (`registry/<lang>/` with
5//! `grammar.wasm`, `tags.scm`, `manifest.json`) stands in for the future hosted
6//! WASM registry; nothing above this module knows grammars aren't compiled in.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex, OnceLock};
11
12use anyhow::{Context, Result};
13use serde::Deserialize;
14use sha2::{Digest, Sha256};
15
16/// Language-specific node kinds that drive the structural niceties (parent
17/// grouping, enclosing-function for callers, go-to-def). Data, not code — so a
18/// new language is fully supported by dropping a manifest, with no recompile.
19#[derive(Deserialize, Clone, Default)]
20pub struct Profile {
21    /// Node kinds that are function/method definitions.
22    #[serde(default)]
23    pub function_kinds: Vec<String>,
24    /// Container node kinds paired with the field that names them, e.g.
25    /// `["impl_item", "type"]` or `["class_definition", "name"]`.
26    #[serde(default)]
27    pub containers: Vec<(String, String)>,
28    /// Node kinds whose text is a usable identifier (for go-to-def).
29    #[serde(default)]
30    pub identifier_kinds: Vec<String>,
31    /// `@reference.*` capture suffixes that denote a call site, e.g. `["call"]`
32    /// for Rust/JS or `["send", "call"]` for Ruby. Empty means the default
33    /// (`"call"`), which keeps every existing manifest working unchanged.
34    /// Kept for manifest backward compatibility; callers now includes all reference
35    /// kinds (issue #33), but the field is preserved for future precision modes.
36    #[serde(default)]
37    #[allow(dead_code)]
38    pub call_kinds: Vec<String>,
39    /// Module-path resolution strategy for import-edge go-to-def (ADR 0001
40    /// Step 2): `"dotted_package"` (Python `foo.bar` → `foo/bar.py`) or
41    /// `"relative_path"` (JS/TS `./bar` → `./bar.js`). `None` disables cross-file
42    /// import resolution; the language keeps directory-wide name lookup.
43    #[serde(default)]
44    pub import_resolution: Option<String>,
45}
46
47impl Profile {
48    /// Is `kind` (a `@reference.*` capture suffix) a call site? Honors the
49    /// manifest's `call_kinds`, falling back to the literal `"call"` so a
50    /// grammar without the field behaves exactly as before.
51    ///
52    /// Kept for manifest backward compatibility; callers now includes all reference
53    /// kinds (issue #33), but the method is preserved for future precision modes.
54    #[allow(dead_code)]
55    pub fn is_call_kind(&self, kind: &str) -> bool {
56        if self.call_kinds.is_empty() {
57            kind == "call"
58        } else {
59            self.call_kinds.iter().any(|k| k == kind)
60        }
61    }
62}
63
64/// Where a hosted artifact was ingested from — recorded for auditability.
65/// grove serves the bytes; this attributes the source.
66#[derive(Deserialize, Clone)]
67pub struct Source {
68    pub repo: String,
69    #[serde(default)]
70    pub rev: String,
71}
72
73#[derive(Deserialize, Clone)]
74pub struct Manifest {
75    pub name: String,
76    pub version: String,
77    pub extensions: Vec<String>,
78    #[serde(default)]
79    pub source: Option<Source>,
80    #[serde(default)]
81    pub profile: Profile,
82}
83
84/// A resolved grammar artifact — enough to load and extract. Cheap to clone
85/// (the heavy wasm bytes and query text are shared via `Arc`).
86#[derive(Clone)]
87pub struct Grammar {
88    pub name: String,
89    pub version: String,
90    pub wasm: Arc<Vec<u8>>,
91    pub tags_query: Arc<String>,
92    /// Optional `locals.scm` (tree-sitter's standard `@local.scope` /
93    /// `@local.definition` / `@local.reference` query). Drives scope-aware
94    /// go-to-def. `None` when the registry dir ships no `locals.scm` — those
95    /// languages keep the directory-wide name lookup.
96    pub locals_query: Option<Arc<String>>,
97    /// Optional `imports.scm` (grove's `@import.name` / `@import.source` /
98    /// `@import.module` query). Drives import-edge cross-file go-to-def. `None`
99    /// when the registry dir ships no `imports.scm`.
100    pub imports_query: Option<Arc<String>>,
101    pub profile: Arc<Profile>,
102}
103
104impl Grammar {
105    /// sha256 of the wasm bytes — the lockfile's integrity field.
106    pub fn wasm_sha256(&self) -> String {
107        sha256(self.wasm.as_slice())
108    }
109}
110
111/// The OS-native global cache location for grammars:
112/// `~/.cache/grove/grammars` (Linux), `~/Library/Caches/grove/grammars` (macOS),
113/// `%LOCALAPPDATA%\grove\grammars` (Windows). Grammars are a cache — reconstructible
114/// from the hosted registry and content-addressed by `grove.lock`.
115pub fn cache_root() -> Option<PathBuf> {
116    dirs::cache_dir().map(|c| c.join("grove").join("grammars"))
117}
118
119/// Dev fallback: the registry shipped in the source tree (only exists in a checkout).
120///
121/// The dev stub lives at the workspace root (`<repo>/registry`). This crate's
122/// `CARGO_MANIFEST_DIR` is `<repo>/core`, so the stub is one directory up — not
123/// beside the crate. Resolving `../registry` keeps the fallback working after the
124/// workspace split (before it, the crate was the repo root and `registry` sat
125/// beside it).
126fn dev_root() -> PathBuf {
127    Path::new(env!("CARGO_MANIFEST_DIR"))
128        .parent()
129        .map(|root| root.join("registry"))
130        .unwrap_or_else(|| PathBuf::from("registry"))
131}
132
133/// A candidate registry root with where it came from, for diagnostics.
134pub struct RootCandidate {
135    pub source: &'static str,
136    pub path: PathBuf,
137    pub exists: bool,
138}
139
140/// The ordered search path, first existing wins. Surfaced by `grove registry`.
141pub fn search_path() -> Vec<RootCandidate> {
142    let mut out = Vec::new();
143    let mut add = |source, path: PathBuf| {
144        let exists = path.is_dir();
145        out.push(RootCandidate { source, path, exists });
146    };
147    if let Ok(p) = std::env::var("GROVE_REGISTRY") {
148        add("GROVE_REGISTRY", PathBuf::from(p));
149    }
150    if let Ok(cwd) = std::env::current_dir() {
151        for dir in cwd.ancestors() {
152            let cand = dir.join(".grove").join("grammars");
153            if cand.is_dir() {
154                add("project (.grove/grammars)", cand);
155                break;
156            }
157        }
158    }
159    if let Some(c) = cache_root() {
160        add("user cache", c);
161    }
162    add("dev (source tree)", dev_root());
163    out
164}
165
166/// Resolve the registry root by precedence: an explicit `GROVE_REGISTRY` always
167/// wins; otherwise the first existing of project-vendored → user cache → dev tree;
168/// otherwise the canonical user-cache path (so errors point at the right home).
169fn registry_root() -> PathBuf {
170    if let Ok(p) = std::env::var("GROVE_REGISTRY") {
171        return PathBuf::from(p);
172    }
173    for cand in search_path() {
174        if cand.exists {
175            return cand.path;
176        }
177    }
178    cache_root().unwrap_or_else(dev_root)
179}
180
181/// Index of available languages, read once from the registry manifests.
182struct Index {
183    root: PathBuf,
184    by_name: HashMap<String, Manifest>,
185    by_ext: HashMap<String, String>, // extension -> language name
186}
187
188fn index() -> &'static Index {
189    static INDEX: OnceLock<Index> = OnceLock::new();
190    INDEX.get_or_init(|| {
191        let root = registry_root();
192        let mut by_name = HashMap::new();
193        let mut by_ext = HashMap::new();
194        if let Ok(entries) = std::fs::read_dir(&root) {
195            for e in entries.flatten() {
196                let mpath = e.path().join("manifest.json");
197                let Ok(text) = std::fs::read_to_string(&mpath) else {
198                    continue;
199                };
200                let Ok(m) = serde_json::from_str::<Manifest>(&text) else {
201                    continue;
202                };
203                for ext in &m.extensions {
204                    by_ext.insert(ext.clone(), m.name.clone());
205                }
206                by_name.insert(m.name.clone(), m);
207            }
208        }
209        Index { root, by_name, by_ext }
210    })
211}
212
213fn cache() -> &'static Mutex<HashMap<String, Grammar>> {
214    static CACHE: OnceLock<Mutex<HashMap<String, Grammar>>> = OnceLock::new();
215    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
216}
217
218/// Resolve a grammar by language id, reading (and caching) its artifacts.
219pub fn resolve(lang: &str) -> Result<Grammar> {
220    if let Some(g) = cache().lock().unwrap().get(lang) {
221        return Ok(g.clone());
222    }
223    let idx = index();
224    let manifest = idx.by_name.get(lang).with_context(|| {
225        format!(
226            "language `{lang}` is not in the registry ({}). Available: {}",
227            idx.root.display(),
228            available().join(", ")
229        )
230    })?;
231    let dir = idx.root.join(lang);
232    let wasm = std::fs::read(dir.join("grammar.wasm"))
233        .with_context(|| format!("reading grammar.wasm for `{lang}`"))?;
234    let tags = std::fs::read_to_string(dir.join("tags.scm"))
235        .with_context(|| format!("reading tags.scm for `{lang}`"))?;
236    // `locals.scm` / `imports.scm` are optional: present them only if the
237    // registry dir ships them.
238    let read_optional = |fname: &str| -> Result<Option<Arc<String>>> {
239        match std::fs::read_to_string(dir.join(fname)) {
240            Ok(s) => Ok(Some(Arc::new(s))),
241            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
242            Err(e) => Err(e).with_context(|| format!("reading {fname} for `{lang}`")),
243        }
244    };
245    let locals = read_optional("locals.scm")?;
246    let imports = read_optional("imports.scm")?;
247    let grammar = Grammar {
248        name: manifest.name.clone(),
249        version: manifest.version.clone(),
250        wasm: Arc::new(wasm),
251        tags_query: Arc::new(tags),
252        locals_query: locals,
253        imports_query: imports,
254        profile: Arc::new(manifest.profile.clone()),
255    };
256    cache()
257        .lock()
258        .unwrap()
259        .insert(lang.to_string(), grammar.clone());
260    Ok(grammar)
261}
262
263/// The language id for a file path, by extension.
264pub fn lang_for_path(path: &Path) -> Option<&'static str> {
265    let ext = path.extension()?.to_str()?;
266    index().by_ext.get(ext).map(String::as_str)
267}
268
269/// Resolve the grammar for a file path.
270pub fn for_path(path: &Path) -> Result<Grammar> {
271    let lang = lang_for_path(path).with_context(|| {
272        format!(
273            "no registered grammar for `{}` (extensions: {})",
274            path.display(),
275            extensions().join(", ")
276        )
277    })?;
278    resolve(lang)
279}
280
281/// True if the path has a registered extension.
282pub fn is_source(path: &Path) -> bool {
283    lang_for_path(path).is_some()
284}
285
286/// The registry root actually in use this run.
287pub fn root() -> &'static Path {
288    &index().root
289}
290
291/// All available language names.
292pub fn available() -> Vec<String> {
293    let mut v: Vec<String> = index().by_name.keys().cloned().collect();
294    v.sort();
295    v
296}
297
298/// All registered extensions.
299pub fn extensions() -> Vec<String> {
300    let mut v: Vec<String> = index().by_ext.keys().cloned().collect();
301    v.sort();
302    v
303}
304
305/// The manifests, for `grove languages`.
306pub fn manifests() -> Vec<Manifest> {
307    let mut v: Vec<Manifest> = index().by_name.values().cloned().collect();
308    v.sort_by(|a, b| a.name.cmp(&b.name));
309    v
310}
311
312/// The canonical content hash for grove artifacts: `sha256:<hex>`. The single
313/// source of truth — the lockfile, the index, and `grove fetch`'s verification
314/// all go through here, so the format can never drift between producer and
315/// verifier.
316pub fn sha256(bytes: &[u8]) -> String {
317    let mut h = Sha256::new();
318    h.update(bytes);
319    format!("sha256:{:x}", h.finalize())
320}
321
322/// Build the hosted catalog (`index.json`) for a registry directory: per
323/// language, its version, provenance, and a content hash of every served file.
324/// This is what registry CI runs to publish; `grove fetch` consumes it.
325///
326/// When `release_base` is set, `grammar.wasm` is recorded as a release **asset**
327/// (`<lang>.wasm`) served from that base, so the heavy binaries live in GitHub
328/// Releases and the repo stays small. `tags.scm`/`manifest.json` are always
329/// served from the repo alongside the catalog.
330pub fn build_index(root: &Path, release_base: Option<&str>) -> Result<serde_json::Value> {
331    let mut dirs: Vec<PathBuf> = std::fs::read_dir(root)
332        .with_context(|| format!("reading registry {}", root.display()))?
333        .flatten()
334        .map(|e| e.path())
335        .filter(|p| p.is_dir())
336        .collect();
337    dirs.sort();
338
339    let mut grammars = Vec::new();
340    for dir in dirs {
341        let mpath = dir.join("manifest.json");
342        if !mpath.exists() {
343            continue;
344        }
345        let m: Manifest = serde_json::from_str(&std::fs::read_to_string(&mpath)?)
346            .with_context(|| format!("parsing {}", mpath.display()))?;
347        let mut files = serde_json::Map::new();
348        for fname in ["grammar.wasm", "tags.scm", "manifest.json"] {
349            let bytes = std::fs::read(dir.join(fname))
350                .with_context(|| format!("hashing {}/{fname}", m.name))?;
351            let mut fref = serde_json::json!({ "sha256": sha256(&bytes) });
352            if fname == "grammar.wasm" && release_base.is_some() {
353                fref["asset"] = serde_json::json!(format!("{}.wasm", m.name));
354            }
355            files.insert(fname.into(), fref);
356        }
357        // `locals.scm` / `imports.scm` are optional (scope-aware + import-edge
358        // resolution, ADR 0001): record each only when the grammar dir ships it,
359        // so `fetch` pulls it for languages that have it without breaking those
360        // that don't.
361        for fname in ["locals.scm", "imports.scm"] {
362            let path = dir.join(fname);
363            if path.exists() {
364                let bytes = std::fs::read(&path)
365                    .with_context(|| format!("hashing {}/{fname}", m.name))?;
366                files.insert(fname.into(), serde_json::json!({ "sha256": sha256(&bytes) }));
367            }
368        }
369        let mut entry = serde_json::json!({
370            "name": m.name,
371            "version": m.version,
372            "extensions": m.extensions,
373            "files": files,
374        });
375        if let Some(src) = &m.source {
376            entry["source"] = serde_json::json!({ "repo": src.repo, "rev": src.rev });
377        }
378        grammars.push(entry);
379    }
380    let mut catalog = serde_json::json!({ "schema": 2, "grammars": grammars });
381    if let Some(base) = release_base {
382        catalog["release_base"] = serde_json::json!(base);
383    }
384    Ok(catalog)
385}
386
387/// Build the registry catalog and write it as JSON. `dir` defaults to the
388/// resolved registry root; `output` defaults to `<dir>/index.json`. Returns the
389/// path written and the grammar count, for the caller to report. Keeps path
390/// resolution and file I/O out of `main`, alongside its sibling verbs.
391pub fn write_index(
392    dir: Option<PathBuf>,
393    output: Option<PathBuf>,
394    release_base: Option<&str>,
395) -> Result<(PathBuf, usize)> {
396    let dir = dir.unwrap_or_else(|| root().to_path_buf());
397    let out = output.unwrap_or_else(|| dir.join("index.json"));
398    let catalog = build_index(&dir, release_base)?;
399    std::fs::write(&out, format!("{}\n", serde_json::to_string_pretty(&catalog)?))
400        .with_context(|| format!("writing {}", out.display()))?;
401    let n = catalog["grammars"].as_array().map_or(0, |a| a.len());
402    Ok((out, n))
403}
404
405/// Write a lockfile pinning every registry grammar's version + wasm hash.
406pub fn write_lock(path: &Path) -> Result<usize> {
407    write_lock_for(&available(), path)
408}
409
410/// Write a lockfile pinning the given languages' version + wasm hash.
411/// Deterministic (sorted) so it is diff-friendly and commit-able.
412pub fn write_lock_for(langs: &[String], path: &Path) -> Result<usize> {
413    let mut names: Vec<String> = langs.to_vec();
414    names.sort();
415    names.dedup();
416    let mut grammars = Vec::new();
417    for name in &names {
418        let g = resolve(name)?;
419        grammars.push(serde_json::json!({
420            "name": g.name,
421            "version": g.version,
422            "wasm": g.wasm_sha256(),
423        }));
424    }
425    let doc = serde_json::json!({ "version": 1, "grammars": grammars });
426    std::fs::write(path, format!("{}\n", serde_json::to_string_pretty(&doc)?))
427        .with_context(|| format!("writing {}", path.display()))?;
428    Ok(grammars.len())
429}
430
431/// The outcome of comparing a cached wasm's sha256 against the lock file.
432#[derive(Debug, PartialEq, Eq)]
433pub enum LockVerifyStatus {
434    /// Computed hash matches the pinned hash in the lock.
435    Match,
436    /// Wasm file exists but its hash differs from the pinned value.
437    Mismatch,
438    /// Wasm file was not found at the expected registry path.
439    Missing,
440}
441
442/// A single grammar's verification result from [`verify_lock`].
443#[derive(Debug)]
444pub struct LockVerifyEntry {
445    /// Language name as recorded in the lock file.
446    pub lang: String,
447    /// The sha256 hash pinned in the lock file (`"sha256:…"`).
448    pub expected: String,
449    /// The sha256 hash we recomputed from disk; `None` when the wasm is absent.
450    pub actual: Option<String>,
451    /// Match / Mismatch / Missing.
452    pub status: LockVerifyStatus,
453}
454
455/// Verify every grammar wasm recorded in a grove.lock file against the hashes
456/// it pins. Returns:
457/// * `Ok(None)` — the lock file does not exist (not an error; caller renders as
458///   "not present").
459/// * `Ok(Some(entries))` — lock parsed; one [`LockVerifyEntry`] per grammar.
460/// * `Err(_)` — I/O or JSON parse failure.
461///
462/// The `path` argument is the lock **file** path (parallel to `write_lock_for`
463/// and `locked_langs`), not a project root. T07 must pass the resolved
464/// `grove.lock` path.
465pub fn verify_lock(path: &Path) -> Result<Option<Vec<LockVerifyEntry>>> {
466    if !path.exists() {
467        return Ok(None);
468    }
469    let text = std::fs::read_to_string(path)
470        .with_context(|| format!("reading {}", path.display()))?;
471    let doc: serde_json::Value = serde_json::from_str(&text)
472        .with_context(|| format!("{} is not valid JSON", path.display()))?;
473    let grammars = doc["grammars"]
474        .as_array()
475        .map(|a| a.as_slice())
476        .unwrap_or_default();
477    let mut entries = Vec::with_capacity(grammars.len());
478    for entry in grammars {
479        let name = match entry["name"].as_str() {
480            Some(n) => n.to_string(),
481            None => continue,
482        };
483        let expected = match entry["wasm"].as_str() {
484            Some(h) => h.to_string(),
485            None => continue,
486        };
487        let wasm_path = registry_root().join(&name).join("grammar.wasm");
488        let (actual, status) = match std::fs::read(&wasm_path) {
489            Ok(bytes) => {
490                let hash = sha256(&bytes);
491                let s = if hash == expected {
492                    LockVerifyStatus::Match
493                } else {
494                    LockVerifyStatus::Mismatch
495                };
496                (Some(hash), s)
497            }
498            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (None, LockVerifyStatus::Missing),
499            Err(e) => {
500                return Err(anyhow::Error::new(e)
501                    .context(format!("reading {}", wasm_path.display())));
502            }
503        };
504        entries.push(LockVerifyEntry { lang: name, expected, actual, status });
505    }
506    Ok(Some(entries))
507}
508
509/// Read the grammar names pinned in a lockfile, in file order. The lock is the
510/// canonical list of the grammars a project needs; `grove init` reads it back to
511/// name those languages in the CLAUDE.md steering block after provisioning.
512pub fn locked_langs(path: &Path) -> Result<Vec<String>> {
513    let text = std::fs::read_to_string(path)
514        .with_context(|| format!("reading {}", path.display()))?;
515    let doc: serde_json::Value = serde_json::from_str(&text)
516        .with_context(|| format!("{} is not valid JSON", path.display()))?;
517    let langs = doc["grammars"]
518        .as_array()
519        .map(|gs| {
520            gs.iter()
521                .filter_map(|g| g["name"].as_str().map(String::from))
522                .collect()
523        })
524        .unwrap_or_default();
525    Ok(langs)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn empty_call_kinds_defaults_to_literal_call() {
534        // A manifest with no `call_kinds` (every grammar shipped before #10)
535        // must keep treating `@reference.call` as the call site.
536        let p = Profile::default();
537        assert!(p.is_call_kind("call"));
538        assert!(!p.is_call_kind("send"));
539        assert!(!p.is_call_kind("invocation"));
540    }
541
542    #[test]
543    fn call_kinds_drives_the_filter() {
544        // A Ruby/Elixir-style grammar declares its own call suffixes; the
545        // literal "call" is no longer special once the field is set.
546        let p = Profile { call_kinds: vec!["send".into(), "invocation".into()], ..Default::default() };
547        assert!(p.is_call_kind("send"));
548        assert!(p.is_call_kind("invocation"));
549        assert!(!p.is_call_kind("call"));
550    }
551
552    /// A minimal one-grammar registry dir that `build_index` can hash.
553    fn toy_registry(tag: &str) -> PathBuf {
554        let dir = std::env::temp_dir().join(format!("grove_index_test_{}_{tag}", std::process::id()));
555        let lang = dir.join("toy");
556        std::fs::create_dir_all(&lang).unwrap();
557        std::fs::write(lang.join("grammar.wasm"), b"\0asm-toy-bytes").unwrap();
558        std::fs::write(lang.join("tags.scm"), "; tags").unwrap();
559        std::fs::write(lang.join("manifest.json"), r#"{"name":"toy","version":"1.2.3","extensions":["toy"]}"#).unwrap();
560        dir
561    }
562
563    #[test]
564    fn write_index_writes_catalog_and_returns_count() {
565        let dir = toy_registry("explicit");
566        let out = dir.join("custom.json");
567
568        let (written, n) = write_index(Some(dir.clone()), Some(out.clone()), None).unwrap();
569        assert_eq!(written, out, "returns the path it wrote");
570        assert_eq!(n, 1, "one grammar in the toy registry");
571
572        let catalog: serde_json::Value =
573            serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
574        assert_eq!(catalog["schema"], serde_json::json!(2));
575        assert_eq!(catalog["grammars"][0]["name"], serde_json::json!("toy"));
576        assert!(catalog["grammars"][0]["files"]["grammar.wasm"]["sha256"]
577            .as_str().unwrap().starts_with("sha256:"));
578
579        std::fs::remove_dir_all(&dir).ok();
580    }
581
582    #[test]
583    fn build_index_records_locals_scm_only_when_present() {
584        // Absent by default: the toy registry ships no locals.scm.
585        let dir = toy_registry("no_locals");
586        let catalog = build_index(&dir, None).unwrap();
587        assert!(
588            catalog["grammars"][0]["files"].get("locals.scm").is_none(),
589            "no locals.scm in the dir → none in the catalog"
590        );
591        std::fs::remove_dir_all(&dir).ok();
592
593        // Present: dropping a locals.scm in the dir surfaces it (hashed) in the
594        // catalog, so `fetch` will pull it (it's repo-served, no asset).
595        let dir = toy_registry("with_locals");
596        std::fs::write(dir.join("toy").join("locals.scm"), "(identifier) @local.reference\n").unwrap();
597        let catalog = build_index(&dir, None).unwrap();
598        let entry = &catalog["grammars"][0]["files"]["locals.scm"];
599        assert!(
600            entry["sha256"].as_str().unwrap().starts_with("sha256:"),
601            "locals.scm hashed into the catalog: {entry}"
602        );
603        assert!(entry.get("asset").is_none(), "locals.scm is repo-served, not a release asset");
604        std::fs::remove_dir_all(&dir).ok();
605    }
606
607    #[test]
608    fn sha256_has_canonical_format() {
609        // Known-answer vector for the empty input, pinning the `sha256:` prefix
610        // and lowercase hex — the format every producer and verifier shares.
611        assert_eq!(
612            sha256(b""),
613            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
614        );
615    }
616
617    #[test]
618    fn wasm_sha256_delegates_to_sha256() {
619        let bytes = b"\0asm-some-grammar".to_vec();
620        let g = Grammar {
621            name: "toy".into(),
622            version: "0.0.0".into(),
623            wasm: Arc::new(bytes.clone()),
624            tags_query: Arc::new(String::new()),
625            locals_query: None,
626            imports_query: None,
627            profile: Arc::new(Profile::default()),
628        };
629        // The lockfile field and the index/fetch helper must agree byte-for-byte.
630        assert_eq!(g.wasm_sha256(), sha256(&bytes));
631    }
632
633    #[test]
634    fn write_index_defaults_output_to_dir_index_json() {
635        let dir = toy_registry("default");
636        let (written, _) = write_index(Some(dir.clone()), None, None).unwrap();
637        assert_eq!(written, dir.join("index.json"), "default output is <dir>/index.json");
638        assert!(written.exists());
639        std::fs::remove_dir_all(&dir).ok();
640    }
641
642    #[test]
643    fn build_index_records_release_asset_when_release_base_set() {
644        let dir = toy_registry("asset");
645        let catalog = build_index(&dir, Some("https://example.test/releases/v1")).unwrap();
646        assert_eq!(catalog["release_base"], serde_json::json!("https://example.test/releases/v1"));
647        let wasm = &catalog["grammars"][0]["files"]["grammar.wasm"];
648        assert_eq!(wasm["asset"], serde_json::json!("toy.wasm"), "wasm routed to a release asset");
649        // tags.scm / manifest.json stay in the repo — no asset field.
650        assert!(catalog["grammars"][0]["files"]["tags.scm"].get("asset").is_none());
651        std::fs::remove_dir_all(&dir).ok();
652    }
653
654    #[test]
655    fn build_index_errors_on_missing_root() {
656        let missing = std::env::temp_dir().join(format!("grove_no_such_registry_{}", std::process::id()));
657        let err = build_index(&missing, None).unwrap_err();
658        assert!(err.to_string().contains("reading registry"), "got: {err}");
659    }
660
661    // ---- path/extension resolution (against whichever registry root wins) ----
662
663    #[test]
664    fn lang_for_path_maps_known_extensions() {
665        assert_eq!(lang_for_path(Path::new("a/b/foo.rs")), Some("rust"));
666        assert_eq!(lang_for_path(Path::new("foo.py")), Some("python"));
667        assert_eq!(lang_for_path(Path::new("foo.js")), Some("javascript"));
668        assert_eq!(lang_for_path(Path::new("foo.unknownext")), None);
669        assert_eq!(lang_for_path(Path::new("no_extension")), None);
670    }
671
672    #[test]
673    fn is_source_follows_extension() {
674        assert!(is_source(Path::new("lib.rs")));
675        assert!(!is_source(Path::new("README.unknownext")));
676        assert!(!is_source(Path::new("Makefile")));
677    }
678
679    #[test]
680    fn for_path_errors_on_unregistered_extension() {
681        let err = for_path(Path::new("notes.unknownext")).err().expect("should error");
682        assert!(err.to_string().contains("no registered grammar"), "got: {err}");
683    }
684
685    #[test]
686    fn resolve_errors_on_unknown_language() {
687        let err = resolve("definitely-not-a-language").err().expect("should error");
688        assert!(err.to_string().contains("not in the registry"), "got: {err}");
689    }
690
691    #[test]
692    fn resolve_caches_and_loads_a_real_grammar() {
693        let a = resolve("rust").unwrap();
694        let b = resolve("rust").unwrap();
695        assert_eq!(a.name, "rust");
696        assert!(!a.wasm.is_empty());
697        // Second resolve returns the cached Arc — same allocation.
698        assert!(Arc::ptr_eq(&a.wasm, &b.wasm));
699    }
700
701    #[test]
702    fn available_and_extensions_are_sorted_and_include_the_dev_stub() {
703        let langs = available();
704        assert!(langs.contains(&"rust".to_string()));
705        assert!(langs.contains(&"python".to_string()));
706        assert!(langs.windows(2).all(|w| w[0] <= w[1]), "available() must be sorted");
707        let exts = extensions();
708        assert!(exts.contains(&"rs".to_string()));
709        assert!(exts.windows(2).all(|w| w[0] <= w[1]), "extensions() must be sorted");
710    }
711
712    #[test]
713    fn manifests_are_sorted_and_carry_versions() {
714        let ms = manifests();
715        assert!(!ms.is_empty());
716        assert!(ms.windows(2).all(|w| w[0].name <= w[1].name), "manifests sorted by name");
717        let rust = ms.iter().find(|m| m.name == "rust").expect("rust manifest");
718        assert!(!rust.version.is_empty());
719        assert!(rust.extensions.contains(&"rs".to_string()));
720    }
721
722    #[test]
723    fn search_path_is_ordered_and_root_exists() {
724        let path = search_path();
725        assert!(!path.is_empty());
726        assert!(path.iter().any(|c| c.source == "dev (source tree)"), "dev candidate always listed");
727        assert!(root().is_dir(), "the resolved root must exist");
728    }
729
730    #[test]
731    fn write_lock_for_pins_versions_and_hashes() {
732        let out = std::env::temp_dir().join(format!("grove_lock_test_{}.lock", std::process::id()));
733        let n = write_lock_for(&["rust".into(), "rust".into()], &out).unwrap();
734        assert_eq!(n, 1, "duplicate langs are deduped");
735        let doc: serde_json::Value =
736            serde_json::from_str(&std::fs::read_to_string(&out).unwrap()).unwrap();
737        assert_eq!(doc["version"], serde_json::json!(1));
738        assert_eq!(doc["grammars"][0]["name"], serde_json::json!("rust"));
739        assert!(doc["grammars"][0]["wasm"].as_str().unwrap().starts_with("sha256:"));
740        std::fs::remove_file(&out).ok();
741    }
742
743    #[test]
744    fn manifest_deserializes_call_kinds_from_profile() {
745        // End-to-end of #10: a manifest's profile.call_kinds reaches the Profile.
746        let json = r#"{
747            "name": "ruby", "version": "1.0.0", "extensions": ["rb"],
748            "profile": { "function_kinds": ["method"], "call_kinds": ["call", "send"] }
749        }"#;
750        let m: Manifest = serde_json::from_str(json).unwrap();
751        assert_eq!(m.profile.call_kinds, vec!["call", "send"]);
752        assert!(m.profile.is_call_kind("send"));
753    }
754
755    #[test]
756    fn verify_lock_returns_none_when_file_absent() {
757        let absent = std::env::temp_dir().join(format!("grove_lock_none_{}.lock", std::process::id()));
758        let result = verify_lock(&absent).unwrap();
759        assert!(result.is_none(), "absent lock file must return Ok(None)");
760    }
761
762    #[test]
763    fn verify_lock_matches_after_write_lock_for() {
764        let out = std::env::temp_dir().join(format!("grove_lock_match_{}.lock", std::process::id()));
765        write_lock_for(&["rust".into()], &out).unwrap();
766        let entries = verify_lock(&out).unwrap().expect("lock file must parse");
767        assert_eq!(entries.len(), 1);
768        assert_eq!(entries[0].lang, "rust");
769        assert_eq!(entries[0].status, LockVerifyStatus::Match);
770        assert!(entries[0].actual.is_some());
771        std::fs::remove_file(&out).ok();
772    }
773
774    #[test]
775    fn verify_lock_detects_tampered_hash() {
776        let out = std::env::temp_dir().join(format!("grove_lock_mismatch_{}.lock", std::process::id()));
777        write_lock_for(&["rust".into()], &out).unwrap();
778        // Overwrite the wasm hash with a bogus value.
779        let text = std::fs::read_to_string(&out).unwrap();
780        let mut doc: serde_json::Value = serde_json::from_str(&text).unwrap();
781        doc["grammars"][0]["wasm"] = serde_json::json!("sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
782        std::fs::write(&out, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
783        let entries = verify_lock(&out).unwrap().expect("lock file must parse");
784        assert_eq!(entries[0].status, LockVerifyStatus::Mismatch);
785        assert!(entries[0].actual.is_some(), "wasm exists so actual hash must be computed");
786        std::fs::remove_file(&out).ok();
787    }
788
789    #[test]
790    fn verify_lock_detects_missing_wasm() {
791        let out = std::env::temp_dir().join(format!("grove_lock_missing_{}.lock", std::process::id()));
792        // Hand-craft a lock for a language whose wasm is definitely not on disk.
793        let doc = serde_json::json!({
794            "version": 1,
795            "grammars": [{"name": "grove_nonexistent_lang_xyz", "version": "0.0.0",
796                          "wasm": "sha256:0000000000000000000000000000000000000000000000000000000000000000"}]
797        });
798        std::fs::write(&out, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
799        let entries = verify_lock(&out).unwrap().expect("lock file must parse");
800        assert_eq!(entries.len(), 1);
801        assert_eq!(entries[0].status, LockVerifyStatus::Missing);
802        assert!(entries[0].actual.is_none());
803        std::fs::remove_file(&out).ok();
804    }
805}