1use 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#[derive(Deserialize, Clone, Default)]
20pub struct Profile {
21 #[serde(default)]
23 pub function_kinds: Vec<String>,
24 #[serde(default)]
27 pub containers: Vec<(String, String)>,
28 #[serde(default)]
30 pub identifier_kinds: Vec<String>,
31 #[serde(default)]
37 #[allow(dead_code)]
38 pub call_kinds: Vec<String>,
39 #[serde(default)]
44 pub import_resolution: Option<String>,
45}
46
47impl Profile {
48 #[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#[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#[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 pub locals_query: Option<Arc<String>>,
97 pub imports_query: Option<Arc<String>>,
101 pub profile: Arc<Profile>,
102}
103
104impl Grammar {
105 pub fn wasm_sha256(&self) -> String {
107 sha256(self.wasm.as_slice())
108 }
109}
110
111pub fn cache_root() -> Option<PathBuf> {
116 dirs::cache_dir().map(|c| c.join("grove").join("grammars"))
117}
118
119fn 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
133pub struct RootCandidate {
135 pub source: &'static str,
136 pub path: PathBuf,
137 pub exists: bool,
138}
139
140pub 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
166fn 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
181struct Index {
183 root: PathBuf,
184 by_name: HashMap<String, Manifest>,
185 by_ext: HashMap<String, String>, }
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
218pub 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 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
263pub 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
269pub 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
281pub fn is_source(path: &Path) -> bool {
283 lang_for_path(path).is_some()
284}
285
286pub fn root() -> &'static Path {
288 &index().root
289}
290
291pub fn available() -> Vec<String> {
293 let mut v: Vec<String> = index().by_name.keys().cloned().collect();
294 v.sort();
295 v
296}
297
298pub fn extensions() -> Vec<String> {
300 let mut v: Vec<String> = index().by_ext.keys().cloned().collect();
301 v.sort();
302 v
303}
304
305pub 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
312pub fn sha256(bytes: &[u8]) -> String {
317 let mut h = Sha256::new();
318 h.update(bytes);
319 format!("sha256:{:x}", h.finalize())
320}
321
322pub 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 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
387pub 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
405pub fn write_lock(path: &Path) -> Result<usize> {
407 write_lock_for(&available(), path)
408}
409
410pub 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#[derive(Debug, PartialEq, Eq)]
433pub enum LockVerifyStatus {
434 Match,
436 Mismatch,
438 Missing,
440}
441
442#[derive(Debug)]
444pub struct LockVerifyEntry {
445 pub lang: String,
447 pub expected: String,
449 pub actual: Option<String>,
451 pub status: LockVerifyStatus,
453}
454
455pub 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
509pub 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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}