Skip to main content

harn_vm/
metadata.rs

1//! Project metadata store for Harn's runtime state root.
2//!
3//! Provides `metadata_get`, `metadata_set`, `metadata_save`, `metadata_stale`,
4//! and `metadata_refresh_hashes` builtins. Stores sharded JSON files by
5//! package root.
6//!
7//! Resolution uses hierarchical inheritance: child directories inherit from
8//! parent directories, with overrides at each level.
9
10use 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/// Per-path metadata: namespaces -> keys -> JSON values. Used for both
25/// directory entries (inherited via [`MetadataState::resolve`]) and file
26/// entries (exact-path only via [`MetadataState::file_namespace`]).
27#[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/// Loaded form of a namespace shard: directory entries plus file entries
35/// keyed by normalized relative path. File entries do not inherit.
36#[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
62/// The full metadata store: directory entries (hierarchical) plus file
63/// entries (exact-path).
64pub(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    /// Resolve metadata for a directory with hierarchical inheritance.
101    /// Walks from root (".") through each path component, merging at each level.
102    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(&current) {
122                merge_metadata(&mut result, meta);
123            }
124        }
125
126        result
127    }
128
129    /// Get a specific namespace for a resolved directory.
130    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(&current) {
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    /// Set metadata for a directory + namespace.
202    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    /// Replace a namespace at a directory wholesale. Unlike
218    /// [`Self::set_namespace`] (which merges per key), keys absent from
219    /// `data` are dropped — required for record-set writers whose payload
220    /// is one atomic document, not a key-value accumulation.
221    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    /// Look up file metadata at an exact normalized path. File entries do
234    /// not inherit from parent directories.
235    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    /// Write file metadata at an exact normalized path.
252    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    /// Save all metadata back to sharded JSON files.
268    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
384/// ISO 8601 timestamp (e.g. `2026-03-29T14:00:00Z`) without a chrono dependency.
385pub(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
598/// Normalize a relative path for use as a file metadata key.
599///
600/// - Converts backslashes to forward slashes.
601/// - Strips a leading `./` and a trailing `/` (file keys never end in `/`).
602/// - Returns `None` if the result is empty or refers to a directory (`.` or `..`).
603fn 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
626/// Read `opts.kind` from an optional dict argument. Returns `None` on a
627/// malformed dict or unknown kind; the empty/nil case yields `default`.
628fn 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
729/// Register metadata builtins on a VM.
730///
731/// The per-VM `MetadataState` lives in a thread-local cell so the
732/// `#[harn_builtin]`-emitted handler fns can access it without closure
733/// capture (which the macro doesn't support). The Harn VM executes
734/// single-threaded per run, so each `register_metadata_builtins` call
735/// replaces the cell for that thread.
736pub 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    // The verification profile store persists through this metadata
744    // state, so its builtins register (and become available) exactly
745    // when the metadata surface does.
746    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    /// Active metadata state for the current thread's pipeline run.
770    /// Set by `register_metadata_builtins`; read by the
771    /// `#[harn_builtin]` handler fns below. `None` before the first
772    /// install — in that case the handlers return a clear runtime error
773    /// rather than a panic.
774    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
1045/// Standalone `host_call("project.metadata_get", ...)` fallback.
1046///
1047/// Embedders such as Burin may provide their own bridge-backed project metadata
1048/// store. When no bridge handles the call, route the host-shaped operation to
1049/// Harn's built-in metadata store so standalone agent/debug/eval runs keep the
1050/// same cross-run learning substrate instead of failing closed.
1051pub(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
1073/// Standalone `host_call("project.metadata_inspect", ...)` fallback.
1074pub(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
1161/// Standalone `host_call("project.metadata_set", ...)` fallback.
1162pub(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
1183/// Standalone `host_call("project.metadata_save", ...)` fallback.
1184pub(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
1193/// Standalone `host_call("project.metadata_stale", ...)` fallback.
1194pub(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
1204/// Standalone `host_call("project.metadata_refresh_hashes", ...)` fallback.
1205pub(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/// invalidate_facts is a no-op: facts live in the metadata namespace.
1391#[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/// Reads metadata for an exact path. Files are addressed directly without
1397/// inheritance from parent directories. Pass `{kind: "dir"}` to fall back
1398/// to hierarchical directory resolution.
1399#[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/// Lists stored file (and optionally directory) entries. Useful for
1499/// iterating over precomputed enrichment artifacts.
1500#[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
1570/// Compute structure hash for a directory (file names + sizes).
1571fn 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
1586/// Compute content hash for a directory (file names + sizes + mtimes).
1587fn 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
1608/// FNV-1a hash (not crypto-grade, just for staleness detection).
1609fn 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
1744/// Scan-pattern matching. Patterns with wildcards use the shared glob
1745/// matcher in name mode (`*` crosses `/`, so the historical `*.rs` form keeps
1746/// matching nested entries during the recursive scan); wildcard-free patterns
1747/// keep their historical substring semantics.
1748fn 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        // Historical recursive-scan form: `*` crosses directory separators.
1763        assert!(glob_match("*.rs", "src/nested/main.rs"));
1764        // `**` forms match too (the previous hand-rolled matcher rejected
1765        // `**/*.rs` because it compared the second `*` literally).
1766        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        // Wildcard-free patterns keep substring semantics.
1770        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        // Dir-level fact set on a parent — should not leak into file lookup.
1915        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        // File lookup must NOT inherit "owner" from the parent dir entry.
1932        assert!(!file_fields.contains_key("owner"));
1933
1934        // Missing file path returns None.
1935        assert!(state.file_namespace("src/missing.rs", "facts").is_none());
1936        // Missing namespace on a known file returns None.
1937        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        // Dir-only namespace must not write a `files` field.
1975        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        // Reload from disk and verify file entries round-trip.
1985        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        // Pre-v2 shard with only `entries`, no `files` — must still load.
2001        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}