1use crate::value::VmDictExt;
11use std::cell::RefCell;
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
16use crate::value::{VmError, VmValue};
17use crate::vm::Vm;
18
19type Namespace = String;
20type FieldKey = String;
21const LEGACY_SHARD_NAME: &str = "root.json";
22const NAMESPACE_ENTRIES_FILE: &str = "entries.json";
23
24#[derive(Clone, Default)]
28pub(crate) struct PathMetadata {
29 pub(crate) namespaces: BTreeMap<Namespace, BTreeMap<FieldKey, serde_json::Value>>,
30}
31
32type DirectoryMetadata = PathMetadata;
33
34#[derive(Default)]
37struct LoadedEntries {
38 dirs: BTreeMap<String, PathMetadata>,
39 files: BTreeMap<String, PathMetadata>,
40}
41
42trait MetadataBackend {
43 fn backend_name(&self) -> &'static str;
44 fn load(&self, root: &Path) -> Result<LoadedEntries, String>;
45 fn save(
46 &self,
47 root: &Path,
48 dirs: &BTreeMap<String, PathMetadata>,
49 files: &BTreeMap<String, PathMetadata>,
50 ) -> Result<(), String>;
51}
52
53#[derive(Default)]
54struct FilesystemMetadataBackend;
55
56impl FilesystemMetadataBackend {
57 fn new() -> Self {
58 Self
59 }
60}
61
62pub(crate) struct MetadataState {
65 entries: BTreeMap<String, PathMetadata>,
66 files: BTreeMap<String, PathMetadata>,
67 base_dir: PathBuf,
68 backend: Box<dyn MetadataBackend>,
69 loaded: bool,
70 dirty: bool,
71}
72
73impl MetadataState {
74 pub(crate) fn new(base_dir: &Path) -> Self {
75 Self {
76 entries: BTreeMap::new(),
77 files: BTreeMap::new(),
78 base_dir: base_dir.to_path_buf(),
79 backend: Box::new(FilesystemMetadataBackend::new()),
80 loaded: false,
81 dirty: false,
82 }
83 }
84
85 fn metadata_dir(&self) -> PathBuf {
86 crate::runtime_paths::metadata_dir(&self.base_dir)
87 }
88
89 fn ensure_loaded(&mut self) {
90 if self.loaded {
91 return;
92 }
93 self.loaded = true;
94 if let Ok(loaded) = self.backend.load(&self.metadata_dir()) {
95 self.entries = loaded.dirs;
96 self.files = loaded.files;
97 }
98 }
99
100 fn resolve(&mut self, directory: &str) -> DirectoryMetadata {
103 self.ensure_loaded();
104 let mut result = DirectoryMetadata::default();
105
106 if let Some(root) = self.entries.get(".").or_else(|| self.entries.get("")) {
107 merge_metadata(&mut result, root);
108 }
109
110 let components: Vec<&str> = directory
111 .split('/')
112 .filter(|c| !c.is_empty() && *c != ".")
113 .collect();
114 let mut current = String::new();
115 for component in components {
116 if current.is_empty() {
117 current = component.to_string();
118 } else {
119 current = format!("{current}/{component}");
120 }
121 if let Some(meta) = self.entries.get(¤t) {
122 merge_metadata(&mut result, meta);
123 }
124 }
125
126 result
127 }
128
129 pub(crate) fn get_namespace(
131 &mut self,
132 directory: &str,
133 namespace: &str,
134 ) -> Option<BTreeMap<FieldKey, serde_json::Value>> {
135 let resolved = self.resolve(directory);
136 resolved.namespaces.get(namespace).cloned()
137 }
138
139 pub(crate) fn local_directory(&mut self, directory: &str) -> DirectoryMetadata {
140 self.ensure_loaded();
141 self.entries.get(directory).cloned().unwrap_or_default()
142 }
143
144 fn lineage_directories(&mut self, directory: &str) -> Vec<String> {
145 self.ensure_loaded();
146 let mut lineage = Vec::new();
147 if self.entries.contains_key(".") {
148 lineage.push(".".to_string());
149 } else if self.entries.contains_key("") {
150 lineage.push(String::new());
151 }
152
153 let components = directory.split('/').filter(|c| !c.is_empty() && *c != ".");
154 let mut current = String::new();
155 for component in components {
156 if current.is_empty() {
157 current = component.to_string();
158 } else {
159 current = format!("{current}/{component}");
160 }
161 if self.entries.contains_key(¤t) {
162 lineage.push(current.clone());
163 }
164 }
165 lineage
166 }
167
168 pub(crate) fn origin_directory(
169 &mut self,
170 directory: &str,
171 namespace: &str,
172 key: Option<&str>,
173 ) -> Option<String> {
174 if namespace.is_empty() {
175 return None;
176 }
177
178 let mut origin = None;
179 for candidate in self.lineage_directories(directory) {
180 let Some(fields) = self
181 .entries
182 .get(&candidate)
183 .and_then(|metadata| metadata.namespaces.get(namespace))
184 else {
185 continue;
186 };
187 if fields.is_empty() {
188 continue;
189 }
190 if let Some(key) = key {
191 if fields.contains_key(key) {
192 origin = Some(candidate);
193 }
194 } else {
195 origin = Some(candidate);
196 }
197 }
198 origin
199 }
200
201 fn set_namespace(
203 &mut self,
204 directory: &str,
205 namespace: &str,
206 data: BTreeMap<FieldKey, serde_json::Value>,
207 ) {
208 self.ensure_loaded();
209 let meta = self.entries.entry(directory.to_string()).or_default();
210 let ns = meta.namespaces.entry(namespace.to_string()).or_default();
211 for (k, v) in data {
212 ns.insert(k, v);
213 }
214 self.dirty = true;
215 }
216
217 pub(crate) fn replace_namespace(
222 &mut self,
223 directory: &str,
224 namespace: &str,
225 data: BTreeMap<FieldKey, serde_json::Value>,
226 ) {
227 self.ensure_loaded();
228 let meta = self.entries.entry(directory.to_string()).or_default();
229 meta.namespaces.insert(namespace.to_string(), data);
230 self.dirty = true;
231 }
232
233 fn file_namespace(
236 &mut self,
237 path: &str,
238 namespace: &str,
239 ) -> Option<BTreeMap<FieldKey, serde_json::Value>> {
240 self.ensure_loaded();
241 self.files
242 .get(path)
243 .and_then(|meta| meta.namespaces.get(namespace).cloned())
244 }
245
246 fn file_entry(&mut self, path: &str) -> Option<PathMetadata> {
247 self.ensure_loaded();
248 self.files.get(path).cloned()
249 }
250
251 fn set_file_namespace(
253 &mut self,
254 path: &str,
255 namespace: &str,
256 data: BTreeMap<FieldKey, serde_json::Value>,
257 ) {
258 self.ensure_loaded();
259 let meta = self.files.entry(path.to_string()).or_default();
260 let ns = meta.namespaces.entry(namespace.to_string()).or_default();
261 for (k, v) in data {
262 ns.insert(k, v);
263 }
264 self.dirty = true;
265 }
266
267 pub(crate) fn save(&mut self) -> Result<(), String> {
269 if !self.dirty {
270 return Ok(());
271 }
272 let meta_dir = self.metadata_dir();
273 self.backend.save(&meta_dir, &self.entries, &self.files)?;
274 self.dirty = false;
275 Ok(())
276 }
277}
278
279impl MetadataBackend for FilesystemMetadataBackend {
280 fn backend_name(&self) -> &'static str {
281 "filesystem"
282 }
283
284 fn load(&self, root: &Path) -> Result<LoadedEntries, String> {
285 let mut loaded = LoadedEntries::default();
286 let legacy_path = root.join(LEGACY_SHARD_NAME);
287 if let Ok(contents) = std::fs::read_to_string(&legacy_path) {
288 loaded.dirs = parse_legacy_entries(&contents);
289 }
290
291 let namespace_dirs = match std::fs::read_dir(root) {
292 Ok(read_dir) => read_dir,
293 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(loaded),
294 Err(error) => return Err(format!("metadata load: {error}")),
295 };
296
297 let mut dirs = namespace_dirs
298 .flatten()
299 .filter(|entry| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
300 .collect::<Vec<_>>();
301 dirs.sort_by_key(|entry| entry.file_name());
302
303 for dir in dirs {
304 let shard_path = dir.path().join(NAMESPACE_ENTRIES_FILE);
305 let Ok(contents) = std::fs::read_to_string(&shard_path) else {
306 continue;
307 };
308 merge_namespace_shard(&mut loaded, &contents);
309 }
310
311 Ok(loaded)
312 }
313
314 fn save(
315 &self,
316 root: &Path,
317 dirs: &BTreeMap<String, PathMetadata>,
318 files: &BTreeMap<String, PathMetadata>,
319 ) -> Result<(), String> {
320 std::fs::create_dir_all(root).map_err(|error| format!("metadata mkdir: {error}"))?;
321
322 let mut dir_namespaces: BTreeMap<String, serde_json::Map<String, serde_json::Value>> =
323 BTreeMap::new();
324 for (dir, meta) in dirs {
325 for (namespace, fields) in &meta.namespaces {
326 dir_namespaces
327 .entry(namespace.clone())
328 .or_default()
329 .insert(dir.clone(), serialize_namespace_fields(fields));
330 }
331 }
332 let mut file_namespaces: BTreeMap<String, serde_json::Map<String, serde_json::Value>> =
333 BTreeMap::new();
334 for (path, meta) in files {
335 for (namespace, fields) in &meta.namespaces {
336 file_namespaces
337 .entry(namespace.clone())
338 .or_default()
339 .insert(path.clone(), serialize_namespace_fields(fields));
340 }
341 }
342
343 let mut all_namespaces: std::collections::BTreeSet<String> =
344 dir_namespaces.keys().cloned().collect();
345 all_namespaces.extend(file_namespaces.keys().cloned());
346
347 for namespace in all_namespaces {
348 let dir_entries = dir_namespaces.remove(&namespace).unwrap_or_default();
349 let file_entries = file_namespaces.remove(&namespace).unwrap_or_default();
350 let namespace_dir = root.join(namespace_path_component(&namespace));
351 std::fs::create_dir_all(&namespace_dir)
352 .map_err(|error| format!("metadata mkdir: {error}"))?;
353 let mut shard = serde_json::Map::new();
354 shard.insert("version".to_string(), serde_json::json!(1));
355 shard.insert(
356 "namespace".to_string(),
357 serde_json::Value::String(namespace.clone()),
358 );
359 shard.insert(
360 "backend".to_string(),
361 serde_json::Value::String(self.backend_name().to_string()),
362 );
363 shard.insert(
364 "generatedAt".to_string(),
365 serde_json::Value::String(chrono_now_iso()),
366 );
367 shard.insert(
368 "entries".to_string(),
369 serde_json::Value::Object(dir_entries),
370 );
371 if !file_entries.is_empty() {
372 shard.insert("files".to_string(), serde_json::Value::Object(file_entries));
373 }
374 let json = serde_json::to_string_pretty(&serde_json::Value::Object(shard))
375 .map_err(|error| format!("metadata json: {error}"))?;
376 std::fs::write(namespace_dir.join(NAMESPACE_ENTRIES_FILE), json)
377 .map_err(|error| format!("metadata write: {error}"))?;
378 }
379
380 Ok(())
381 }
382}
383
384pub(crate) fn chrono_now_iso() -> String {
386 let now = std::time::SystemTime::now();
387 let secs = now
388 .duration_since(std::time::UNIX_EPOCH)
389 .unwrap_or_default()
390 .as_secs();
391 let days = secs / 86400;
392 let time_secs = secs % 86400;
393 let hours = time_secs / 3600;
394 let minutes = (time_secs % 3600) / 60;
395 let seconds = time_secs % 60;
396 let mut y = 1970i64;
397 let mut remaining = days as i64;
398 loop {
399 let days_in_year: i64 = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) {
400 366
401 } else {
402 365
403 };
404 if remaining < days_in_year {
405 break;
406 }
407 remaining -= days_in_year;
408 y += 1;
409 }
410 let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
411 let month_days: [i64; 12] = [
412 31,
413 if leap { 29 } else { 28 },
414 31,
415 30,
416 31,
417 30,
418 31,
419 31,
420 30,
421 31,
422 30,
423 31,
424 ];
425 let mut m = 0usize;
426 for days in &month_days {
427 if remaining < *days {
428 break;
429 }
430 remaining -= *days;
431 m += 1;
432 }
433 format!(
434 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
435 y,
436 m + 1,
437 remaining + 1,
438 hours,
439 minutes,
440 seconds
441 )
442}
443
444fn merge_metadata(target: &mut DirectoryMetadata, source: &DirectoryMetadata) {
445 for (ns, fields) in &source.namespaces {
446 let target_ns = target.namespaces.entry(ns.clone()).or_default();
447 for (k, v) in fields {
448 target_ns.insert(k.clone(), v.clone());
449 }
450 }
451}
452
453fn parse_namespace_fields(val: &serde_json::Value) -> BTreeMap<FieldKey, serde_json::Value> {
454 let mut fields = BTreeMap::new();
455 let Some(obj) = val.as_object() else {
456 return fields;
457 };
458 for (key, value) in obj {
459 fields.insert(key.clone(), value.clone());
460 }
461 fields
462}
463
464fn serialize_namespace_fields(fields: &BTreeMap<FieldKey, serde_json::Value>) -> serde_json::Value {
465 let mut fields_obj = serde_json::Map::new();
466 for (k, v) in fields {
467 fields_obj.insert(k.clone(), v.clone());
468 }
469 serde_json::Value::Object(fields_obj)
470}
471
472fn parse_path_metadata(val: &serde_json::Value) -> PathMetadata {
473 let mut meta = PathMetadata::default();
474 let obj = match val.as_object() {
475 Some(o) => o,
476 None => return meta,
477 };
478 if let Some(ns_obj) = obj.get("namespaces").and_then(|n| n.as_object()) {
479 for (ns_name, fields_val) in ns_obj {
480 if let Some(fields) = fields_val.as_object() {
481 let mut field_map = BTreeMap::new();
482 for (k, v) in fields {
483 field_map.insert(k.clone(), v.clone());
484 }
485 meta.namespaces.insert(ns_name.clone(), field_map);
486 }
487 }
488 }
489 meta
490}
491
492fn parse_legacy_entries(contents: &str) -> BTreeMap<String, PathMetadata> {
493 let mut entries = BTreeMap::new();
494 let parsed: serde_json::Value = match serde_json::from_str(contents) {
495 Ok(v) => v,
496 Err(_) => return entries,
497 };
498 let Some(shard_entries) = parsed.get("entries").and_then(|e| e.as_object()) else {
499 return entries;
500 };
501 for (dir, meta_val) in shard_entries {
502 entries.insert(dir.clone(), parse_path_metadata(meta_val));
503 }
504 entries
505}
506
507fn merge_namespace_shard(loaded: &mut LoadedEntries, contents: &str) {
508 let parsed: serde_json::Value = match serde_json::from_str(contents) {
509 Ok(v) => v,
510 Err(_) => return,
511 };
512 let Some(namespace) = parsed.get("namespace").and_then(|value| value.as_str()) else {
513 return;
514 };
515 if let Some(shard_entries) = parsed.get("entries").and_then(|value| value.as_object()) {
516 for (dir, fields_val) in shard_entries {
517 let directory = loaded.dirs.entry(dir.clone()).or_default();
518 directory
519 .namespaces
520 .insert(namespace.to_string(), parse_namespace_fields(fields_val));
521 }
522 }
523 if let Some(shard_files) = parsed.get("files").and_then(|value| value.as_object()) {
524 for (path, fields_val) in shard_files {
525 let file = loaded.files.entry(path.clone()).or_default();
526 file.namespaces
527 .insert(namespace.to_string(), parse_namespace_fields(fields_val));
528 }
529 }
530}
531
532fn namespace_path_component(namespace: &str) -> String {
533 let mut result = String::new();
534 for ch in namespace.chars() {
535 match ch {
536 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => result.push(ch),
537 _ => result.push_str(&format!("_{:02X}", ch as u32)),
538 }
539 }
540 if result.is_empty() || result == "." || result == ".." {
541 "_".to_string()
542 } else {
543 result
544 }
545}
546
547use crate::value::vm_to_storage_json as vm_to_json;
548
549pub(crate) fn json_to_vm(jv: &serde_json::Value) -> VmValue {
550 match jv {
551 serde_json::Value::Null => VmValue::Nil,
552 serde_json::Value::Bool(b) => VmValue::Bool(*b),
553 serde_json::Value::Number(n) => {
554 if let Some(i) = n.as_i64() {
555 VmValue::Int(i)
556 } else {
557 VmValue::Float(n.as_f64().unwrap_or(0.0))
558 }
559 }
560 serde_json::Value::String(s) => VmValue::String(arcstr::ArcStr::from(s.as_str())),
561 serde_json::Value::Array(arr) => {
562 VmValue::List(std::sync::Arc::new(arr.iter().map(json_to_vm).collect()))
563 }
564 serde_json::Value::Object(map) => {
565 let mut m = BTreeMap::new();
566 for (k, v) in map {
567 m.insert(k.clone(), json_to_vm(v));
568 }
569 VmValue::dict(m)
570 }
571 }
572}
573
574fn namespace_fields_to_vm(fields: &BTreeMap<FieldKey, serde_json::Value>) -> VmValue {
575 let mut map = BTreeMap::new();
576 for (k, v) in fields {
577 map.insert(k.clone(), json_to_vm(v));
578 }
579 VmValue::dict(map)
580}
581
582fn directory_metadata_to_vm(meta: &DirectoryMetadata) -> VmValue {
583 let mut namespaces = BTreeMap::new();
584 for (ns, fields) in &meta.namespaces {
585 namespaces.insert(ns.clone(), namespace_fields_to_vm(fields));
586 }
587 VmValue::dict(namespaces)
588}
589
590fn normalize_directory_key(dir: &str) -> String {
591 if dir.trim().is_empty() || dir == "." {
592 ".".to_string()
593 } else {
594 dir.to_string()
595 }
596}
597
598fn normalize_file_key(path: &str) -> Option<String> {
604 let trimmed = path.trim().replace('\\', "/");
605 let stripped = trimmed.strip_prefix("./").unwrap_or(&trimmed);
606 let stripped = stripped.trim_end_matches('/');
607 if stripped.is_empty() || stripped == "." || stripped == ".." {
608 return None;
609 }
610 Some(stripped.to_string())
611}
612
613#[derive(Clone, Copy, PartialEq, Eq)]
614enum PathKind {
615 File,
616 Dir,
617}
618
619#[derive(Clone, Copy, PartialEq, Eq)]
620enum PathKindFilter {
621 File,
622 Dir,
623 All,
624}
625
626fn parse_path_kind_filter(
629 value: Option<&VmValue>,
630 default: PathKindFilter,
631 allow_all: bool,
632) -> Option<PathKindFilter> {
633 let dict = match value {
634 Some(VmValue::Dict(dict)) => dict,
635 Some(VmValue::Nil) | None => return Some(default),
636 _ => return None,
637 };
638 match dict.get("kind") {
639 Some(VmValue::String(s)) => match s.as_ref() {
640 "file" => Some(PathKindFilter::File),
641 "dir" | "directory" => Some(PathKindFilter::Dir),
642 "all" if allow_all => Some(PathKindFilter::All),
643 _ => None,
644 },
645 None | Some(VmValue::Nil) => Some(default),
646 _ => None,
647 }
648}
649
650fn parse_path_kind(value: Option<&VmValue>) -> Option<PathKind> {
651 match parse_path_kind_filter(value, PathKindFilter::File, false)? {
652 PathKindFilter::File => Some(PathKind::File),
653 PathKindFilter::Dir => Some(PathKind::Dir),
654 PathKindFilter::All => None,
655 }
656}
657
658#[derive(Clone)]
659struct ScanOptions {
660 pattern: Option<String>,
661 max_depth: usize,
662 include_hidden: bool,
663 include_dirs: bool,
664 include_files: bool,
665}
666
667impl Default for ScanOptions {
668 fn default() -> Self {
669 Self {
670 pattern: None,
671 max_depth: 5,
672 include_hidden: false,
673 include_dirs: true,
674 include_files: true,
675 }
676 }
677}
678
679fn bool_arg(map: &crate::value::DictMap, key: &str, default: bool) -> bool {
680 match map.get(key) {
681 Some(VmValue::Bool(value)) => *value,
682 _ => default,
683 }
684}
685
686fn usize_arg(map: &crate::value::DictMap, key: &str, default: usize) -> usize {
687 match map.get(key) {
688 Some(VmValue::Int(value)) if *value >= 0 => *value as usize,
689 _ => default,
690 }
691}
692
693fn parse_scan_options(
694 pattern_or_options: Option<&VmValue>,
695 explicit_options: Option<&VmValue>,
696) -> ScanOptions {
697 let mut options = ScanOptions::default();
698 if let Some(VmValue::String(pattern)) = pattern_or_options {
699 options.pattern = Some(pattern.to_string());
700 } else if let Some(VmValue::Dict(dict)) = pattern_or_options {
701 apply_scan_options_dict(&mut options, dict);
702 }
703 if let Some(VmValue::Dict(dict)) = explicit_options {
704 apply_scan_options_dict(&mut options, dict);
705 }
706 options
707}
708
709fn apply_scan_options_dict(options: &mut ScanOptions, dict: &crate::value::DictMap) {
710 if let Some(pattern) = dict.get("pattern").map(|value| value.display()) {
711 if !pattern.is_empty() {
712 options.pattern = Some(pattern);
713 }
714 }
715 options.max_depth = usize_arg(dict, "max_depth", options.max_depth);
716 options.include_hidden = bool_arg(dict, "include_hidden", options.include_hidden);
717 options.include_dirs = bool_arg(dict, "include_dirs", options.include_dirs);
718 options.include_files = bool_arg(dict, "include_files", options.include_files);
719}
720
721fn resolve_scan_root(rel_dir: &str) -> PathBuf {
722 let candidate = PathBuf::from(rel_dir);
723 if candidate.is_absolute() {
724 return candidate;
725 }
726 crate::stdlib::process::resolve_source_relative_path(rel_dir)
727}
728
729pub fn register_metadata_builtins(vm: &mut Vm, base_dir: &Path) {
737 METADATA_STATE.with(|cell| {
738 *cell.borrow_mut() = Some(MetadataState::new(base_dir));
739 });
740 for def in MODULE_BUILTINS {
741 vm.register_builtin_def(def);
742 }
743 for def in crate::verification::MODULE_BUILTINS {
747 vm.register_builtin_def(def);
748 }
749}
750
751pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
752 &METADATA_GET_IMPL_DEF,
753 &METADATA_RESOLVE_IMPL_DEF,
754 &METADATA_ENTRIES_IMPL_DEF,
755 &METADATA_SET_IMPL_DEF,
756 &METADATA_SAVE_IMPL_DEF,
757 &METADATA_STALE_IMPL_DEF,
758 &METADATA_REFRESH_HASHES_IMPL_DEF,
759 &METADATA_STATUS_IMPL_DEF,
760 &COMPUTE_CONTENT_HASH_IMPL_DEF,
761 &INVALIDATE_FACTS_IMPL_DEF,
762 &PATH_METADATA_GET_IMPL_DEF,
763 &PATH_METADATA_SET_IMPL_DEF,
764 &PATH_METADATA_ENTRIES_IMPL_DEF,
765 &SCAN_DIRECTORY_IMPL_DEF,
766];
767
768thread_local! {
769 static METADATA_STATE: RefCell<Option<MetadataState>> = const { RefCell::new(None) };
775}
776
777pub(crate) fn with_state<R>(
778 fn_name: &'static str,
779 f: impl FnOnce(&mut MetadataState) -> Result<R, VmError>,
780) -> Result<R, VmError> {
781 METADATA_STATE.with(|cell| {
782 let mut guard = cell.borrow_mut();
783 let state = guard.as_mut().ok_or_else(|| {
784 VmError::Runtime(format!(
785 "{fn_name}: metadata builtins not registered for this VM"
786 ))
787 })?;
788 f(state)
789 })
790}
791
792#[harn_builtin(
793 sig = "metadata_get(dir: string, namespace?: string|nil) -> dict|nil",
794 category = "metadata"
795)]
796fn metadata_get_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
797 let dir = args.first().map(|a| a.display()).unwrap_or_default();
798 let namespace = args.get(1).and_then(|a| {
799 if matches!(a, VmValue::Nil) {
800 None
801 } else {
802 Some(a.display())
803 }
804 });
805
806 with_state("metadata_get", |st| {
807 if let Some(ns) = namespace {
808 match st.get_namespace(&dir, &ns) {
809 Some(fields) => {
810 let mut m = BTreeMap::new();
811 for (k, v) in fields {
812 m.insert(k, json_to_vm(&v));
813 }
814 Ok(VmValue::dict(m))
815 }
816 None => Ok(VmValue::Nil),
817 }
818 } else {
819 let resolved = st.resolve(&dir);
820 let mut m = BTreeMap::new();
821 for fields in resolved.namespaces.values() {
822 for (k, v) in fields {
823 m.insert(k.clone(), json_to_vm(v));
824 }
825 }
826 if m.is_empty() {
827 Ok(VmValue::Nil)
828 } else {
829 Ok(VmValue::dict(m))
830 }
831 }
832 })
833}
834
835#[harn_builtin(
836 sig = "metadata_resolve(dir: string, namespace?: string|nil) -> dict|nil",
837 category = "metadata"
838)]
839fn metadata_resolve_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
840 let dir = args.first().map(|a| a.display()).unwrap_or_default();
841 let namespace = args.get(1).and_then(|a| {
842 if matches!(a, VmValue::Nil) {
843 None
844 } else {
845 Some(a.display())
846 }
847 });
848 with_state("metadata_resolve", |st| {
849 let resolved = st.resolve(&dir);
850 if let Some(ns) = namespace {
851 match resolved.namespaces.get(&ns) {
852 Some(fields) => Ok(namespace_fields_to_vm(fields)),
853 None => Ok(VmValue::Nil),
854 }
855 } else if resolved.namespaces.is_empty() {
856 Ok(VmValue::Nil)
857 } else {
858 Ok(directory_metadata_to_vm(&resolved))
859 }
860 })
861}
862
863#[harn_builtin(
864 sig = "metadata_entries(namespace?: string|nil) -> list",
865 category = "metadata"
866)]
867fn metadata_entries_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
868 let namespace = args.first().and_then(|a| {
869 if matches!(a, VmValue::Nil) {
870 None
871 } else {
872 Some(a.display())
873 }
874 });
875 with_state("metadata_entries", |st| {
876 st.ensure_loaded();
877 let directories: Vec<String> = st.entries.keys().cloned().collect();
878 let mut items = Vec::new();
879 for dir in directories {
880 let local = st.local_directory(&dir);
881 let resolved = st.resolve(&dir);
882 let mut item = BTreeMap::new();
883 item.put_str("dir", normalize_directory_key(&dir));
884 match &namespace {
885 Some(ns) => {
886 item.insert(
887 "local".to_string(),
888 local
889 .namespaces
890 .get(ns)
891 .map(namespace_fields_to_vm)
892 .unwrap_or(VmValue::Nil),
893 );
894 item.insert(
895 "resolved".to_string(),
896 resolved
897 .namespaces
898 .get(ns)
899 .map(namespace_fields_to_vm)
900 .unwrap_or(VmValue::Nil),
901 );
902 }
903 None => {
904 item.insert("local".to_string(), directory_metadata_to_vm(&local));
905 item.insert("resolved".to_string(), directory_metadata_to_vm(&resolved));
906 }
907 }
908 items.push(VmValue::dict(item));
909 }
910 Ok(VmValue::List(std::sync::Arc::new(items)))
911 })
912}
913
914#[harn_builtin(
915 sig = "metadata_set(dir: string, namespace: string, data: dict) -> nil",
916 category = "metadata"
917)]
918fn metadata_set_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
919 let dir = args.first().map(|a| a.display()).unwrap_or_default();
920 let namespace = args.get(1).map(|a| a.display()).unwrap_or_default();
921 let data_val = args.get(2).cloned().unwrap_or(VmValue::Nil);
922
923 let mut data = BTreeMap::new();
924 if let VmValue::Dict(dict) = &data_val {
925 for (k, v) in dict.iter() {
926 data.insert(k.to_string(), vm_to_json(v));
927 }
928 }
929
930 with_state("metadata_set", |st| {
931 if !data.is_empty() {
932 st.set_namespace(&dir, &namespace, data);
933 }
934 Ok(VmValue::Nil)
935 })
936}
937
938fn metadata_fields_from_value(
939 namespace: &str,
940 value: &VmValue,
941) -> BTreeMap<FieldKey, serde_json::Value> {
942 let mut data = BTreeMap::new();
943 match value {
944 VmValue::Dict(dict) => {
945 for (k, v) in dict.iter() {
946 data.insert(k.to_string(), vm_to_json(v));
947 }
948 }
949 VmValue::String(_) if !namespace.is_empty() => {
950 data.insert(namespace.to_string(), vm_to_json(value));
951 }
952 _ => {}
953 }
954 data
955}
956
957fn classification_field<'a>(
958 metadata: &'a DirectoryMetadata,
959 key: &str,
960) -> Option<&'a serde_json::Value> {
961 metadata
962 .namespaces
963 .get("classification")
964 .and_then(|classification| classification.get(key))
965}
966
967fn classification_string(metadata: &DirectoryMetadata, key: &str) -> Option<String> {
968 classification_field(metadata, key)
969 .and_then(|value| value.as_str())
970 .map(ToString::to_string)
971}
972
973fn metadata_full_dir(base: &Path, dir: &str) -> PathBuf {
974 if dir.is_empty() || dir == "." {
975 base.to_path_buf()
976 } else {
977 base.join(dir)
978 }
979}
980
981fn metadata_inspect_stale_flags(
982 base: &Path,
983 dir: &str,
984 metadata: &DirectoryMetadata,
985) -> (bool, bool) {
986 let full_dir = metadata_full_dir(base, dir);
987 if let Some(stored_hash) = classification_string(metadata, "structureHash") {
988 let current_hash = compute_structure_hash(&full_dir);
989 if current_hash != stored_hash {
990 return (true, true);
991 }
992 }
993 if let Some(stored_hash) = classification_string(metadata, "contentHash") {
994 let current_hash = compute_content_hash_for_dir(&full_dir);
995 if current_hash != stored_hash {
996 return (true, true);
997 }
998 }
999 (false, false)
1000}
1001
1002fn optional_string_value(value: Option<String>) -> VmValue {
1003 value
1004 .map(|text| VmValue::String(arcstr::ArcStr::from(text)))
1005 .unwrap_or(VmValue::Nil)
1006}
1007
1008fn refresh_metadata_hashes(st: &mut MetadataState) {
1009 st.ensure_loaded();
1010 let base = st.base_dir.clone();
1011 let dirs: Vec<String> = st.entries.keys().cloned().collect();
1012 for dir in dirs {
1013 let full_dir = if dir.is_empty() {
1014 base.clone()
1015 } else {
1016 base.join(&dir)
1017 };
1018 let hash = compute_structure_hash(&full_dir);
1019 let entry = st.entries.entry(dir).or_default();
1020 let ns = entry
1021 .namespaces
1022 .entry("classification".to_string())
1023 .or_default();
1024 ns.insert("structureHash".to_string(), serde_json::Value::String(hash));
1025 }
1026 st.dirty = true;
1027}
1028
1029fn param_string(params: &crate::value::DictMap, key: &str) -> String {
1030 params
1031 .get(key)
1032 .map(|value| value.display())
1033 .unwrap_or_default()
1034}
1035
1036fn optional_param_string(params: &crate::value::DictMap, key: &str) -> Option<String> {
1037 let value = param_string(params, key);
1038 if value.is_empty() {
1039 None
1040 } else {
1041 Some(value)
1042 }
1043}
1044
1045pub(crate) fn project_metadata_host_get(
1052 params: &crate::value::DictMap,
1053) -> Result<VmValue, VmError> {
1054 let dir = param_string(params, "dir");
1055 let namespace = optional_param_string(params, "namespace");
1056 with_state("project.metadata_get", |st| {
1057 if let Some(ns) = namespace {
1058 match st.get_namespace(&dir, &ns) {
1059 Some(fields) => Ok(namespace_fields_to_vm(&fields)),
1060 None => Ok(VmValue::Nil),
1061 }
1062 } else {
1063 let resolved = st.resolve(&dir);
1064 if resolved.namespaces.is_empty() {
1065 Ok(VmValue::Nil)
1066 } else {
1067 Ok(directory_metadata_to_vm(&resolved))
1068 }
1069 }
1070 })
1071}
1072
1073pub(crate) fn project_metadata_host_inspect(
1075 params: &crate::value::DictMap,
1076) -> Result<VmValue, VmError> {
1077 let dir = param_string(params, "dir");
1078 let namespace = param_string(params, "namespace");
1079 let key = optional_param_string(params, "key");
1080 with_state("project.metadata_inspect", |st| {
1081 let resolved = st.resolve(&dir);
1082 let origin = st.origin_directory(&dir, &namespace, key.as_deref());
1083 let inspection_dir = origin.clone().unwrap_or_else(|| dir.clone());
1084 let missing_structure_hash = classification_string(&resolved, "structureHash").is_none();
1085 let missing_content_hash = classification_string(&resolved, "contentHash").is_none();
1086 let (stale_tier1, stale_tier2) = if missing_structure_hash || missing_content_hash {
1087 (false, false)
1088 } else {
1089 metadata_inspect_stale_flags(&st.base_dir, &inspection_dir, &resolved)
1090 };
1091
1092 let mut payload = BTreeMap::new();
1093 payload.insert(
1094 "dir".to_string(),
1095 VmValue::String(arcstr::ArcStr::from(dir)),
1096 );
1097 payload.insert(
1098 "namespace".to_string(),
1099 VmValue::String(arcstr::ArcStr::from(namespace.clone())),
1100 );
1101 payload.insert(
1102 "origin_dir".to_string(),
1103 optional_string_value(origin.clone().map(|raw| normalize_directory_key(&raw))),
1104 );
1105 payload.insert(
1106 "inspected_dir".to_string(),
1107 VmValue::String(arcstr::ArcStr::from(normalize_directory_key(
1108 &inspection_dir,
1109 ))),
1110 );
1111 payload.insert(
1112 "missing_structure_hash".to_string(),
1113 VmValue::Bool(missing_structure_hash),
1114 );
1115 payload.insert(
1116 "missing_content_hash".to_string(),
1117 VmValue::Bool(missing_content_hash),
1118 );
1119 payload.insert("stale_tier1".to_string(), VmValue::Bool(stale_tier1));
1120 payload.insert("stale_tier2".to_string(), VmValue::Bool(stale_tier2));
1121 let enriched_at = classification_field(&resolved, "enrichedAt");
1122 payload.insert(
1123 "has_enriched_at".to_string(),
1124 VmValue::Bool(enriched_at.is_some()),
1125 );
1126 payload.insert(
1127 "enriched_at_ms".to_string(),
1128 enriched_at.map(json_to_vm).unwrap_or(VmValue::Nil),
1129 );
1130 payload.insert(
1131 "structure_hash".to_string(),
1132 optional_string_value(classification_string(&resolved, "structureHash")),
1133 );
1134 payload.insert(
1135 "content_hash".to_string(),
1136 optional_string_value(classification_string(&resolved, "contentHash")),
1137 );
1138
1139 if namespace.is_empty() {
1140 payload.insert("resolved".to_string(), directory_metadata_to_vm(&resolved));
1141 payload.insert("origin".to_string(), VmValue::Nil);
1142 } else {
1143 let resolved_fields = resolved
1144 .namespaces
1145 .get(&namespace)
1146 .map(namespace_fields_to_vm)
1147 .unwrap_or(VmValue::dict(crate::value::DictMap::new()));
1148 payload.insert("resolved".to_string(), resolved_fields);
1149 let origin_fields = origin
1150 .as_ref()
1151 .and_then(|origin_dir| st.entries.get(origin_dir))
1152 .and_then(|metadata| metadata.namespaces.get(&namespace))
1153 .map(namespace_fields_to_vm)
1154 .unwrap_or(VmValue::Nil);
1155 payload.insert("origin".to_string(), origin_fields);
1156 }
1157 Ok(VmValue::dict(payload))
1158 })
1159}
1160
1161pub(crate) fn project_metadata_host_set(
1163 params: &crate::value::DictMap,
1164) -> Result<VmValue, VmError> {
1165 let dir = param_string(params, "dir");
1166 let namespace = param_string(params, "namespace");
1167 let value = params
1168 .get("value")
1169 .or_else(|| params.get("data"))
1170 .cloned()
1171 .unwrap_or(VmValue::Nil);
1172 let data = metadata_fields_from_value(&namespace, &value);
1173 if namespace.is_empty() || data.is_empty() {
1174 return Ok(VmValue::Nil);
1175 }
1176 with_state("project.metadata_set", |st| {
1177 st.set_namespace(&dir, &namespace, data);
1178 st.save().map_err(VmError::Runtime)?;
1179 Ok(VmValue::Nil)
1180 })
1181}
1182
1183pub(crate) fn project_metadata_host_save(
1185 _params: &crate::value::DictMap,
1186) -> Result<VmValue, VmError> {
1187 with_state("project.metadata_save", |st| {
1188 st.save().map_err(VmError::Runtime)?;
1189 Ok(VmValue::Nil)
1190 })
1191}
1192
1193pub(crate) fn project_metadata_host_stale(
1195 _params: &crate::value::DictMap,
1196) -> Result<VmValue, VmError> {
1197 with_state("project.metadata_stale", |st| {
1198 st.ensure_loaded();
1199 let base = st.base_dir.clone();
1200 Ok(metadata_stale_value(st, &base))
1201 })
1202}
1203
1204pub(crate) fn project_metadata_host_refresh_hashes(
1206 _params: &crate::value::DictMap,
1207) -> Result<VmValue, VmError> {
1208 with_state("project.metadata_refresh_hashes", |st| {
1209 refresh_metadata_hashes(st);
1210 st.save().map_err(VmError::Runtime)?;
1211 Ok(VmValue::Nil)
1212 })
1213}
1214
1215#[harn_builtin(sig = "metadata_save() -> nil", category = "metadata")]
1216fn metadata_save_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1217 with_state("metadata_save", |st| {
1218 st.save().map_err(VmError::Runtime)?;
1219 Ok(VmValue::Nil)
1220 })
1221}
1222
1223#[harn_builtin(
1224 sig = "metadata_stale(project?: string) -> dict",
1225 category = "metadata"
1226)]
1227fn metadata_stale_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1228 with_state("metadata_stale", |st| {
1229 st.ensure_loaded();
1230 let base = st.base_dir.clone();
1231 let mut tier1_stale: Vec<VmValue> = Vec::new();
1232 let mut tier2_stale: Vec<VmValue> = Vec::new();
1233
1234 for (dir, meta) in &st.entries {
1235 let full_dir = if dir.is_empty() {
1236 base.clone()
1237 } else {
1238 base.join(dir)
1239 };
1240 if let Some(stored_hash) = meta
1241 .namespaces
1242 .get("classification")
1243 .and_then(|ns| ns.get("structureHash"))
1244 .and_then(|v| v.as_str())
1245 {
1246 let current_hash = compute_structure_hash(&full_dir);
1247 if current_hash != stored_hash {
1248 tier1_stale.push(VmValue::String(arcstr::ArcStr::from(dir.as_str())));
1249 continue;
1250 }
1251 }
1252 if let Some(stored_hash) = meta
1253 .namespaces
1254 .get("classification")
1255 .and_then(|ns| ns.get("contentHash"))
1256 .and_then(|v| v.as_str())
1257 {
1258 let current_hash = compute_content_hash_for_dir(&full_dir);
1259 if current_hash != stored_hash {
1260 tier2_stale.push(VmValue::String(arcstr::ArcStr::from(dir.as_str())));
1261 }
1262 }
1263 }
1264
1265 let any_stale = !tier1_stale.is_empty() || !tier2_stale.is_empty();
1266 let mut m = BTreeMap::new();
1267 m.insert("any_stale".to_string(), VmValue::Bool(any_stale));
1268 m.insert(
1269 "tier1".to_string(),
1270 VmValue::List(std::sync::Arc::new(tier1_stale)),
1271 );
1272 m.insert(
1273 "tier2".to_string(),
1274 VmValue::List(std::sync::Arc::new(tier2_stale)),
1275 );
1276 Ok(VmValue::dict(m))
1277 })
1278}
1279
1280#[harn_builtin(sig = "metadata_refresh_hashes() -> nil", category = "metadata")]
1281fn metadata_refresh_hashes_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1282 with_state("metadata_refresh_hashes", |st| {
1283 refresh_metadata_hashes(st);
1284 Ok(VmValue::Nil)
1285 })
1286}
1287
1288#[harn_builtin(
1289 sig = "metadata_status(namespace?: string|nil) -> dict",
1290 category = "metadata"
1291)]
1292fn metadata_status_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1293 let namespace = args.first().and_then(|a| {
1294 if matches!(a, VmValue::Nil) {
1295 None
1296 } else {
1297 Some(a.display())
1298 }
1299 });
1300 with_state("metadata_status", |st| {
1301 st.ensure_loaded();
1302 let base = st.base_dir.clone();
1303 let mut namespaces = BTreeMap::new();
1304 let mut directories = Vec::new();
1305 let mut missing_structure_hash = Vec::new();
1306 let mut missing_content_hash = Vec::new();
1307 for (dir, meta) in &st.entries {
1308 directories.push(VmValue::String(arcstr::ArcStr::from(
1309 normalize_directory_key(dir),
1310 )));
1311 for ns in meta.namespaces.keys() {
1312 namespaces.insert(ns.clone(), VmValue::Bool(true));
1313 }
1314 let full_dir = if dir.is_empty() {
1315 base.clone()
1316 } else {
1317 base.join(dir)
1318 };
1319 let relevant = namespace
1320 .as_ref()
1321 .and_then(|name| meta.namespaces.get(name))
1322 .or_else(|| meta.namespaces.get("classification"));
1323 if let Some(fields) = relevant {
1324 if !fields.contains_key("structureHash") && full_dir.exists() {
1325 missing_structure_hash.push(VmValue::String(arcstr::ArcStr::from(
1326 normalize_directory_key(dir),
1327 )));
1328 }
1329 if !fields.contains_key("contentHash") && full_dir.exists() {
1330 missing_content_hash.push(VmValue::String(arcstr::ArcStr::from(
1331 normalize_directory_key(dir),
1332 )));
1333 }
1334 }
1335 }
1336 let stale = metadata_stale_value(st, &base);
1337 let mut result = BTreeMap::new();
1338 result.insert(
1339 "directory_count".to_string(),
1340 VmValue::Int(st.entries.len() as i64),
1341 );
1342 result.insert(
1343 "namespace_count".to_string(),
1344 VmValue::Int(namespaces.len() as i64),
1345 );
1346 result.insert(
1347 "namespaces".to_string(),
1348 VmValue::List(std::sync::Arc::new(
1349 namespaces
1350 .keys()
1351 .cloned()
1352 .map(|name| VmValue::String(arcstr::ArcStr::from(name)))
1353 .collect(),
1354 )),
1355 );
1356 result.insert(
1357 "directories".to_string(),
1358 VmValue::List(std::sync::Arc::new(directories)),
1359 );
1360 result.insert(
1361 "missing_structure_hash".to_string(),
1362 VmValue::List(std::sync::Arc::new(missing_structure_hash)),
1363 );
1364 result.insert(
1365 "missing_content_hash".to_string(),
1366 VmValue::List(std::sync::Arc::new(missing_content_hash)),
1367 );
1368 result.insert("stale".to_string(), stale);
1369 Ok(VmValue::dict(result))
1370 })
1371}
1372
1373#[harn_builtin(
1374 sig = "compute_content_hash(dir: string) -> string",
1375 category = "metadata"
1376)]
1377fn compute_content_hash_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1378 let dir = args.first().map(|a| a.display()).unwrap_or_default();
1379 with_state("compute_content_hash", |st| {
1380 let full_dir = if dir.is_empty() {
1381 st.base_dir.clone()
1382 } else {
1383 st.base_dir.join(&dir)
1384 };
1385 let hash = compute_content_hash_for_dir(&full_dir);
1386 Ok(VmValue::String(arcstr::ArcStr::from(hash)))
1387 })
1388}
1389
1390#[harn_builtin(sig = "invalidate_facts(dir?: string) -> nil", category = "metadata")]
1392fn invalidate_facts_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1393 Ok(VmValue::Nil)
1394}
1395
1396#[harn_builtin(
1400 sig = "path_metadata_get(path: string, namespace?: string|nil, opts?: dict|nil) -> dict|nil",
1401 category = "metadata"
1402)]
1403fn path_metadata_get_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1404 let path = args.first().map(|a| a.display()).unwrap_or_default();
1405 let namespace = args.get(1).and_then(|a| {
1406 if matches!(a, VmValue::Nil) {
1407 None
1408 } else {
1409 Some(a.display())
1410 }
1411 });
1412 let Some(kind) = parse_path_kind(args.get(2)) else {
1413 return Err(VmError::Runtime(
1414 "path_metadata_get: opts.kind must be \"file\" or \"dir\"".to_string(),
1415 ));
1416 };
1417 with_state("path_metadata_get", |st| match kind {
1418 PathKind::File => {
1419 let Some(key) = normalize_file_key(&path) else {
1420 return Ok(VmValue::Nil);
1421 };
1422 match namespace {
1423 Some(ns) => match st.file_namespace(&key, &ns) {
1424 Some(fields) => Ok(namespace_fields_to_vm(&fields)),
1425 None => Ok(VmValue::Nil),
1426 },
1427 None => match st.file_entry(&key) {
1428 Some(meta) if !meta.namespaces.is_empty() => {
1429 Ok(directory_metadata_to_vm(&meta))
1430 }
1431 _ => Ok(VmValue::Nil),
1432 },
1433 }
1434 }
1435 PathKind::Dir => {
1436 if let Some(ns) = namespace {
1437 match st.get_namespace(&path, &ns) {
1438 Some(fields) => Ok(namespace_fields_to_vm(&fields)),
1439 None => Ok(VmValue::Nil),
1440 }
1441 } else {
1442 let resolved = st.resolve(&path);
1443 if resolved.namespaces.is_empty() {
1444 Ok(VmValue::Nil)
1445 } else {
1446 Ok(directory_metadata_to_vm(&resolved))
1447 }
1448 }
1449 }
1450 })
1451}
1452
1453#[harn_builtin(
1454 sig = "path_metadata_set(path: string, namespace: string, data: dict, opts?: dict|nil) -> nil",
1455 category = "metadata"
1456)]
1457fn path_metadata_set_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1458 let path = args.first().map(|a| a.display()).unwrap_or_default();
1459 let namespace = args.get(1).map(|a| a.display()).unwrap_or_default();
1460 let data_val = args.get(2).cloned().unwrap_or(VmValue::Nil);
1461 let Some(kind) = parse_path_kind(args.get(3)) else {
1462 return Err(VmError::Runtime(
1463 "path_metadata_set: opts.kind must be \"file\" or \"dir\"".to_string(),
1464 ));
1465 };
1466 if namespace.is_empty() {
1467 return Err(VmError::Runtime(
1468 "path_metadata_set: namespace must not be empty".to_string(),
1469 ));
1470 }
1471 let mut data = BTreeMap::new();
1472 if let VmValue::Dict(dict) = &data_val {
1473 for (k, v) in dict.iter() {
1474 data.insert(k.to_string(), vm_to_json(v));
1475 }
1476 }
1477 if data.is_empty() {
1478 return Ok(VmValue::Nil);
1479 }
1480 with_state("path_metadata_set", |st| {
1481 match kind {
1482 PathKind::File => {
1483 let Some(key) = normalize_file_key(&path) else {
1484 return Err(VmError::Runtime(format!(
1485 "path_metadata_set: {path:?} is not a valid file path"
1486 )));
1487 };
1488 st.set_file_namespace(&key, &namespace, data);
1489 }
1490 PathKind::Dir => {
1491 st.set_namespace(&path, &namespace, data);
1492 }
1493 }
1494 Ok(VmValue::Nil)
1495 })
1496}
1497
1498#[harn_builtin(
1501 sig = "path_metadata_entries(namespace?: string|nil, opts?: dict|nil) -> list",
1502 category = "metadata"
1503)]
1504fn path_metadata_entries_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1505 let namespace = args.first().and_then(|a| {
1506 if matches!(a, VmValue::Nil) {
1507 None
1508 } else {
1509 Some(a.display())
1510 }
1511 });
1512 let Some(filter) = parse_path_kind_filter(args.get(1), PathKindFilter::File, true) else {
1513 return Err(VmError::Runtime(
1514 "path_metadata_entries: opts.kind must be \"file\", \"dir\", or \"all\"".to_string(),
1515 ));
1516 };
1517 let include_files = matches!(filter, PathKindFilter::File | PathKindFilter::All);
1518 let include_dirs = matches!(filter, PathKindFilter::Dir | PathKindFilter::All);
1519 with_state("path_metadata_entries", |st| {
1520 st.ensure_loaded();
1521 let mut items = Vec::new();
1522 if include_files {
1523 for (path, meta) in &st.files {
1524 let local = match &namespace {
1525 Some(ns) => match meta.namespaces.get(ns) {
1526 Some(fields) => namespace_fields_to_vm(fields),
1527 None => continue,
1528 },
1529 None => directory_metadata_to_vm(meta),
1530 };
1531 let mut item = BTreeMap::new();
1532 item.put_str("kind", "file");
1533 item.put_str("path", path.as_str());
1534 item.insert("local".to_string(), local);
1535 items.push(VmValue::dict(item));
1536 }
1537 }
1538 if include_dirs {
1539 let directories: Vec<String> = st.entries.keys().cloned().collect();
1540 for dir in directories {
1541 let local = st.local_directory(&dir);
1542 let resolved = st.resolve(&dir);
1543 let local_value = match &namespace {
1544 Some(ns) => match local.namespaces.get(ns) {
1545 Some(fields) => namespace_fields_to_vm(fields),
1546 None => continue,
1547 },
1548 None => directory_metadata_to_vm(&local),
1549 };
1550 let resolved_value = match &namespace {
1551 Some(ns) => resolved
1552 .namespaces
1553 .get(ns)
1554 .map(namespace_fields_to_vm)
1555 .unwrap_or(VmValue::Nil),
1556 None => directory_metadata_to_vm(&resolved),
1557 };
1558 let mut item = BTreeMap::new();
1559 item.put_str("kind", "dir");
1560 item.put_str("path", normalize_directory_key(&dir));
1561 item.insert("local".to_string(), local_value);
1562 item.insert("resolved".to_string(), resolved_value);
1563 items.push(VmValue::dict(item));
1564 }
1565 }
1566 Ok(VmValue::List(std::sync::Arc::new(items)))
1567 })
1568}
1569
1570fn compute_structure_hash(dir: &Path) -> String {
1572 let mut entries: Vec<String> = Vec::new();
1573 if let Ok(rd) = std::fs::read_dir(dir) {
1574 for entry in rd.flatten() {
1575 if let Ok(meta) = entry.metadata() {
1576 let name = entry.file_name().to_string_lossy().into_owned();
1577 entries.push(format!("{}:{}", name, meta.len()));
1578 }
1579 }
1580 }
1581 entries.sort();
1582 let joined = entries.join("|");
1583 format!("{:x}", fnv_hash(joined.as_bytes()))
1584}
1585
1586fn compute_content_hash_for_dir(dir: &Path) -> String {
1588 let mut entries: Vec<String> = Vec::new();
1589 if let Ok(rd) = std::fs::read_dir(dir) {
1590 for entry in rd.flatten() {
1591 if let Ok(meta) = entry.metadata() {
1592 let name = entry.file_name().to_string_lossy().into_owned();
1593 let mtime = meta
1594 .modified()
1595 .ok()
1596 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1597 .map(|d| d.as_secs())
1598 .unwrap_or(0);
1599 entries.push(format!("{}:{}:{}", name, meta.len(), mtime));
1600 }
1601 }
1602 }
1603 entries.sort();
1604 let joined = entries.join("|");
1605 format!("{:x}", fnv_hash(joined.as_bytes()))
1606}
1607
1608fn fnv_hash(data: &[u8]) -> u64 {
1610 let mut hash: u64 = 0xcbf29ce484222325;
1611 for &byte in data {
1612 hash ^= byte as u64;
1613 hash = hash.wrapping_mul(0x100000001b3);
1614 }
1615 hash
1616}
1617
1618#[harn_builtin(
1619 sig = "scan_directory(path?: string, pattern_or_options?: string|dict|nil, options?: dict|nil) -> list",
1620 category = "metadata"
1621)]
1622fn scan_directory_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
1623 let rel_dir = args.first().map(|a| a.display()).unwrap_or_default();
1624 let options = parse_scan_options(args.get(1), args.get(2));
1625 let scan_base = resolve_scan_root(".");
1626 let full_dir = if rel_dir.is_empty() {
1627 scan_base.clone()
1628 } else {
1629 scan_base.join(&rel_dir)
1630 };
1631 let mut results: Vec<VmValue> = Vec::new();
1632 scan_dir_recursive(&full_dir, &scan_base, &options, &mut results, 0);
1633 Ok(VmValue::List(std::sync::Arc::new(results)))
1634}
1635
1636fn metadata_stale_value(state: &MetadataState, base_dir: &Path) -> VmValue {
1637 let mut tier1_stale: Vec<VmValue> = Vec::new();
1638 let mut tier2_stale: Vec<VmValue> = Vec::new();
1639 for (dir, meta) in &state.entries {
1640 let full_dir = if dir.is_empty() {
1641 base_dir.to_path_buf()
1642 } else {
1643 base_dir.join(dir)
1644 };
1645 if let Some(stored_hash) = meta
1646 .namespaces
1647 .get("classification")
1648 .and_then(|ns| ns.get("structureHash"))
1649 .and_then(|v| v.as_str())
1650 {
1651 let current_hash = compute_structure_hash(&full_dir);
1652 if current_hash != stored_hash {
1653 tier1_stale.push(VmValue::String(arcstr::ArcStr::from(
1654 normalize_directory_key(dir),
1655 )));
1656 continue;
1657 }
1658 }
1659 if let Some(stored_hash) = meta
1660 .namespaces
1661 .get("classification")
1662 .and_then(|ns| ns.get("contentHash"))
1663 .and_then(|v| v.as_str())
1664 {
1665 let current_hash = compute_content_hash_for_dir(&full_dir);
1666 if current_hash != stored_hash {
1667 tier2_stale.push(VmValue::String(arcstr::ArcStr::from(
1668 normalize_directory_key(dir),
1669 )));
1670 }
1671 }
1672 }
1673 let any_stale = !tier1_stale.is_empty() || !tier2_stale.is_empty();
1674 let mut m = BTreeMap::new();
1675 m.insert("any_stale".to_string(), VmValue::Bool(any_stale));
1676 m.insert(
1677 "tier1".to_string(),
1678 VmValue::List(std::sync::Arc::new(tier1_stale)),
1679 );
1680 m.insert(
1681 "tier2".to_string(),
1682 VmValue::List(std::sync::Arc::new(tier2_stale)),
1683 );
1684 VmValue::dict(m)
1685}
1686
1687fn scan_dir_recursive(
1688 dir: &Path,
1689 base: &Path,
1690 options: &ScanOptions,
1691 results: &mut Vec<VmValue>,
1692 depth: usize,
1693) {
1694 if depth > options.max_depth {
1695 return;
1696 }
1697 let rd = match std::fs::read_dir(dir) {
1698 Ok(rd) => rd,
1699 Err(_) => return,
1700 };
1701 for entry in rd.flatten() {
1702 let meta = match entry.metadata() {
1703 Ok(m) => m,
1704 Err(_) => continue,
1705 };
1706 let name = entry.file_name().to_string_lossy().into_owned();
1707 if !options.include_hidden && name.starts_with('.') {
1708 continue;
1709 }
1710 let rel_path = entry
1711 .path()
1712 .strip_prefix(base)
1713 .unwrap_or(entry.path().as_path())
1714 .to_string_lossy()
1715 .replace('\\', "/");
1716 if let Some(pat) = &options.pattern {
1717 if !glob_match(pat, &rel_path) {
1718 if meta.is_dir() {
1719 scan_dir_recursive(&entry.path(), base, options, results, depth + 1);
1720 }
1721 continue;
1722 }
1723 }
1724 let mtime = meta
1725 .modified()
1726 .ok()
1727 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1728 .map(|d| d.as_secs() as i64)
1729 .unwrap_or(0);
1730 let mut m = BTreeMap::new();
1731 m.put_str("path", rel_path);
1732 m.insert("size".to_string(), VmValue::Int(meta.len() as i64));
1733 m.insert("modified".to_string(), VmValue::Int(mtime));
1734 m.insert("is_dir".to_string(), VmValue::Bool(meta.is_dir()));
1735 if (meta.is_dir() && options.include_dirs) || (!meta.is_dir() && options.include_files) {
1736 results.push(VmValue::dict(m));
1737 }
1738 if meta.is_dir() {
1739 scan_dir_recursive(&entry.path(), base, options, results, depth + 1);
1740 }
1741 }
1742}
1743
1744fn glob_match(pattern: &str, path: &str) -> bool {
1749 if pattern.contains('*') || pattern.contains('?') {
1750 harn_glob::match_name(pattern, path)
1751 } else {
1752 path.contains(pattern)
1753 }
1754}
1755
1756#[cfg(test)]
1757mod tests {
1758 use super::*;
1759
1760 #[test]
1761 fn scan_pattern_matching_globs_and_substrings() {
1762 assert!(glob_match("*.rs", "src/nested/main.rs"));
1764 assert!(glob_match("**/*.rs", "src/nested/main.rs"));
1767 assert!(glob_match("src/**", "src/nested/main.rs"));
1768 assert!(!glob_match("*.toml", "src/main.rs"));
1769 assert!(glob_match("nested", "src/nested/main.rs"));
1771 assert!(!glob_match("missing", "src/nested/main.rs"));
1772 }
1773
1774 fn temp_path(name: &str) -> PathBuf {
1775 let unique = std::time::SystemTime::now()
1776 .duration_since(std::time::UNIX_EPOCH)
1777 .unwrap_or_default()
1778 .as_nanos();
1779 std::env::temp_dir().join(format!("harn-metadata-{name}-{unique}"))
1780 }
1781
1782 #[test]
1783 fn metadata_resolve_preserves_namespace_structure() {
1784 let base = temp_path("resolve");
1785 let mut state = MetadataState::new(&base);
1786 state.set_namespace(
1787 "",
1788 "classification",
1789 BTreeMap::from([("language".into(), serde_json::json!("rust"))]),
1790 );
1791 state.set_namespace(
1792 "src",
1793 "classification",
1794 BTreeMap::from([("owner".into(), serde_json::json!("vm"))]),
1795 );
1796
1797 let resolved = state.resolve("src");
1798 let classification = resolved.namespaces.get("classification").unwrap();
1799 assert_eq!(
1800 classification.get("language"),
1801 Some(&serde_json::json!("rust"))
1802 );
1803 assert_eq!(classification.get("owner"), Some(&serde_json::json!("vm")));
1804 }
1805
1806 #[test]
1807 fn metadata_save_writes_namespace_shards() {
1808 let base = temp_path("save");
1809 let mut state = MetadataState::new(&base);
1810 state.set_namespace(
1811 ".",
1812 "classification",
1813 BTreeMap::from([("language".into(), serde_json::json!("rust"))]),
1814 );
1815 state.set_namespace(
1816 "src",
1817 "coding-enrichment-v1",
1818 BTreeMap::from([("_deep_scan".into(), serde_json::json!({"version": 1}))]),
1819 );
1820 state.save().expect("save");
1821
1822 let metadata_root = crate::runtime_paths::metadata_dir(&base);
1823 let classification = std::fs::read_to_string(
1824 metadata_root
1825 .join("classification")
1826 .join(NAMESPACE_ENTRIES_FILE),
1827 )
1828 .expect("classification shard");
1829 let parsed = serde_json::from_str::<serde_json::Value>(&classification).expect("json");
1830 assert_eq!(
1831 parsed.get("namespace").and_then(|value| value.as_str()),
1832 Some("classification")
1833 );
1834 assert!(parsed
1835 .get("entries")
1836 .and_then(|value| value.get("."))
1837 .is_some());
1838
1839 let enrichment = std::fs::read_to_string(
1840 metadata_root
1841 .join("coding-enrichment-v1")
1842 .join(NAMESPACE_ENTRIES_FILE),
1843 )
1844 .expect("enrichment shard");
1845 let parsed = serde_json::from_str::<serde_json::Value>(&enrichment).expect("json");
1846 assert!(parsed
1847 .get("entries")
1848 .and_then(|value| value.get("src"))
1849 .is_some());
1850 }
1851
1852 #[test]
1853 fn metadata_load_merges_legacy_and_namespace_shards() {
1854 let base = temp_path("load");
1855 let metadata_root = crate::runtime_paths::metadata_dir(&base);
1856 std::fs::create_dir_all(metadata_root.join("facts")).unwrap();
1857 std::fs::write(
1858 metadata_root.join(LEGACY_SHARD_NAME),
1859 serde_json::json!({
1860 "version": 2,
1861 "entries": {
1862 ".": {
1863 "namespaces": {
1864 "classification": {
1865 "language": "rust"
1866 }
1867 }
1868 }
1869 }
1870 })
1871 .to_string(),
1872 )
1873 .unwrap();
1874 std::fs::write(
1875 metadata_root.join("facts").join(NAMESPACE_ENTRIES_FILE),
1876 serde_json::json!({
1877 "version": 1,
1878 "namespace": "facts",
1879 "entries": {
1880 "src": {
1881 "kind": "module"
1882 }
1883 }
1884 })
1885 .to_string(),
1886 )
1887 .unwrap();
1888
1889 let mut state = MetadataState::new(&base);
1890 state.ensure_loaded();
1891 assert_eq!(
1892 state
1893 .entries
1894 .get(".")
1895 .and_then(|meta| meta.namespaces.get("classification"))
1896 .and_then(|fields| fields.get("language")),
1897 Some(&serde_json::json!("rust"))
1898 );
1899 assert_eq!(
1900 state
1901 .entries
1902 .get("src")
1903 .and_then(|meta| meta.namespaces.get("facts"))
1904 .and_then(|fields| fields.get("kind")),
1905 Some(&serde_json::json!("module"))
1906 );
1907 }
1908
1909 #[test]
1910 fn path_metadata_file_round_trip_does_not_inherit() {
1911 let base = temp_path("path_file_roundtrip");
1912 let mut state = MetadataState::new(&base);
1913
1914 state.set_namespace(
1916 "src",
1917 "facts",
1918 BTreeMap::from([("owner".into(), serde_json::json!("vm"))]),
1919 );
1920 state.set_file_namespace(
1921 "src/foo.rs",
1922 "facts",
1923 BTreeMap::from([("summary".into(), serde_json::json!("entry point"))]),
1924 );
1925
1926 let file_fields = state.file_namespace("src/foo.rs", "facts").expect("file");
1927 assert_eq!(
1928 file_fields.get("summary"),
1929 Some(&serde_json::json!("entry point"))
1930 );
1931 assert!(!file_fields.contains_key("owner"));
1933
1934 assert!(state.file_namespace("src/missing.rs", "facts").is_none());
1936 assert!(state
1938 .file_namespace("src/foo.rs", "other_namespace")
1939 .is_none());
1940 }
1941
1942 #[test]
1943 fn path_metadata_persists_files_alongside_dirs() {
1944 let base = temp_path("path_persist");
1945 let mut state = MetadataState::new(&base);
1946 state.set_namespace(
1947 ".",
1948 "classification",
1949 BTreeMap::from([("language".into(), serde_json::json!("rust"))]),
1950 );
1951 state.set_file_namespace(
1952 "src/foo.rs",
1953 "facts",
1954 BTreeMap::from([("summary".into(), serde_json::json!("entry point"))]),
1955 );
1956 state.set_file_namespace(
1957 "src/bar.rs",
1958 "facts",
1959 BTreeMap::from([("summary".into(), serde_json::json!("helpers"))]),
1960 );
1961 state.save().expect("save");
1962
1963 let facts_shard = std::fs::read_to_string(
1964 crate::runtime_paths::metadata_dir(&base)
1965 .join("facts")
1966 .join(NAMESPACE_ENTRIES_FILE),
1967 )
1968 .expect("facts shard");
1969 let parsed = serde_json::from_str::<serde_json::Value>(&facts_shard).expect("json");
1970 let files = parsed.get("files").and_then(|v| v.as_object()).unwrap();
1971 assert!(files.contains_key("src/foo.rs"));
1972 assert!(files.contains_key("src/bar.rs"));
1973
1974 let class_shard = std::fs::read_to_string(
1976 crate::runtime_paths::metadata_dir(&base)
1977 .join("classification")
1978 .join(NAMESPACE_ENTRIES_FILE),
1979 )
1980 .expect("classification shard");
1981 let parsed = serde_json::from_str::<serde_json::Value>(&class_shard).expect("json");
1982 assert!(parsed.get("files").is_none());
1983
1984 let mut reloaded = MetadataState::new(&base);
1986 let fields = reloaded
1987 .file_namespace("src/foo.rs", "facts")
1988 .expect("reloaded");
1989 assert_eq!(
1990 fields.get("summary"),
1991 Some(&serde_json::json!("entry point"))
1992 );
1993 }
1994
1995 #[test]
1996 fn path_metadata_load_tolerates_stale_snapshot_without_files_section() {
1997 let base = temp_path("path_stale");
1998 let metadata_root = crate::runtime_paths::metadata_dir(&base);
1999 std::fs::create_dir_all(metadata_root.join("facts")).unwrap();
2000 std::fs::write(
2002 metadata_root.join("facts").join(NAMESPACE_ENTRIES_FILE),
2003 serde_json::json!({
2004 "version": 1,
2005 "namespace": "facts",
2006 "entries": {
2007 "src": {"kind": "module"}
2008 }
2009 })
2010 .to_string(),
2011 )
2012 .unwrap();
2013
2014 let mut state = MetadataState::new(&base);
2015 state.ensure_loaded();
2016 assert_eq!(
2017 state
2018 .entries
2019 .get("src")
2020 .and_then(|meta| meta.namespaces.get("facts"))
2021 .and_then(|f| f.get("kind")),
2022 Some(&serde_json::json!("module"))
2023 );
2024 assert!(state.files.is_empty());
2025 assert!(state.file_namespace("src/foo.rs", "facts").is_none());
2026 }
2027
2028 #[test]
2029 fn normalize_file_key_handles_common_inputs() {
2030 assert_eq!(normalize_file_key("src/foo.rs"), Some("src/foo.rs".into()));
2031 assert_eq!(
2032 normalize_file_key("./src/foo.rs"),
2033 Some("src/foo.rs".into())
2034 );
2035 assert_eq!(
2036 normalize_file_key("src\\nested\\foo.rs"),
2037 Some("src/nested/foo.rs".into())
2038 );
2039 assert_eq!(normalize_file_key("src/foo.rs/"), Some("src/foo.rs".into()));
2040 assert_eq!(normalize_file_key(""), None);
2041 assert_eq!(normalize_file_key("."), None);
2042 assert_eq!(normalize_file_key(".."), None);
2043 }
2044
2045 #[test]
2046 fn scan_options_filter_hidden_and_depth() {
2047 let base = temp_path("scan");
2048 std::fs::create_dir_all(base.join("project/deep")).unwrap();
2049 std::fs::write(base.join("project/root.txt"), "root").unwrap();
2050 std::fs::write(base.join("project/.hidden.txt"), "hidden").unwrap();
2051 std::fs::write(base.join("project/deep/nested.txt"), "nested").unwrap();
2052
2053 let options = ScanOptions {
2054 pattern: Some(".txt".into()),
2055 max_depth: 0,
2056 include_hidden: false,
2057 include_dirs: false,
2058 include_files: true,
2059 };
2060 let mut results = Vec::new();
2061 scan_dir_recursive(&base.join("project"), &base, &options, &mut results, 0);
2062 let paths: Vec<String> = results
2063 .into_iter()
2064 .map(|value| match value {
2065 VmValue::Dict(dict) => dict.get("path").unwrap().display(),
2066 _ => String::new(),
2067 })
2068 .collect();
2069 assert_eq!(paths, vec!["project/root.txt".to_string()]);
2070 let _ = std::fs::remove_dir_all(base);
2071 }
2072}