Skip to main content

harn_hostlib/code_index/
builtins.rs

1//! Host-builtin handlers for the `code_index` module.
2//!
3//! Each handler shape mirrors the schema in
4//! `schemas/code_index/<method>.{request,response}.json`. A single shared
5//! [`SharedIndex`] cell is captured by the closure of every handler so
6//! every builtin observes the same in-memory state. The `current_agent_id`
7//! op also reads from the capability's `current_agent` slot, but for
8//! every other op the index mutex is the source of truth.
9
10use harn_vm::VmValue;
11use std::collections::HashSet;
12use std::path::PathBuf;
13use std::sync::{Arc, Mutex};
14
15use super::agents::AgentId;
16use super::file_table::{fnv1a64, FileId};
17use super::imports;
18use super::state::{now_unix_ms, IndexState};
19use super::trigram;
20use super::versions::EditOp;
21use crate::error::HostlibError;
22use crate::tools::args::{
23    build_dict, dict_arg, optional_bool, optional_int_list, optional_string, optional_string_list,
24    require_string, str_value, to_agent_path, to_agent_path_str,
25};
26use crate::value_args;
27
28/// Shared, mutable cell carrying the (at most one) live workspace index.
29/// `Mutex` rather than `RwLock` because rebuilds flip the slot wholesale
30/// and every mutating op (record_edit, agent_register, lock_try, etc.)
31/// needs exclusive access. Single-threaded VM scripts pay no real cost
32/// from the choice; embedders that fan out across threads are still
33/// safe because the mutex serialises everyone.
34pub type SharedIndex = Arc<Mutex<Option<IndexState>>>;
35
36// === Builtin name constants ===
37//
38// Every handler routes through one of these. They double as the module's
39// public surface area so cross-repo schema-drift tests can discover them
40// without scraping source.
41
42pub(super) const BUILTIN_QUERY: &str = "hostlib_code_index_query";
43pub(super) const BUILTIN_REBUILD: &str = "hostlib_code_index_rebuild";
44pub(super) const BUILTIN_STATS: &str = "hostlib_code_index_stats";
45pub(super) const BUILTIN_IMPORTS_FOR: &str = "hostlib_code_index_imports_for";
46pub(super) const BUILTIN_IMPORTERS_OF: &str = "hostlib_code_index_importers_of";
47
48pub(super) const BUILTIN_PATH_TO_ID: &str = "hostlib_code_index_path_to_id";
49pub(super) const BUILTIN_ID_TO_PATH: &str = "hostlib_code_index_id_to_path";
50pub(super) const BUILTIN_FILE_IDS: &str = "hostlib_code_index_file_ids";
51pub(super) const BUILTIN_FILE_META: &str = "hostlib_code_index_file_meta";
52pub(super) const BUILTIN_FILE_HASH: &str = "hostlib_code_index_file_hash";
53pub(super) const BUILTIN_FILE_HASH_SNAPSHOT: &str = "hostlib_code_index_file_hash_snapshot";
54
55pub(super) const BUILTIN_READ_RANGE: &str = "hostlib_code_index_read_range";
56pub(super) const BUILTIN_REINDEX_FILE: &str = "hostlib_code_index_reindex_file";
57pub(super) const BUILTIN_TRIGRAM_QUERY: &str = "hostlib_code_index_trigram_query";
58pub(super) const BUILTIN_EXTRACT_TRIGRAMS: &str = "hostlib_code_index_extract_trigrams";
59pub(super) const BUILTIN_WORD_GET: &str = "hostlib_code_index_word_get";
60pub(super) const BUILTIN_DEPS_GET: &str = "hostlib_code_index_deps_get";
61pub(super) const BUILTIN_OUTLINE_GET: &str = "hostlib_code_index_outline_get";
62
63pub(super) const BUILTIN_CURRENT_SEQ: &str = "hostlib_code_index_current_seq";
64pub(super) const BUILTIN_CHANGES_SINCE: &str = "hostlib_code_index_changes_since";
65pub(super) const BUILTIN_VERSION_RECORD: &str = "hostlib_code_index_version_record";
66
67pub(super) const BUILTIN_AGENT_REGISTER: &str = "hostlib_code_index_agent_register";
68pub(super) const BUILTIN_AGENT_HEARTBEAT: &str = "hostlib_code_index_agent_heartbeat";
69pub(super) const BUILTIN_AGENT_UNREGISTER: &str = "hostlib_code_index_agent_unregister";
70pub(super) const BUILTIN_LOCK_TRY: &str = "hostlib_code_index_lock_try";
71pub(super) const BUILTIN_LOCK_RELEASE: &str = "hostlib_code_index_lock_release";
72pub(super) const BUILTIN_STATUS: &str = "hostlib_code_index_status";
73pub(super) const BUILTIN_CURRENT_AGENT_ID: &str = "hostlib_code_index_current_agent_id";
74
75pub(super) const BUILTIN_CYPHER: &str = "hostlib_code_index_cypher";
76pub(super) const BUILTIN_BRANCH_OVERLAY: &str = "hostlib_code_index_branch_overlay";
77pub(super) const BUILTIN_FRESHNESS: &str = "hostlib_code_index_freshness";
78
79// === Search / rebuild / stats ===
80
81/// Shared body for `query`. When `readonly` is supplied, hits from every
82/// read-only secondary root (issue #2403 follow-up) are merged in after the
83/// primary index so library/dependency symbols are discoverable without
84/// clobbering the project index. Primary hits keep `root: nil`; read-only
85/// hits carry the absolute path of their dependency root.
86pub(super) fn run_query_merged(
87    index: &SharedIndex,
88    readonly: Option<&super::readonly::ReadonlyRoots>,
89    args: &[VmValue],
90) -> Result<VmValue, HostlibError> {
91    let raw = dict_arg(BUILTIN_QUERY, args)?;
92    let dict = raw.as_ref();
93    let needle = require_string(BUILTIN_QUERY, dict, "needle")?;
94    if needle.is_empty() {
95        return Err(HostlibError::InvalidParameter {
96            builtin: BUILTIN_QUERY,
97            param: "needle",
98            message: "must not be empty".to_string(),
99        });
100    }
101    let case_sensitive = optional_bool(BUILTIN_QUERY, dict, "case_sensitive", false)?;
102    let max_results = optional_positive_usize(BUILTIN_QUERY, dict, "max_results")?.unwrap_or(100);
103    let scope = optional_string_list(BUILTIN_QUERY, dict, "scope")?;
104
105    let mut hits: Vec<Hit> = Vec::new();
106    {
107        let guard = index.lock().expect("code_index mutex poisoned");
108        if let Some(state) = guard.as_ref() {
109            collect_hits_scoped(state, &needle, case_sensitive, &scope, &mut hits);
110        }
111    }
112    if let Some(readonly) = readonly {
113        // Dependency roots ignore `scope` (it is a project-relative
114        // restriction) — they are an additive symbol-discovery surface.
115        if scope.is_empty() {
116            hits.extend(super::readonly::query_readonly_hits(
117                readonly,
118                &needle,
119                case_sensitive,
120            ));
121        }
122    }
123
124    hits.sort_by(|a, b| {
125        b.match_count
126            .cmp(&a.match_count)
127            .then_with(|| a.path.cmp(&b.path))
128    });
129    let truncated = hits.len() > max_results;
130    if truncated {
131        hits.truncate(max_results);
132    }
133    Ok(build_dict([
134        (
135            "results",
136            VmValue::List(Arc::new(hits.into_iter().map(hit_to_value).collect())),
137        ),
138        ("truncated", VmValue::Bool(truncated)),
139    ]))
140}
141
142/// Score `needle` against every file in `state`, honoring `scope`, and push
143/// matching files onto `hits`. Hits are tagged with the index's root only
144/// when it is a read-only secondary root (the caller passes the primary
145/// index with an empty `scope` to leave `root: nil`).
146fn collect_hits_scoped(
147    state: &IndexState,
148    needle: &str,
149    case_sensitive: bool,
150    scope: &[String],
151    hits: &mut Vec<Hit>,
152) {
153    let candidate_ids = candidates_for(state, needle);
154    for id in candidate_ids {
155        let Some(file) = state.files.get(&id) else {
156            continue;
157        };
158        if !scope_allows(scope, &file.relative_path) {
159            continue;
160        }
161        let Some(text) = read_file_text(&state.root, &file.relative_path) else {
162            continue;
163        };
164        let count = count_matches(&text, needle, case_sensitive);
165        if count == 0 {
166            continue;
167        }
168        hits.push(Hit {
169            path: file.relative_path.clone(),
170            match_count: count,
171            root: None,
172        });
173    }
174}
175
176/// Read-only entry point used by [`super::readonly::query_readonly_hits`]:
177/// score `needle` against `state` (a dependency root) with no scope filter
178/// and tag every hit with the root's absolute path.
179pub(super) fn collect_hits_into(
180    state: &IndexState,
181    needle: &str,
182    case_sensitive: bool,
183    hits: &mut Vec<Hit>,
184) {
185    let before = hits.len();
186    collect_hits_scoped(state, needle, case_sensitive, &[], hits);
187    let root = to_agent_path(&state.root);
188    for hit in &mut hits[before..] {
189        hit.root = Some(root.clone());
190    }
191}
192
193pub(super) fn run_stats(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
194    let guard = index.lock().expect("code_index mutex poisoned");
195    let Some(state) = guard.as_ref() else {
196        return Ok(empty_stats_response());
197    };
198    Ok(build_dict([
199        ("indexed_files", VmValue::Int(state.files.len() as i64)),
200        (
201            "trigrams",
202            VmValue::Int(state.trigrams.distinct_trigrams() as i64),
203        ),
204        ("words", VmValue::Int(state.words.distinct_words() as i64)),
205        ("memory_bytes", VmValue::Int(state.estimated_bytes() as i64)),
206        (
207            "last_rebuild_unix_ms",
208            VmValue::Int(state.last_built_unix_ms),
209        ),
210    ]))
211}
212
213pub(super) fn run_imports_for(
214    index: &SharedIndex,
215    args: &[VmValue],
216) -> Result<VmValue, HostlibError> {
217    let raw = dict_arg(BUILTIN_IMPORTS_FOR, args)?;
218    let dict = raw.as_ref();
219    let path = require_string(BUILTIN_IMPORTS_FOR, dict, "path")?;
220    let guard = index.lock().expect("code_index mutex poisoned");
221    let Some(state) = guard.as_ref() else {
222        return Ok(empty_imports_response(&path));
223    };
224    let Some(file_id) = state.lookup_path(&path) else {
225        return Ok(empty_imports_response(&path));
226    };
227    let Some(file) = state.files.get(&file_id) else {
228        return Ok(empty_imports_response(&path));
229    };
230    let kind = imports::import_kind(&file.language).to_string();
231    let base_dir = imports::parent_dir(&file.relative_path);
232    let resolved_ids: HashSet<FileId> = state.deps.imports_of(file_id).into_iter().collect();
233    let mut entries: Vec<VmValue> = Vec::with_capacity(file.imports.len());
234    for raw_import in &file.imports {
235        let resolved_path =
236            imports::resolve_module(raw_import, &file.language, &base_dir, &state.path_to_id)
237                .filter(|id| resolved_ids.contains(id))
238                .and_then(|id| state.files.get(&id).map(|f| f.relative_path.clone()));
239        entries.push(import_entry(raw_import, resolved_path.as_deref(), &kind));
240    }
241    Ok(build_dict([
242        ("path", str_value(&file.relative_path)),
243        ("imports", VmValue::List(Arc::new(entries))),
244    ]))
245}
246
247pub(super) fn run_importers_of(
248    index: &SharedIndex,
249    args: &[VmValue],
250) -> Result<VmValue, HostlibError> {
251    let raw = dict_arg(BUILTIN_IMPORTERS_OF, args)?;
252    let dict = raw.as_ref();
253    let module = require_string(BUILTIN_IMPORTERS_OF, dict, "module")?;
254    let guard = index.lock().expect("code_index mutex poisoned");
255    let Some(state) = guard.as_ref() else {
256        return Ok(empty_importers_response(&module));
257    };
258
259    let target_id = state.lookup_path(&module).or_else(|| {
260        // Fallback: suffix-match on relative paths so callers can request
261        // by basename (matching the `allowSuffixMatch` convention used by
262        // the resolver itself).
263        let needle = format!("/{module}");
264        state
265            .path_to_id
266            .iter()
267            .find(|(p, _)| p.ends_with(&needle) || *p == &module)
268            .map(|(_, id)| *id)
269    });
270
271    let mut importers: Vec<String> = match target_id {
272        Some(id) => state
273            .deps
274            .importers_of(id)
275            .into_iter()
276            .filter_map(|importer_id| {
277                state
278                    .files
279                    .get(&importer_id)
280                    .map(|f| f.relative_path.clone())
281            })
282            .collect(),
283        None => Vec::new(),
284    };
285    importers.sort();
286    Ok(build_dict([
287        ("module", str_value(&module)),
288        (
289            "importers",
290            VmValue::List(Arc::new(importers.into_iter().map(str_value).collect())),
291        ),
292    ]))
293}
294
295// === File table accessors ===
296
297pub(super) fn run_path_to_id(
298    index: &SharedIndex,
299    args: &[VmValue],
300) -> Result<VmValue, HostlibError> {
301    let raw = dict_arg(BUILTIN_PATH_TO_ID, args)?;
302    let path = require_string(BUILTIN_PATH_TO_ID, raw.as_ref(), "path")?;
303    let guard = index.lock().expect("code_index mutex poisoned");
304    let id = guard.as_ref().and_then(|s| s.lookup_path(&path));
305    Ok(match id {
306        Some(id) => VmValue::Int(id as i64),
307        None => VmValue::Nil,
308    })
309}
310
311pub(super) fn run_id_to_path(
312    index: &SharedIndex,
313    args: &[VmValue],
314) -> Result<VmValue, HostlibError> {
315    let raw = dict_arg(BUILTIN_ID_TO_PATH, args)?;
316    let id = require_positive_file_id(BUILTIN_ID_TO_PATH, raw.as_ref(), "file_id")?;
317    let guard = index.lock().expect("code_index mutex poisoned");
318    let path = guard
319        .as_ref()
320        .and_then(|s| s.files.get(&id))
321        .map(|f| f.relative_path.clone());
322    Ok(match path {
323        Some(p) => str_value(&p),
324        None => VmValue::Nil,
325    })
326}
327
328pub(super) fn run_file_ids(
329    index: &SharedIndex,
330    _args: &[VmValue],
331) -> Result<VmValue, HostlibError> {
332    let guard = index.lock().expect("code_index mutex poisoned");
333    let mut ids: Vec<FileId> = guard
334        .as_ref()
335        .map(|s| s.files.keys().copied().collect())
336        .unwrap_or_default();
337    ids.sort_unstable();
338    Ok(VmValue::List(Arc::new(
339        ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
340    )))
341}
342
343pub(super) fn run_file_meta(
344    index: &SharedIndex,
345    args: &[VmValue],
346) -> Result<VmValue, HostlibError> {
347    let raw = dict_arg(BUILTIN_FILE_META, args)?;
348    let dict = raw.as_ref();
349    let guard = index.lock().expect("code_index mutex poisoned");
350    let Some(state) = guard.as_ref() else {
351        return Ok(VmValue::Nil);
352    };
353    let id_opt: Option<FileId> = if dict.contains_key("file_id") {
354        Some(require_positive_file_id(
355            BUILTIN_FILE_META,
356            dict,
357            "file_id",
358        )?)
359    } else if let Some(VmValue::String(p)) = dict.get("path") {
360        state.lookup_path(p)
361    } else {
362        return Err(HostlibError::MissingParameter {
363            builtin: BUILTIN_FILE_META,
364            param: "file_id|path",
365        });
366    };
367    let Some(id) = id_opt else {
368        return Ok(VmValue::Nil);
369    };
370    let Some(file) = state.files.get(&id) else {
371        return Ok(VmValue::Nil);
372    };
373    let last_edit_seq = state
374        .versions
375        .last_entry(&file.relative_path)
376        .map(|e| e.seq)
377        .unwrap_or(0);
378    Ok(build_dict([
379        ("id", VmValue::Int(file.id as i64)),
380        ("path", str_value(&file.relative_path)),
381        ("language", str_value(&file.language)),
382        ("size", VmValue::Int(file.size_bytes as i64)),
383        ("line_count", VmValue::Int(file.line_count as i64)),
384        ("hash", str_value(file.content_hash.to_string())),
385        ("mtime_ms", VmValue::Int(file.mtime_ms)),
386        ("last_edit_seq", VmValue::Int(last_edit_seq as i64)),
387    ]))
388}
389
390pub(super) fn run_file_hash(
391    index: &SharedIndex,
392    args: &[VmValue],
393) -> Result<VmValue, HostlibError> {
394    let raw = dict_arg(BUILTIN_FILE_HASH, args)?;
395    let path = require_string(BUILTIN_FILE_HASH, raw.as_ref(), "path")?;
396    let guard = index.lock().expect("code_index mutex poisoned");
397    let Some(state) = guard.as_ref() else {
398        return Ok(VmValue::Nil);
399    };
400    let Some(abs) = state.absolute_path(&path) else {
401        return Ok(VmValue::Nil);
402    };
403    let bytes = match crate::fs::read(&abs, None) {
404        Some(result) => result,
405        None => std::fs::read(&abs),
406    };
407    match bytes {
408        Ok(bytes) => Ok(str_value(fnv1a64(&bytes).to_string())),
409        Err(_) => Ok(VmValue::Nil),
410    }
411}
412
413pub(super) fn run_file_hash_snapshot(
414    index: &SharedIndex,
415    args: &[VmValue],
416) -> Result<VmValue, HostlibError> {
417    let raw = dict_arg(BUILTIN_FILE_HASH_SNAPSHOT, args)?;
418    let dict = raw.as_ref();
419    if !dict.contains_key("paths") {
420        return Err(HostlibError::MissingParameter {
421            builtin: BUILTIN_FILE_HASH_SNAPSHOT,
422            param: "paths",
423        });
424    }
425    let paths = optional_string_list(BUILTIN_FILE_HASH_SNAPSHOT, dict, "paths")?;
426    if paths.is_empty() {
427        return Err(HostlibError::InvalidParameter {
428            builtin: BUILTIN_FILE_HASH_SNAPSHOT,
429            param: "paths",
430            message: "must contain at least one path".to_string(),
431        });
432    }
433    if paths.len() > 4096 {
434        return Err(HostlibError::InvalidParameter {
435            builtin: BUILTIN_FILE_HASH_SNAPSHOT,
436            param: "paths",
437            message: "must contain at most 4096 paths".to_string(),
438        });
439    }
440
441    let guard = index.lock().expect("code_index mutex poisoned");
442    let Some(state) = guard.as_ref() else {
443        return Ok(build_dict([
444            ("seq", VmValue::Int(0)),
445            ("captured_at_ms", VmValue::Int(now_unix_ms())),
446            ("algorithm", str_value("fnv1a64")),
447            ("snapshot", VmValue::dict(harn_vm::value::DictMap::new())),
448            (
449                "missing",
450                VmValue::List(Arc::new(paths.into_iter().map(str_value).collect())),
451            ),
452            ("files", VmValue::List(Arc::new(Vec::new()))),
453        ]));
454    };
455    let seq = state.versions.current_seq as i64;
456    let captured_at_ms = now_unix_ms();
457    let mut files = Vec::with_capacity(paths.len());
458    let mut snapshot = harn_vm::value::DictMap::new();
459    let mut missing = Vec::new();
460    for path in paths {
461        let entry = file_hash_snapshot_entry(state, &path);
462        if let Some(hash) = &entry.hash {
463            snapshot.insert(harn_vm::value::intern_key(&entry.path), str_value(hash));
464        } else {
465            missing.push(str_value(&entry.path));
466        }
467        files.push(entry.value);
468    }
469    Ok(build_dict([
470        ("seq", VmValue::Int(seq)),
471        ("captured_at_ms", VmValue::Int(captured_at_ms)),
472        ("algorithm", str_value("fnv1a64")),
473        ("snapshot", VmValue::dict(snapshot)),
474        ("missing", VmValue::List(Arc::new(missing))),
475        ("files", VmValue::List(Arc::new(files))),
476    ]))
477}
478
479// === Cached reads ===
480
481/// Shared body for `read_range`. When `readonly` is supplied, a path that
482/// is not inside the primary workspace root is resolved against the
483/// read-only secondary roots (issue #2403 follow-up) so a symbol
484/// discovered in a dependency root can be read back. Resolution stays
485/// confined to a known indexed root in every case — arbitrary host paths
486/// are still rejected.
487pub(super) fn run_read_range_merged(
488    index: &SharedIndex,
489    readonly: Option<&super::readonly::ReadonlyRoots>,
490    args: &[VmValue],
491) -> Result<VmValue, HostlibError> {
492    let raw = dict_arg(BUILTIN_READ_RANGE, args)?;
493    let dict = raw.as_ref();
494    let path = require_string(BUILTIN_READ_RANGE, dict, "path")?;
495    let start = optional_positive_i64(BUILTIN_READ_RANGE, dict, "start")?;
496    let end = optional_positive_i64(BUILTIN_READ_RANGE, dict, "end")?;
497    let abs =
498        match readonly {
499            Some(readonly) => super::readonly::resolve_read_path(index, readonly, &path)
500                .ok_or_else(|| HostlibError::InvalidParameter {
501                    builtin: BUILTIN_READ_RANGE,
502                    param: "path",
503                    message: "path must stay within the indexed workspace root or a read-only \
504                          dependency root"
505                        .to_string(),
506                })?,
507            None => {
508                let guard = index.lock().expect("code_index mutex poisoned");
509                match guard.as_ref() {
510                    Some(state) => state.absolute_path(&path).ok_or_else(|| {
511                        HostlibError::InvalidParameter {
512                            builtin: BUILTIN_READ_RANGE,
513                            param: "path",
514                            message: "path must stay within the indexed workspace root".to_string(),
515                        }
516                    })?,
517                    None => PathBuf::from(&path),
518                }
519            }
520        };
521
522    let content_result = match crate::fs::read_to_string(&abs, None) {
523        Some(result) => result,
524        None => std::fs::read_to_string(&abs),
525    };
526    let content = match content_result {
527        Ok(s) => s,
528        Err(_) => {
529            return Err(HostlibError::Backend {
530                builtin: BUILTIN_READ_RANGE,
531                message: format!("file not found: {path}"),
532            })
533        }
534    };
535
536    if start.is_none() && end.is_none() {
537        return Ok(build_dict([("content", str_value(&content))]));
538    }
539    // `split('\n')` yields a phantom trailing "" for newline-terminated content
540    // (and a lone "" for empty content), which would over-report `total` by one
541    // and let `start = total` return a phantom empty line. Drop it so totals and
542    // ranges match `crate::text::count_lines`, the canonical line-count semantics.
543    let lines: Vec<&str> = if content.is_empty() {
544        Vec::new()
545    } else {
546        let mut parts: Vec<&str> = content.split('\n').collect();
547        if content.ends_with('\n') {
548            parts.pop();
549        }
550        parts
551    };
552    let total = lines.len() as i64;
553    let lo = (start.unwrap_or(1) - 1).max(0) as usize;
554    let hi = end.unwrap_or(total).min(total).max(0) as usize;
555    if lo >= hi {
556        return Ok(build_dict([
557            ("content", str_value("")),
558            ("start", VmValue::Int((lo as i64) + 1)),
559            ("end", VmValue::Int(hi as i64)),
560        ]));
561    }
562    let slice = lines[lo..hi].join("\n");
563    Ok(build_dict([
564        ("content", str_value(&slice)),
565        ("start", VmValue::Int((lo as i64) + 1)),
566        ("end", VmValue::Int(hi as i64)),
567    ]))
568}
569
570pub(super) fn run_reindex_file(
571    index: &SharedIndex,
572    args: &[VmValue],
573) -> Result<VmValue, HostlibError> {
574    let raw = dict_arg(BUILTIN_REINDEX_FILE, args)?;
575    let path = require_string(BUILTIN_REINDEX_FILE, raw.as_ref(), "path")?;
576    let mut guard = index.lock().expect("code_index mutex poisoned");
577    let Some(state) = guard.as_mut() else {
578        return Ok(build_dict([
579            ("indexed", VmValue::Bool(false)),
580            ("file_id", VmValue::Nil),
581        ]));
582    };
583    let Some(abs) = state.absolute_path(&path) else {
584        return Err(HostlibError::InvalidParameter {
585            builtin: BUILTIN_REINDEX_FILE,
586            param: "path",
587            message: "path must stay within the indexed workspace root".to_string(),
588        });
589    };
590    let id = state.reindex_file(&abs);
591    Ok(build_dict([
592        ("indexed", VmValue::Bool(id.is_some())),
593        (
594            "file_id",
595            id.map(|i| VmValue::Int(i as i64)).unwrap_or(VmValue::Nil),
596        ),
597    ]))
598}
599
600pub(super) fn run_trigram_query(
601    index: &SharedIndex,
602    args: &[VmValue],
603) -> Result<VmValue, HostlibError> {
604    let raw = dict_arg(BUILTIN_TRIGRAM_QUERY, args)?;
605    let dict = raw.as_ref();
606    let trigrams_raw = optional_int_list(BUILTIN_TRIGRAM_QUERY, dict, "trigrams")?;
607    let max_files = optional_positive_usize(BUILTIN_TRIGRAM_QUERY, dict, "max_files")?;
608    let mut trigrams = Vec::with_capacity(trigrams_raw.len());
609    for n in trigrams_raw {
610        if n < 0 {
611            return Err(HostlibError::InvalidParameter {
612                builtin: BUILTIN_TRIGRAM_QUERY,
613                param: "trigrams",
614                message: "entries must be >= 0".to_string(),
615            });
616        }
617        trigrams.push(n as u32);
618    }
619    let guard = index.lock().expect("code_index mutex poisoned");
620    let mut ids: Vec<FileId> = match guard.as_ref() {
621        Some(state) => state.trigrams.query(&trigrams).into_iter().collect(),
622        None => Vec::new(),
623    };
624    ids.sort_unstable();
625    if let Some(limit) = max_files {
626        ids.truncate(limit);
627    }
628    Ok(VmValue::List(Arc::new(
629        ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
630    )))
631}
632
633pub(super) fn run_extract_trigrams(
634    _index: &SharedIndex,
635    args: &[VmValue],
636) -> Result<VmValue, HostlibError> {
637    let raw = dict_arg(BUILTIN_EXTRACT_TRIGRAMS, args)?;
638    let query = require_string(BUILTIN_EXTRACT_TRIGRAMS, raw.as_ref(), "query")?;
639    let mut tgs = trigram::query_trigrams(&query);
640    tgs.sort_unstable();
641    Ok(VmValue::List(Arc::new(
642        tgs.into_iter().map(|n| VmValue::Int(n as i64)).collect(),
643    )))
644}
645
646pub(super) fn run_word_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
647    let raw = dict_arg(BUILTIN_WORD_GET, args)?;
648    let word = require_string(BUILTIN_WORD_GET, raw.as_ref(), "word")?;
649    let guard = index.lock().expect("code_index mutex poisoned");
650    let hits: Vec<VmValue> = match guard.as_ref() {
651        Some(state) => state
652            .words
653            .get(&word)
654            .iter()
655            .map(|h| {
656                build_dict([
657                    ("file_id", VmValue::Int(h.file as i64)),
658                    ("line", VmValue::Int(h.line as i64)),
659                ])
660            })
661            .collect(),
662        None => Vec::new(),
663    };
664    Ok(VmValue::List(Arc::new(hits)))
665}
666
667pub(super) fn run_deps_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
668    let raw = dict_arg(BUILTIN_DEPS_GET, args)?;
669    let dict = raw.as_ref();
670    let id = require_positive_file_id(BUILTIN_DEPS_GET, dict, "file_id")?;
671    let direction = optional_string(BUILTIN_DEPS_GET, dict, "direction")?
672        .unwrap_or_else(|| "importers".to_string());
673    let guard = index.lock().expect("code_index mutex poisoned");
674    let mut neighbors: Vec<FileId> = match guard.as_ref() {
675        Some(state) => match direction.as_str() {
676            "importers" => state.deps.importers_of(id),
677            "imports" => state.deps.imports_of(id),
678            _ => {
679                return Err(HostlibError::InvalidParameter {
680                    builtin: BUILTIN_DEPS_GET,
681                    param: "direction",
682                    message: format!("expected \"importers\" or \"imports\", got {direction:?}"),
683                })
684            }
685        },
686        None => Vec::new(),
687    };
688    neighbors.sort_unstable();
689    Ok(VmValue::List(Arc::new(
690        neighbors
691            .into_iter()
692            .map(|id| VmValue::Int(id as i64))
693            .collect(),
694    )))
695}
696
697pub(super) fn run_outline_get(
698    index: &SharedIndex,
699    args: &[VmValue],
700) -> Result<VmValue, HostlibError> {
701    let raw = dict_arg(BUILTIN_OUTLINE_GET, args)?;
702    let id = require_positive_file_id(BUILTIN_OUTLINE_GET, raw.as_ref(), "file_id")?;
703    let guard = index.lock().expect("code_index mutex poisoned");
704    let symbols: Vec<VmValue> = match guard.as_ref().and_then(|s| s.files.get(&id)) {
705        Some(file) => file
706            .symbols
707            .iter()
708            .map(|sym| {
709                build_dict([
710                    ("name", str_value(&sym.name)),
711                    ("kind", str_value(&sym.kind)),
712                    (
713                        "access_level",
714                        sym.access_level
715                            .as_deref()
716                            .map(str_value)
717                            .unwrap_or(VmValue::Nil),
718                    ),
719                    ("start_line", VmValue::Int(sym.start_line as i64)),
720                    ("end_line", VmValue::Int(sym.end_line as i64)),
721                    ("signature", str_value(&sym.signature)),
722                ])
723            })
724            .collect(),
725        None => Vec::new(),
726    };
727    Ok(VmValue::List(Arc::new(symbols)))
728}
729
730// === Change log ===
731
732pub(super) fn run_current_seq(
733    index: &SharedIndex,
734    _args: &[VmValue],
735) -> Result<VmValue, HostlibError> {
736    let guard = index.lock().expect("code_index mutex poisoned");
737    let seq = guard.as_ref().map(|s| s.versions.current_seq).unwrap_or(0);
738    Ok(VmValue::Int(seq as i64))
739}
740
741pub(super) fn run_changes_since(
742    index: &SharedIndex,
743    args: &[VmValue],
744) -> Result<VmValue, HostlibError> {
745    let raw = dict_arg(BUILTIN_CHANGES_SINCE, args)?;
746    let dict = raw.as_ref();
747    let seq = optional_non_negative_u64(BUILTIN_CHANGES_SINCE, dict, "seq", 0)?;
748    let limit = optional_positive_usize(BUILTIN_CHANGES_SINCE, dict, "limit")?;
749    let guard = index.lock().expect("code_index mutex poisoned");
750    let records = match guard.as_ref() {
751        Some(state) => state.versions.changes_since(seq, limit),
752        None => Vec::new(),
753    };
754    Ok(VmValue::List(Arc::new(
755        records
756            .into_iter()
757            .map(|r| {
758                build_dict([
759                    ("path", str_value(&r.path)),
760                    ("seq", VmValue::Int(r.seq as i64)),
761                    ("agent_id", VmValue::Int(r.agent_id as i64)),
762                    ("op", str_value(r.op.as_str())),
763                    ("hash", str_value(r.hash.to_string())),
764                    ("size", VmValue::Int(r.size as i64)),
765                    ("timestamp_ms", VmValue::Int(r.timestamp_ms)),
766                ])
767            })
768            .collect(),
769    )))
770}
771
772pub(super) fn run_version_record(
773    index: &SharedIndex,
774    args: &[VmValue],
775) -> Result<VmValue, HostlibError> {
776    let raw = dict_arg(BUILTIN_VERSION_RECORD, args)?;
777    let dict = raw.as_ref();
778    let agent_id = require_non_negative_u64(BUILTIN_VERSION_RECORD, dict, "agent_id")?;
779    let path = require_string(BUILTIN_VERSION_RECORD, dict, "path")?;
780    let op_str =
781        optional_string(BUILTIN_VERSION_RECORD, dict, "op")?.unwrap_or_else(|| "write".to_string());
782    let op = EditOp::parse(&op_str).unwrap_or(EditOp::Write);
783    let hash = parse_hash(BUILTIN_VERSION_RECORD, dict, "hash")?;
784    let size = optional_non_negative_u64(BUILTIN_VERSION_RECORD, dict, "size", 0)?;
785    let now = now_unix_ms();
786    let mut guard = index.lock().expect("code_index mutex poisoned");
787    let state = ensure_state(BUILTIN_VERSION_RECORD, &mut guard)?;
788    let normalized = normalize_relative_path(state, &path);
789    let seq = state
790        .versions
791        .record(normalized, agent_id, op, hash, size, now);
792    state.agents.note_edit(agent_id, now);
793    Ok(VmValue::Int(seq as i64))
794}
795
796// === Agent registry + locks ===
797
798pub(super) fn run_agent_register(
799    index: &SharedIndex,
800    args: &[VmValue],
801) -> Result<VmValue, HostlibError> {
802    let raw = dict_arg(BUILTIN_AGENT_REGISTER, args)?;
803    let dict = raw.as_ref();
804    let name = optional_string(BUILTIN_AGENT_REGISTER, dict, "name")?
805        .unwrap_or_else(|| "agent".to_string());
806    let requested_id = optional_positive_u64(BUILTIN_AGENT_REGISTER, dict, "agent_id")?;
807    let now = now_unix_ms();
808    let mut guard = index.lock().expect("code_index mutex poisoned");
809    let state = ensure_state(BUILTIN_AGENT_REGISTER, &mut guard)?;
810    let id = match requested_id {
811        Some(id) => state.agents.register_with_id(id, name, now),
812        None => state.agents.register(name, now),
813    };
814    Ok(VmValue::Int(id as i64))
815}
816
817pub(super) fn run_agent_heartbeat(
818    index: &SharedIndex,
819    args: &[VmValue],
820) -> Result<VmValue, HostlibError> {
821    let raw = dict_arg(BUILTIN_AGENT_HEARTBEAT, args)?;
822    let id = require_positive_u64(BUILTIN_AGENT_HEARTBEAT, raw.as_ref(), "agent_id")?;
823    let now = now_unix_ms();
824    let mut guard = index.lock().expect("code_index mutex poisoned");
825    let state = ensure_state(BUILTIN_AGENT_HEARTBEAT, &mut guard)?;
826    state.agents.heartbeat(id, now);
827    Ok(VmValue::Bool(true))
828}
829
830pub(super) fn run_agent_unregister(
831    index: &SharedIndex,
832    args: &[VmValue],
833) -> Result<VmValue, HostlibError> {
834    let raw = dict_arg(BUILTIN_AGENT_UNREGISTER, args)?;
835    let id = require_positive_u64(BUILTIN_AGENT_UNREGISTER, raw.as_ref(), "agent_id")?;
836    let mut guard = index.lock().expect("code_index mutex poisoned");
837    let state = ensure_state(BUILTIN_AGENT_UNREGISTER, &mut guard)?;
838    state.agents.unregister(id);
839    Ok(VmValue::Bool(true))
840}
841
842pub(super) fn run_lock_try(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
843    let raw = dict_arg(BUILTIN_LOCK_TRY, args)?;
844    let dict = raw.as_ref();
845    let agent_id = require_positive_u64(BUILTIN_LOCK_TRY, dict, "agent_id")?;
846    let path = require_string(BUILTIN_LOCK_TRY, dict, "path")?;
847    let ttl = optional_positive_i64(BUILTIN_LOCK_TRY, dict, "ttl_ms")?;
848    let now = now_unix_ms();
849    let mut guard = index.lock().expect("code_index mutex poisoned");
850    let state = ensure_state(BUILTIN_LOCK_TRY, &mut guard)?;
851    let granted = state.agents.try_lock(agent_id, &path, ttl, now);
852    if granted {
853        return Ok(build_dict([
854            ("locked", VmValue::Bool(true)),
855            ("holder", VmValue::Int(agent_id as i64)),
856        ]));
857    }
858    let holder = state.agents.lock_holder(&path, now);
859    Ok(build_dict([
860        ("locked", VmValue::Bool(false)),
861        (
862            "holder",
863            holder
864                .map(|id| VmValue::Int(id as i64))
865                .unwrap_or(VmValue::Nil),
866        ),
867    ]))
868}
869
870pub(super) fn run_lock_release(
871    index: &SharedIndex,
872    args: &[VmValue],
873) -> Result<VmValue, HostlibError> {
874    let raw = dict_arg(BUILTIN_LOCK_RELEASE, args)?;
875    let dict = raw.as_ref();
876    let agent_id = require_positive_u64(BUILTIN_LOCK_RELEASE, dict, "agent_id")?;
877    let path = require_string(BUILTIN_LOCK_RELEASE, dict, "path")?;
878    let mut guard = index.lock().expect("code_index mutex poisoned");
879    let state = ensure_state(BUILTIN_LOCK_RELEASE, &mut guard)?;
880    state.agents.release_lock(agent_id, &path);
881    Ok(VmValue::Bool(true))
882}
883
884pub(super) fn run_status(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
885    let guard = index.lock().expect("code_index mutex poisoned");
886    match guard.as_ref() {
887        Some(state) => Ok(build_dict([
888            ("file_count", VmValue::Int(state.files.len() as i64)),
889            (
890                "current_seq",
891                VmValue::Int(state.versions.current_seq as i64),
892            ),
893            ("last_indexed_at_ms", VmValue::Int(state.last_built_unix_ms)),
894            (
895                "git_head",
896                state
897                    .git_head
898                    .as_deref()
899                    .map(str_value)
900                    .unwrap_or(VmValue::Nil),
901            ),
902            (
903                "agents",
904                VmValue::List(Arc::new(
905                    state
906                        .agents
907                        .agents()
908                        .map(|info| {
909                            build_dict([
910                                ("id", VmValue::Int(info.id as i64)),
911                                ("name", str_value(&info.name)),
912                                (
913                                    "state",
914                                    str_value(match info.state {
915                                        super::agents::AgentState::Active => "active",
916                                        super::agents::AgentState::Crashed => "crashed",
917                                        super::agents::AgentState::Gone => "gone",
918                                    }),
919                                ),
920                                ("last_seen_ms", VmValue::Int(info.last_seen_ms)),
921                                ("edit_count", VmValue::Int(info.edit_count as i64)),
922                                ("lock_count", VmValue::Int(info.locked_paths.len() as i64)),
923                            ])
924                        })
925                        .collect(),
926                )),
927            ),
928        ])),
929        None => Ok(build_dict([
930            ("file_count", VmValue::Int(0)),
931            ("current_seq", VmValue::Int(0)),
932            ("last_indexed_at_ms", VmValue::Int(0)),
933            ("git_head", VmValue::Nil),
934            ("agents", VmValue::List(Arc::new(Vec::new()))),
935        ])),
936    }
937}
938
939pub(super) fn run_current_agent_id(
940    slot: &Arc<Mutex<Option<AgentId>>>,
941    _args: &[VmValue],
942) -> Result<VmValue, HostlibError> {
943    let guard = slot.lock().expect("current_agent slot poisoned");
944    Ok(match *guard {
945        Some(id) => VmValue::Int(id as i64),
946        None => VmValue::Nil,
947    })
948}
949
950// === Symbol graph: cypher, branch_overlay, freshness (issue #2434) ===
951
952pub(super) fn run_cypher(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
953    let raw = dict_arg(BUILTIN_CYPHER, args)?;
954    let dict = raw.as_ref();
955    let query = require_string(BUILTIN_CYPHER, dict, "query")?;
956
957    let guard = index.lock().expect("code_index mutex poisoned");
958    let Some(state) = guard.as_ref() else {
959        return Ok(build_dict([
960            ("rows", VmValue::List(Arc::new(Vec::new()))),
961            ("overlay", VmValue::Nil),
962        ]));
963    };
964
965    let graph = state.overlays.graph(&state.symbols);
966    let rows = super::cypher::execute(&query, graph).map_err(|err| HostlibError::Backend {
967        builtin: BUILTIN_CYPHER,
968        message: err.to_string(),
969    })?;
970
971    let rows_vm: Vec<VmValue> = rows
972        .into_iter()
973        .map(|row| {
974            let mut map: harn_vm::value::DictMap = harn_vm::value::DictMap::new();
975            for (k, v) in row {
976                map.insert(harn_vm::value::intern_key(&k), v.to_vm());
977            }
978            VmValue::dict(map)
979        })
980        .collect();
981
982    Ok(build_dict([
983        ("rows", VmValue::List(Arc::new(rows_vm))),
984        (
985            "overlay",
986            match state.overlays.active() {
987                Some(name) => str_value(name),
988                None => VmValue::Nil,
989            },
990        ),
991    ]))
992}
993
994pub(super) fn run_branch_overlay(
995    index: &SharedIndex,
996    args: &[VmValue],
997) -> Result<VmValue, HostlibError> {
998    let raw = dict_arg(BUILTIN_BRANCH_OVERLAY, args)?;
999    let dict = raw.as_ref();
1000    let branch = optional_string(BUILTIN_BRANCH_OVERLAY, dict, "branch")?;
1001    let activate = optional_bool(BUILTIN_BRANCH_OVERLAY, dict, "activate", true)?;
1002    let action = optional_string(BUILTIN_BRANCH_OVERLAY, dict, "action")?;
1003
1004    let mut guard = index.lock().expect("code_index mutex poisoned");
1005    let state = ensure_state(BUILTIN_BRANCH_OVERLAY, &mut guard)?;
1006
1007    let mut reuse: f64 = 1.0;
1008    match action.as_deref().unwrap_or("activate") {
1009        "deactivate" => {
1010            state.overlays.activate(None);
1011        }
1012        "create" => {
1013            let branch_name = branch.ok_or(HostlibError::MissingParameter {
1014                builtin: BUILTIN_BRANCH_OVERLAY,
1015                param: "branch",
1016            })?;
1017            let mut overlay = super::overlay::BranchOverlay::new(&branch_name);
1018            overlay.materialize(&state.symbols);
1019            state.overlays.set(overlay);
1020            if activate {
1021                state.overlays.activate(Some(branch_name));
1022            }
1023            reuse = state.overlays.reuse_fraction(&state.symbols);
1024        }
1025        "activate" => {
1026            let branch_name = branch.ok_or(HostlibError::MissingParameter {
1027                builtin: BUILTIN_BRANCH_OVERLAY,
1028                param: "branch",
1029            })?;
1030            // If the overlay doesn't exist, create an empty pass-through
1031            // one — the base graph then serves it untouched, giving the
1032            // 100% reuse / 0-change baseline.
1033            if state.overlays.get(&branch_name).is_none() {
1034                let mut overlay = super::overlay::BranchOverlay::new(&branch_name);
1035                overlay.materialize(&state.symbols);
1036                state.overlays.set(overlay);
1037            }
1038            state.overlays.activate(Some(branch_name));
1039            reuse = state.overlays.reuse_fraction(&state.symbols);
1040        }
1041        other => {
1042            return Err(HostlibError::InvalidParameter {
1043                builtin: BUILTIN_BRANCH_OVERLAY,
1044                param: "action",
1045                message: format!("expected one of activate|deactivate|create, got `{other}`"),
1046            })
1047        }
1048    }
1049
1050    Ok(build_dict([
1051        (
1052            "active",
1053            match state.overlays.active() {
1054                Some(name) => str_value(name),
1055                None => VmValue::Nil,
1056            },
1057        ),
1058        ("reuse_fraction", VmValue::Float(reuse)),
1059    ]))
1060}
1061
1062pub(super) fn run_freshness(
1063    index: &SharedIndex,
1064    args: &[VmValue],
1065) -> Result<VmValue, HostlibError> {
1066    let raw = dict_arg(BUILTIN_FRESHNESS, args)?;
1067    let dict = raw.as_ref();
1068    let path = require_string(BUILTIN_FRESHNESS, dict, "path")?;
1069
1070    let guard = index.lock().expect("code_index mutex poisoned");
1071    let state = guard.as_ref().ok_or_else(|| HostlibError::Backend {
1072        builtin: BUILTIN_FRESHNESS,
1073        message: "code index has not been initialised — call \
1074            `hostlib_code_index_rebuild` first"
1075            .to_string(),
1076    })?;
1077
1078    let normalized = normalize_relative_path(state, &path);
1079    let file = state
1080        .lookup_path(&normalized)
1081        .and_then(|id| state.files.get(&id));
1082    let Some(file) = file else {
1083        return Ok(unknown_freshness_response(&path));
1084    };
1085
1086    let abs = state.root.join(&file.relative_path);
1087    let (disk_mtime, disk_hash) = match std::fs::read(&abs) {
1088        Ok(bytes) => {
1089            let hash = fnv1a64(&bytes);
1090            let mtime = std::fs::metadata(&abs)
1091                .ok()
1092                .and_then(|m| m.modified().ok())
1093                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1094                .map(|d| d.as_millis() as i64)
1095                .unwrap_or(0);
1096            (mtime, Some(hash))
1097        }
1098        Err(_) => (0, None),
1099    };
1100    let stale = disk_hash != Some(file.content_hash);
1101    Ok(build_dict([
1102        ("path", str_value(&file.relative_path)),
1103        ("known", VmValue::Bool(true)),
1104        ("stale", VmValue::Bool(stale)),
1105        (
1106            "indexed_hash",
1107            VmValue::String(arcstr::ArcStr::from(
1108                format!("{:016x}", file.content_hash).as_str(),
1109            )),
1110        ),
1111        ("indexed_mtime_ms", VmValue::Int(file.mtime_ms)),
1112        (
1113            "disk_hash",
1114            match disk_hash {
1115                Some(h) => VmValue::String(arcstr::ArcStr::from(format!("{h:016x}").as_str())),
1116                None => VmValue::Nil,
1117            },
1118        ),
1119        ("disk_mtime_ms", VmValue::Int(disk_mtime)),
1120    ]))
1121}
1122
1123// === Helpers ===
1124
1125struct FileHashSnapshotEntry {
1126    value: VmValue,
1127    path: String,
1128    hash: Option<String>,
1129}
1130
1131fn file_hash_snapshot_entry(state: &IndexState, path: &str) -> FileHashSnapshotEntry {
1132    let normalized = normalize_relative_path(state, path);
1133    let indexed_file = state
1134        .lookup_path(&normalized)
1135        .and_then(|id| state.files.get(&id));
1136    let abs = state
1137        .absolute_path(path)
1138        .or_else(|| state.absolute_path(&normalized));
1139    let (readable, hash, hash_source, disk_size, disk_mtime_ms) = match abs {
1140        Some(abs) => {
1141            let metadata = std::fs::metadata(&abs).ok();
1142            let mtime_ms = metadata
1143                .as_ref()
1144                .and_then(|m| m.modified().ok())
1145                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1146                .map(|d| d.as_millis() as i64);
1147            if let (Some(file), Some(metadata), Some(mtime_ms)) =
1148                (indexed_file, metadata.as_ref(), mtime_ms)
1149            {
1150                if metadata.len() == file.size_bytes && mtime_ms == file.mtime_ms {
1151                    return file_hash_snapshot_value(
1152                        state,
1153                        normalized,
1154                        indexed_file,
1155                        true,
1156                        Some(file.content_hash.to_string()),
1157                        "indexed",
1158                        VmValue::Int(file.size_bytes as i64),
1159                        VmValue::Int(file.mtime_ms),
1160                    );
1161                }
1162            }
1163            let bytes = match crate::fs::read(&abs, None) {
1164                Some(result) => result,
1165                None => std::fs::read(&abs),
1166            };
1167            match bytes {
1168                Ok(bytes) => {
1169                    let hash = fnv1a64(&bytes).to_string();
1170                    (
1171                        true,
1172                        Some(hash),
1173                        "disk",
1174                        VmValue::Int(bytes.len() as i64),
1175                        mtime_ms.map(VmValue::Int).unwrap_or(VmValue::Nil),
1176                    )
1177                }
1178                Err(_) => (false, None, "missing", VmValue::Nil, VmValue::Nil),
1179            }
1180        }
1181        None => (false, None, "missing", VmValue::Nil, VmValue::Nil),
1182    };
1183    file_hash_snapshot_value(
1184        state,
1185        normalized,
1186        indexed_file,
1187        readable,
1188        hash,
1189        hash_source,
1190        disk_size,
1191        disk_mtime_ms,
1192    )
1193}
1194
1195fn file_hash_snapshot_value(
1196    state: &IndexState,
1197    normalized: String,
1198    indexed_file: Option<&super::file_table::IndexedFile>,
1199    readable: bool,
1200    hash: Option<String>,
1201    hash_source: &str,
1202    disk_size: VmValue,
1203    disk_mtime_ms: VmValue,
1204) -> FileHashSnapshotEntry {
1205    let indexed_hash = indexed_file
1206        .map(|file| str_value(file.content_hash.to_string()))
1207        .unwrap_or(VmValue::Nil);
1208    let indexed_mtime_ms = indexed_file
1209        .map(|file| VmValue::Int(file.mtime_ms))
1210        .unwrap_or(VmValue::Nil);
1211    let last_edit_seq = state
1212        .versions
1213        .last_entry(&normalized)
1214        .map(|entry| entry.seq as i64)
1215        .unwrap_or(0);
1216    let hash_value = hash.as_ref().map(str_value).unwrap_or(VmValue::Nil);
1217    let value = build_dict([
1218        ("path", str_value(&normalized)),
1219        ("known", VmValue::Bool(indexed_file.is_some())),
1220        ("readable", VmValue::Bool(readable)),
1221        ("hash", hash_value),
1222        ("hash_source", str_value(hash_source)),
1223        ("size", disk_size),
1224        ("mtime_ms", disk_mtime_ms),
1225        ("indexed_hash", indexed_hash),
1226        ("indexed_mtime_ms", indexed_mtime_ms),
1227        ("last_edit_seq", VmValue::Int(last_edit_seq)),
1228    ]);
1229    FileHashSnapshotEntry {
1230        value,
1231        path: normalized,
1232        hash,
1233    }
1234}
1235
1236fn ensure_state<'a>(
1237    builtin: &'static str,
1238    guard: &'a mut std::sync::MutexGuard<'_, Option<IndexState>>,
1239) -> Result<&'a mut IndexState, HostlibError> {
1240    if guard.is_none() {
1241        return Err(HostlibError::Backend {
1242            builtin,
1243            message: "code index has not been initialised — call \
1244                 `hostlib_code_index_rebuild` or restore from a snapshot first"
1245                .to_string(),
1246        });
1247    }
1248    Ok(guard.as_mut().unwrap())
1249}
1250
1251fn parse_hash(
1252    builtin: &'static str,
1253    dict: &harn_vm::value::DictMap,
1254    key: &'static str,
1255) -> Result<u64, HostlibError> {
1256    match dict.get(key) {
1257        None | Some(VmValue::Nil) => Ok(0),
1258        Some(VmValue::Int(n)) if *n >= 0 => Ok(*n as u64),
1259        Some(VmValue::Int(n)) => Err(HostlibError::InvalidParameter {
1260            builtin,
1261            param: key,
1262            message: format!("must be >= 0, got {n}"),
1263        }),
1264        Some(VmValue::String(s)) => s
1265            .parse::<u64>()
1266            .map_err(|_| HostlibError::InvalidParameter {
1267                builtin,
1268                param: key,
1269                message: format!("expected u64-parseable string, got {s:?}"),
1270            }),
1271        Some(other) => Err(HostlibError::InvalidParameter {
1272            builtin,
1273            param: key,
1274            message: format!(
1275                "expected integer or numeric string, got {}",
1276                other.type_name()
1277            ),
1278        }),
1279    }
1280}
1281
1282fn require_positive_u64(
1283    builtin: &'static str,
1284    dict: &harn_vm::value::DictMap,
1285    key: &'static str,
1286) -> Result<u64, HostlibError> {
1287    let raw = require_non_negative_u64(builtin, dict, key)?;
1288    if raw == 0 {
1289        return Err(HostlibError::InvalidParameter {
1290            builtin,
1291            param: key,
1292            message: "must be >= 1".to_string(),
1293        });
1294    }
1295    Ok(raw)
1296}
1297
1298fn require_positive_file_id(
1299    builtin: &'static str,
1300    dict: &harn_vm::value::DictMap,
1301    key: &'static str,
1302) -> Result<FileId, HostlibError> {
1303    let raw = require_positive_u64(builtin, dict, key)?;
1304    FileId::try_from(raw).map_err(|_| HostlibError::InvalidParameter {
1305        builtin,
1306        param: key,
1307        message: "does not fit in file id".to_string(),
1308    })
1309}
1310
1311fn require_non_negative_u64(
1312    builtin: &'static str,
1313    dict: &harn_vm::value::DictMap,
1314    key: &'static str,
1315) -> Result<u64, HostlibError> {
1316    match value_args::optional_i64_no_default(builtin, dict, key)? {
1317        Some(value) if value >= 0 => Ok(value as u64),
1318        Some(value) => Err(HostlibError::InvalidParameter {
1319            builtin,
1320            param: key,
1321            message: format!("must be >= 0, got {value}"),
1322        }),
1323        None => Err(HostlibError::MissingParameter {
1324            builtin,
1325            param: key,
1326        }),
1327    }
1328}
1329
1330fn optional_positive_u64(
1331    builtin: &'static str,
1332    dict: &harn_vm::value::DictMap,
1333    key: &'static str,
1334) -> Result<Option<u64>, HostlibError> {
1335    match dict.get(key) {
1336        None | Some(VmValue::Nil) => Ok(None),
1337        Some(_) => require_positive_u64(builtin, dict, key).map(Some),
1338    }
1339}
1340
1341fn optional_non_negative_u64(
1342    builtin: &'static str,
1343    dict: &harn_vm::value::DictMap,
1344    key: &'static str,
1345    default: u64,
1346) -> Result<u64, HostlibError> {
1347    match dict.get(key) {
1348        None | Some(VmValue::Nil) => Ok(default),
1349        Some(_) => require_non_negative_u64(builtin, dict, key),
1350    }
1351}
1352
1353fn optional_positive_i64(
1354    builtin: &'static str,
1355    dict: &harn_vm::value::DictMap,
1356    key: &'static str,
1357) -> Result<Option<i64>, HostlibError> {
1358    match value_args::optional_i64_no_default(builtin, dict, key)? {
1359        None => Ok(None),
1360        Some(value) if value >= 1 => Ok(Some(value)),
1361        Some(value) => Err(HostlibError::InvalidParameter {
1362            builtin,
1363            param: key,
1364            message: format!("must be >= 1, got {value}"),
1365        }),
1366    }
1367}
1368
1369fn optional_positive_usize(
1370    builtin: &'static str,
1371    dict: &harn_vm::value::DictMap,
1372    key: &'static str,
1373) -> Result<Option<usize>, HostlibError> {
1374    match optional_positive_u64(builtin, dict, key)? {
1375        Some(value) => {
1376            usize::try_from(value)
1377                .map(Some)
1378                .map_err(|_| HostlibError::InvalidParameter {
1379                    builtin,
1380                    param: key,
1381                    message: "does not fit in usize".to_string(),
1382                })
1383        }
1384        None => Ok(None),
1385    }
1386}
1387
1388/// Re-export of [`normalize_relative_path`] for sibling modules
1389/// (e.g. [`super::rename`]). Inputs may be a workspace-relative path,
1390/// an absolute path inside the workspace, or an unknown path; the
1391/// returned string is always workspace-relative when resolvable and
1392/// falls back to the raw input otherwise.
1393pub(super) fn normalize_relative_path_for(state: &IndexState, path: &str) -> String {
1394    normalize_relative_path(state, path)
1395}
1396
1397fn normalize_relative_path(state: &IndexState, path: &str) -> String {
1398    if let Some(rel) = state
1399        .lookup_path(path)
1400        .and_then(|id| state.files.get(&id))
1401        .map(|f| f.relative_path.clone())
1402    {
1403        return rel;
1404    }
1405    let p = std::path::Path::new(path);
1406    if p.is_absolute() {
1407        if let Ok(rel) = p.strip_prefix(&state.root) {
1408            return to_agent_path(rel);
1409        }
1410    }
1411    to_agent_path_str(path)
1412}
1413
1414fn candidates_for(state: &IndexState, needle: &str) -> Vec<FileId> {
1415    if needle.len() >= 3 {
1416        let trigrams = trigram::query_trigrams(needle);
1417        return state.trigrams.query(&trigrams).into_iter().collect();
1418    }
1419    state.files.keys().copied().collect()
1420}
1421
1422fn read_file_text(root: &std::path::Path, relative: &str) -> Option<String> {
1423    let path = root.join(relative);
1424    match crate::fs::read_to_string(&path, None) {
1425        Some(result) => result.ok(),
1426        None => std::fs::read_to_string(path).ok(),
1427    }
1428}
1429
1430fn count_matches(haystack: &str, needle: &str, case_sensitive: bool) -> u64 {
1431    if case_sensitive {
1432        haystack.matches(needle).count() as u64
1433    } else {
1434        let lower_h = haystack.to_lowercase();
1435        let lower_n = needle.to_lowercase();
1436        lower_h.matches(&lower_n).count() as u64
1437    }
1438}
1439
1440fn scope_allows(scope: &[String], relative: &str) -> bool {
1441    if scope.is_empty() {
1442        return true;
1443    }
1444    scope
1445        .iter()
1446        .any(|s| relative == s || relative.starts_with(&format!("{s}/")) || s.is_empty())
1447}
1448
1449pub(super) struct Hit {
1450    pub(super) path: String,
1451    pub(super) match_count: u64,
1452    /// Absolute path of the read-only dependency root this hit came from,
1453    /// or `None` for a primary-workspace hit (issue #2403 follow-up).
1454    pub(super) root: Option<String>,
1455}
1456
1457fn hit_to_value(hit: Hit) -> VmValue {
1458    let Hit {
1459        path,
1460        match_count,
1461        root,
1462    } = hit;
1463    build_dict([
1464        ("path", str_value(&path)),
1465        ("score", VmValue::Float(match_count as f64)),
1466        ("match_count", VmValue::Int(match_count as i64)),
1467        (
1468            "root",
1469            match root {
1470                Some(r) => str_value(&r),
1471                None => VmValue::Nil,
1472            },
1473        ),
1474    ])
1475}
1476
1477fn import_entry(module: &str, resolved: Option<&str>, kind: &str) -> VmValue {
1478    let mut map: harn_vm::value::DictMap = harn_vm::value::DictMap::new();
1479    map.insert(harn_vm::value::intern_key("module"), str_value(module));
1480    map.insert(
1481        harn_vm::value::intern_key("resolved_path"),
1482        match resolved {
1483            Some(p) => str_value(p),
1484            None => VmValue::Nil,
1485        },
1486    );
1487    map.insert(harn_vm::value::intern_key("kind"), str_value(kind));
1488    VmValue::dict(map)
1489}
1490
1491fn empty_stats_response() -> VmValue {
1492    build_dict([
1493        ("indexed_files", VmValue::Int(0)),
1494        ("trigrams", VmValue::Int(0)),
1495        ("words", VmValue::Int(0)),
1496        ("memory_bytes", VmValue::Int(0)),
1497        ("last_rebuild_unix_ms", VmValue::Nil),
1498    ])
1499}
1500
1501fn empty_imports_response(path: &str) -> VmValue {
1502    build_dict([
1503        ("path", str_value(path)),
1504        ("imports", VmValue::List(Arc::new(Vec::new()))),
1505    ])
1506}
1507
1508fn empty_importers_response(module: &str) -> VmValue {
1509    build_dict([
1510        ("module", str_value(module)),
1511        ("importers", VmValue::List(Arc::new(Vec::new()))),
1512    ])
1513}
1514
1515fn unknown_freshness_response(path: &str) -> VmValue {
1516    build_dict([
1517        ("path", str_value(path)),
1518        ("known", VmValue::Bool(false)),
1519        ("stale", VmValue::Bool(true)),
1520        ("indexed_hash", VmValue::Nil),
1521        ("indexed_mtime_ms", VmValue::Nil),
1522        ("disk_hash", VmValue::Nil),
1523        ("disk_mtime_ms", VmValue::Nil),
1524    ])
1525}