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