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::{BTreeMap, HashSet};
11use std::path::PathBuf;
12use std::rc::Rc;
13use std::sync::{Arc, Mutex};
14use std::time::Instant;
15
16use harn_vm::VmValue;
17
18use super::agents::AgentId;
19use super::file_table::{fnv1a64, FileId};
20use super::imports;
21use super::state::{now_unix_ms, IndexState};
22use super::trigram;
23use super::versions::EditOp;
24use crate::error::HostlibError;
25use crate::tools::args::{
26    build_dict, dict_arg, optional_bool, optional_int_list, optional_string, optional_string_list,
27    require_string, str_value,
28};
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";
55
56pub(super) const BUILTIN_READ_RANGE: &str = "hostlib_code_index_read_range";
57pub(super) const BUILTIN_REINDEX_FILE: &str = "hostlib_code_index_reindex_file";
58pub(super) const BUILTIN_TRIGRAM_QUERY: &str = "hostlib_code_index_trigram_query";
59pub(super) const BUILTIN_EXTRACT_TRIGRAMS: &str = "hostlib_code_index_extract_trigrams";
60pub(super) const BUILTIN_WORD_GET: &str = "hostlib_code_index_word_get";
61pub(super) const BUILTIN_DEPS_GET: &str = "hostlib_code_index_deps_get";
62pub(super) const BUILTIN_OUTLINE_GET: &str = "hostlib_code_index_outline_get";
63
64pub(super) const BUILTIN_CURRENT_SEQ: &str = "hostlib_code_index_current_seq";
65pub(super) const BUILTIN_CHANGES_SINCE: &str = "hostlib_code_index_changes_since";
66pub(super) const BUILTIN_VERSION_RECORD: &str = "hostlib_code_index_version_record";
67
68pub(super) const BUILTIN_AGENT_REGISTER: &str = "hostlib_code_index_agent_register";
69pub(super) const BUILTIN_AGENT_HEARTBEAT: &str = "hostlib_code_index_agent_heartbeat";
70pub(super) const BUILTIN_AGENT_UNREGISTER: &str = "hostlib_code_index_agent_unregister";
71pub(super) const BUILTIN_LOCK_TRY: &str = "hostlib_code_index_lock_try";
72pub(super) const BUILTIN_LOCK_RELEASE: &str = "hostlib_code_index_lock_release";
73pub(super) const BUILTIN_STATUS: &str = "hostlib_code_index_status";
74pub(super) const BUILTIN_CURRENT_AGENT_ID: &str = "hostlib_code_index_current_agent_id";
75
76pub(super) const BUILTIN_CYPHER: &str = "hostlib_code_index_cypher";
77pub(super) const BUILTIN_BRANCH_OVERLAY: &str = "hostlib_code_index_branch_overlay";
78pub(super) const BUILTIN_FRESHNESS: &str = "hostlib_code_index_freshness";
79
80// === Search / rebuild / stats ===
81
82pub(super) fn run_query(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
83    let raw = dict_arg(BUILTIN_QUERY, args)?;
84    let dict = raw.as_ref();
85    let needle = require_string(BUILTIN_QUERY, dict, "needle")?;
86    if needle.is_empty() {
87        return Err(HostlibError::InvalidParameter {
88            builtin: BUILTIN_QUERY,
89            param: "needle",
90            message: "must not be empty".to_string(),
91        });
92    }
93    let case_sensitive = optional_bool(BUILTIN_QUERY, dict, "case_sensitive", false)?;
94    let max_results = optional_positive_usize(BUILTIN_QUERY, dict, "max_results")?.unwrap_or(100);
95    let scope = optional_string_list(BUILTIN_QUERY, dict, "scope")?;
96
97    let guard = index.lock().expect("code_index mutex poisoned");
98    let Some(state) = guard.as_ref() else {
99        return Ok(empty_query_response());
100    };
101
102    let candidate_ids = candidates_for(state, &needle);
103    let mut hits: Vec<Hit> = Vec::new();
104    for id in candidate_ids {
105        let Some(file) = state.files.get(&id) else {
106            continue;
107        };
108        if !scope_allows(&scope, &file.relative_path) {
109            continue;
110        }
111        let Some(text) = read_file_text(&state.root, &file.relative_path) else {
112            continue;
113        };
114        let count = count_matches(&text, &needle, case_sensitive);
115        if count == 0 {
116            continue;
117        }
118        hits.push(Hit {
119            path: file.relative_path.clone(),
120            score: count as f64,
121            match_count: count,
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(Rc::new(hits.into_iter().map(hit_to_value).collect())),
137        ),
138        ("truncated", VmValue::Bool(truncated)),
139    ]))
140}
141
142pub(super) fn run_rebuild(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
143    let raw = dict_arg(BUILTIN_REBUILD, args)?;
144    let dict = raw.as_ref();
145    let _force = optional_bool(BUILTIN_REBUILD, dict, "force", false)?;
146    let root = optional_string(BUILTIN_REBUILD, dict, "root")?
147        .map(PathBuf::from)
148        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
149    if !root.exists() {
150        return Err(HostlibError::InvalidParameter {
151            builtin: BUILTIN_REBUILD,
152            param: "root",
153            message: format!("path `{}` does not exist", root.display()),
154        });
155    }
156    if !root.is_dir() {
157        return Err(HostlibError::InvalidParameter {
158            builtin: BUILTIN_REBUILD,
159            param: "root",
160            message: format!("path `{}` is not a directory", root.display()),
161        });
162    }
163    let started = Instant::now();
164    let (state, outcome) = IndexState::build_from_root(&root);
165    let elapsed_ms = started.elapsed().as_millis() as i64;
166    {
167        let mut guard = index.lock().expect("code_index mutex poisoned");
168        *guard = Some(state);
169    }
170    Ok(build_dict([
171        ("files_indexed", VmValue::Int(outcome.files_indexed as i64)),
172        ("files_skipped", VmValue::Int(outcome.files_skipped as i64)),
173        ("elapsed_ms", VmValue::Int(elapsed_ms)),
174    ]))
175}
176
177pub(super) fn run_stats(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
178    let guard = index.lock().expect("code_index mutex poisoned");
179    let Some(state) = guard.as_ref() else {
180        return Ok(empty_stats_response());
181    };
182    Ok(build_dict([
183        ("indexed_files", VmValue::Int(state.files.len() as i64)),
184        (
185            "trigrams",
186            VmValue::Int(state.trigrams.distinct_trigrams() as i64),
187        ),
188        ("words", VmValue::Int(state.words.distinct_words() as i64)),
189        ("memory_bytes", VmValue::Int(state.estimated_bytes() as i64)),
190        (
191            "last_rebuild_unix_ms",
192            VmValue::Int(state.last_built_unix_ms),
193        ),
194    ]))
195}
196
197pub(super) fn run_imports_for(
198    index: &SharedIndex,
199    args: &[VmValue],
200) -> Result<VmValue, HostlibError> {
201    let raw = dict_arg(BUILTIN_IMPORTS_FOR, args)?;
202    let dict = raw.as_ref();
203    let path = require_string(BUILTIN_IMPORTS_FOR, dict, "path")?;
204    let guard = index.lock().expect("code_index mutex poisoned");
205    let Some(state) = guard.as_ref() else {
206        return Ok(empty_imports_response(&path));
207    };
208    let Some(file_id) = state.lookup_path(&path) else {
209        return Ok(empty_imports_response(&path));
210    };
211    let Some(file) = state.files.get(&file_id) else {
212        return Ok(empty_imports_response(&path));
213    };
214    let kind = imports::import_kind(&file.language).to_string();
215    let base_dir = imports::parent_dir(&file.relative_path);
216    let resolved_ids: HashSet<FileId> = state.deps.imports_of(file_id).into_iter().collect();
217    let mut entries: Vec<VmValue> = Vec::with_capacity(file.imports.len());
218    for raw_import in &file.imports {
219        let resolved_path =
220            imports::resolve_module(raw_import, &file.language, &base_dir, &state.path_to_id)
221                .filter(|id| resolved_ids.contains(id))
222                .and_then(|id| state.files.get(&id).map(|f| f.relative_path.clone()));
223        entries.push(import_entry(raw_import, resolved_path.as_deref(), &kind));
224    }
225    Ok(build_dict([
226        ("path", str_value(&file.relative_path)),
227        ("imports", VmValue::List(Rc::new(entries))),
228    ]))
229}
230
231pub(super) fn run_importers_of(
232    index: &SharedIndex,
233    args: &[VmValue],
234) -> Result<VmValue, HostlibError> {
235    let raw = dict_arg(BUILTIN_IMPORTERS_OF, args)?;
236    let dict = raw.as_ref();
237    let module = require_string(BUILTIN_IMPORTERS_OF, dict, "module")?;
238    let guard = index.lock().expect("code_index mutex poisoned");
239    let Some(state) = guard.as_ref() else {
240        return Ok(empty_importers_response(&module));
241    };
242
243    let target_id = state.lookup_path(&module).or_else(|| {
244        // Fallback: suffix-match on relative paths so callers can request
245        // by basename (matching the `allowSuffixMatch` convention used by
246        // the resolver itself).
247        let needle = format!("/{module}");
248        state
249            .path_to_id
250            .iter()
251            .find(|(p, _)| p.ends_with(&needle) || *p == &module)
252            .map(|(_, id)| *id)
253    });
254
255    let mut importers: Vec<String> = match target_id {
256        Some(id) => state
257            .deps
258            .importers_of(id)
259            .into_iter()
260            .filter_map(|importer_id| {
261                state
262                    .files
263                    .get(&importer_id)
264                    .map(|f| f.relative_path.clone())
265            })
266            .collect(),
267        None => Vec::new(),
268    };
269    importers.sort();
270    Ok(build_dict([
271        ("module", str_value(&module)),
272        (
273            "importers",
274            VmValue::List(Rc::new(importers.into_iter().map(str_value).collect())),
275        ),
276    ]))
277}
278
279// === File table accessors ===
280
281pub(super) fn run_path_to_id(
282    index: &SharedIndex,
283    args: &[VmValue],
284) -> Result<VmValue, HostlibError> {
285    let raw = dict_arg(BUILTIN_PATH_TO_ID, args)?;
286    let path = require_string(BUILTIN_PATH_TO_ID, raw.as_ref(), "path")?;
287    let guard = index.lock().expect("code_index mutex poisoned");
288    let id = guard.as_ref().and_then(|s| s.lookup_path(&path));
289    Ok(match id {
290        Some(id) => VmValue::Int(id as i64),
291        None => VmValue::Nil,
292    })
293}
294
295pub(super) fn run_id_to_path(
296    index: &SharedIndex,
297    args: &[VmValue],
298) -> Result<VmValue, HostlibError> {
299    let raw = dict_arg(BUILTIN_ID_TO_PATH, args)?;
300    let id = require_positive_file_id(BUILTIN_ID_TO_PATH, raw.as_ref(), "file_id")?;
301    let guard = index.lock().expect("code_index mutex poisoned");
302    let path = guard
303        .as_ref()
304        .and_then(|s| s.files.get(&id))
305        .map(|f| f.relative_path.clone());
306    Ok(match path {
307        Some(p) => str_value(&p),
308        None => VmValue::Nil,
309    })
310}
311
312pub(super) fn run_file_ids(
313    index: &SharedIndex,
314    _args: &[VmValue],
315) -> Result<VmValue, HostlibError> {
316    let guard = index.lock().expect("code_index mutex poisoned");
317    let mut ids: Vec<FileId> = guard
318        .as_ref()
319        .map(|s| s.files.keys().copied().collect())
320        .unwrap_or_default();
321    ids.sort_unstable();
322    Ok(VmValue::List(Rc::new(
323        ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
324    )))
325}
326
327pub(super) fn run_file_meta(
328    index: &SharedIndex,
329    args: &[VmValue],
330) -> Result<VmValue, HostlibError> {
331    let raw = dict_arg(BUILTIN_FILE_META, args)?;
332    let dict = raw.as_ref();
333    let guard = index.lock().expect("code_index mutex poisoned");
334    let Some(state) = guard.as_ref() else {
335        return Ok(VmValue::Nil);
336    };
337    let id_opt: Option<FileId> = if dict.contains_key("file_id") {
338        Some(require_positive_file_id(
339            BUILTIN_FILE_META,
340            dict,
341            "file_id",
342        )?)
343    } else if let Some(VmValue::String(p)) = dict.get("path") {
344        state.lookup_path(p)
345    } else {
346        return Err(HostlibError::MissingParameter {
347            builtin: BUILTIN_FILE_META,
348            param: "file_id|path",
349        });
350    };
351    let Some(id) = id_opt else {
352        return Ok(VmValue::Nil);
353    };
354    let Some(file) = state.files.get(&id) else {
355        return Ok(VmValue::Nil);
356    };
357    let last_edit_seq = state
358        .versions
359        .last_entry(&file.relative_path)
360        .map(|e| e.seq)
361        .unwrap_or(0);
362    Ok(build_dict([
363        ("id", VmValue::Int(file.id as i64)),
364        ("path", str_value(&file.relative_path)),
365        ("language", str_value(&file.language)),
366        ("size", VmValue::Int(file.size_bytes as i64)),
367        ("line_count", VmValue::Int(file.line_count as i64)),
368        ("hash", str_value(file.content_hash.to_string())),
369        ("mtime_ms", VmValue::Int(file.mtime_ms)),
370        ("last_edit_seq", VmValue::Int(last_edit_seq as i64)),
371    ]))
372}
373
374pub(super) fn run_file_hash(
375    index: &SharedIndex,
376    args: &[VmValue],
377) -> Result<VmValue, HostlibError> {
378    let raw = dict_arg(BUILTIN_FILE_HASH, args)?;
379    let path = require_string(BUILTIN_FILE_HASH, raw.as_ref(), "path")?;
380    let guard = index.lock().expect("code_index mutex poisoned");
381    let Some(state) = guard.as_ref() else {
382        return Ok(VmValue::Nil);
383    };
384    let Some(abs) = state.absolute_path(&path) else {
385        return Ok(VmValue::Nil);
386    };
387    let bytes = match crate::fs::read(&abs, None) {
388        Some(result) => result,
389        None => std::fs::read(&abs),
390    };
391    match bytes {
392        Ok(bytes) => Ok(str_value(fnv1a64(&bytes).to_string())),
393        Err(_) => Ok(VmValue::Nil),
394    }
395}
396
397// === Cached reads ===
398
399pub(super) fn run_read_range(
400    index: &SharedIndex,
401    args: &[VmValue],
402) -> Result<VmValue, HostlibError> {
403    let raw = dict_arg(BUILTIN_READ_RANGE, args)?;
404    let dict = raw.as_ref();
405    let path = require_string(BUILTIN_READ_RANGE, dict, "path")?;
406    let start = optional_positive_i64(BUILTIN_READ_RANGE, dict, "start")?;
407    let end = optional_positive_i64(BUILTIN_READ_RANGE, dict, "end")?;
408    let guard = index.lock().expect("code_index mutex poisoned");
409    let abs = match guard.as_ref() {
410        Some(state) => {
411            state
412                .absolute_path(&path)
413                .ok_or_else(|| HostlibError::InvalidParameter {
414                    builtin: BUILTIN_READ_RANGE,
415                    param: "path",
416                    message: "path must stay within the indexed workspace root".to_string(),
417                })?
418        }
419        None => PathBuf::from(&path),
420    };
421    drop(guard);
422
423    let content_result = match crate::fs::read_to_string(&abs, None) {
424        Some(result) => result,
425        None => std::fs::read_to_string(&abs),
426    };
427    let content = match content_result {
428        Ok(s) => s,
429        Err(_) => {
430            return Err(HostlibError::Backend {
431                builtin: BUILTIN_READ_RANGE,
432                message: format!("file not found: {path}"),
433            })
434        }
435    };
436
437    if start.is_none() && end.is_none() {
438        return Ok(build_dict([("content", str_value(&content))]));
439    }
440    let lines: Vec<&str> = content.split('\n').collect();
441    let total = lines.len() as i64;
442    let lo = (start.unwrap_or(1) - 1).max(0) as usize;
443    let hi = end.unwrap_or(total).min(total).max(0) as usize;
444    if lo >= hi {
445        return Ok(build_dict([
446            ("content", str_value("")),
447            ("start", VmValue::Int((lo as i64) + 1)),
448            ("end", VmValue::Int(hi as i64)),
449        ]));
450    }
451    let slice = lines[lo..hi].join("\n");
452    Ok(build_dict([
453        ("content", str_value(&slice)),
454        ("start", VmValue::Int((lo as i64) + 1)),
455        ("end", VmValue::Int(hi as i64)),
456    ]))
457}
458
459pub(super) fn run_reindex_file(
460    index: &SharedIndex,
461    args: &[VmValue],
462) -> Result<VmValue, HostlibError> {
463    let raw = dict_arg(BUILTIN_REINDEX_FILE, args)?;
464    let path = require_string(BUILTIN_REINDEX_FILE, raw.as_ref(), "path")?;
465    let mut guard = index.lock().expect("code_index mutex poisoned");
466    let Some(state) = guard.as_mut() else {
467        return Ok(build_dict([
468            ("indexed", VmValue::Bool(false)),
469            ("file_id", VmValue::Nil),
470        ]));
471    };
472    let Some(abs) = state.absolute_path(&path) else {
473        return Err(HostlibError::InvalidParameter {
474            builtin: BUILTIN_REINDEX_FILE,
475            param: "path",
476            message: "path must stay within the indexed workspace root".to_string(),
477        });
478    };
479    let id = state.reindex_file(&abs);
480    Ok(build_dict([
481        ("indexed", VmValue::Bool(id.is_some())),
482        (
483            "file_id",
484            id.map(|i| VmValue::Int(i as i64)).unwrap_or(VmValue::Nil),
485        ),
486    ]))
487}
488
489pub(super) fn run_trigram_query(
490    index: &SharedIndex,
491    args: &[VmValue],
492) -> Result<VmValue, HostlibError> {
493    let raw = dict_arg(BUILTIN_TRIGRAM_QUERY, args)?;
494    let dict = raw.as_ref();
495    let trigrams_raw = optional_int_list(BUILTIN_TRIGRAM_QUERY, dict, "trigrams")?;
496    let max_files = optional_positive_usize(BUILTIN_TRIGRAM_QUERY, dict, "max_files")?;
497    let mut trigrams = Vec::with_capacity(trigrams_raw.len());
498    for n in trigrams_raw {
499        if n < 0 {
500            return Err(HostlibError::InvalidParameter {
501                builtin: BUILTIN_TRIGRAM_QUERY,
502                param: "trigrams",
503                message: "entries must be >= 0".to_string(),
504            });
505        }
506        trigrams.push(n as u32);
507    }
508    let guard = index.lock().expect("code_index mutex poisoned");
509    let mut ids: Vec<FileId> = match guard.as_ref() {
510        Some(state) => state.trigrams.query(&trigrams).into_iter().collect(),
511        None => Vec::new(),
512    };
513    ids.sort_unstable();
514    if let Some(limit) = max_files {
515        ids.truncate(limit);
516    }
517    Ok(VmValue::List(Rc::new(
518        ids.into_iter().map(|id| VmValue::Int(id as i64)).collect(),
519    )))
520}
521
522pub(super) fn run_extract_trigrams(
523    _index: &SharedIndex,
524    args: &[VmValue],
525) -> Result<VmValue, HostlibError> {
526    let raw = dict_arg(BUILTIN_EXTRACT_TRIGRAMS, args)?;
527    let query = require_string(BUILTIN_EXTRACT_TRIGRAMS, raw.as_ref(), "query")?;
528    let mut tgs = trigram::query_trigrams(&query);
529    tgs.sort_unstable();
530    Ok(VmValue::List(Rc::new(
531        tgs.into_iter().map(|n| VmValue::Int(n as i64)).collect(),
532    )))
533}
534
535pub(super) fn run_word_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
536    let raw = dict_arg(BUILTIN_WORD_GET, args)?;
537    let word = require_string(BUILTIN_WORD_GET, raw.as_ref(), "word")?;
538    let guard = index.lock().expect("code_index mutex poisoned");
539    let hits: Vec<VmValue> = match guard.as_ref() {
540        Some(state) => state
541            .words
542            .get(&word)
543            .iter()
544            .map(|h| {
545                build_dict([
546                    ("file_id", VmValue::Int(h.file as i64)),
547                    ("line", VmValue::Int(h.line as i64)),
548                ])
549            })
550            .collect(),
551        None => Vec::new(),
552    };
553    Ok(VmValue::List(Rc::new(hits)))
554}
555
556pub(super) fn run_deps_get(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
557    let raw = dict_arg(BUILTIN_DEPS_GET, args)?;
558    let dict = raw.as_ref();
559    let id = require_positive_file_id(BUILTIN_DEPS_GET, dict, "file_id")?;
560    let direction = optional_string(BUILTIN_DEPS_GET, dict, "direction")?
561        .unwrap_or_else(|| "importers".to_string());
562    let guard = index.lock().expect("code_index mutex poisoned");
563    let mut neighbors: Vec<FileId> = match guard.as_ref() {
564        Some(state) => match direction.as_str() {
565            "importers" => state.deps.importers_of(id),
566            "imports" => state.deps.imports_of(id),
567            _ => {
568                return Err(HostlibError::InvalidParameter {
569                    builtin: BUILTIN_DEPS_GET,
570                    param: "direction",
571                    message: format!("expected \"importers\" or \"imports\", got {direction:?}"),
572                })
573            }
574        },
575        None => Vec::new(),
576    };
577    neighbors.sort_unstable();
578    Ok(VmValue::List(Rc::new(
579        neighbors
580            .into_iter()
581            .map(|id| VmValue::Int(id as i64))
582            .collect(),
583    )))
584}
585
586pub(super) fn run_outline_get(
587    index: &SharedIndex,
588    args: &[VmValue],
589) -> Result<VmValue, HostlibError> {
590    let raw = dict_arg(BUILTIN_OUTLINE_GET, args)?;
591    let id = require_positive_file_id(BUILTIN_OUTLINE_GET, raw.as_ref(), "file_id")?;
592    let guard = index.lock().expect("code_index mutex poisoned");
593    let symbols: Vec<VmValue> = match guard.as_ref().and_then(|s| s.files.get(&id)) {
594        Some(file) => file
595            .symbols
596            .iter()
597            .map(|sym| {
598                build_dict([
599                    ("name", str_value(&sym.name)),
600                    ("kind", str_value(&sym.kind)),
601                    ("start_line", VmValue::Int(sym.start_line as i64)),
602                    ("end_line", VmValue::Int(sym.end_line as i64)),
603                    ("signature", str_value(&sym.signature)),
604                ])
605            })
606            .collect(),
607        None => Vec::new(),
608    };
609    Ok(VmValue::List(Rc::new(symbols)))
610}
611
612// === Change log ===
613
614pub(super) fn run_current_seq(
615    index: &SharedIndex,
616    _args: &[VmValue],
617) -> Result<VmValue, HostlibError> {
618    let guard = index.lock().expect("code_index mutex poisoned");
619    let seq = guard.as_ref().map(|s| s.versions.current_seq).unwrap_or(0);
620    Ok(VmValue::Int(seq as i64))
621}
622
623pub(super) fn run_changes_since(
624    index: &SharedIndex,
625    args: &[VmValue],
626) -> Result<VmValue, HostlibError> {
627    let raw = dict_arg(BUILTIN_CHANGES_SINCE, args)?;
628    let dict = raw.as_ref();
629    let seq = optional_non_negative_u64(BUILTIN_CHANGES_SINCE, dict, "seq", 0)?;
630    let limit = optional_positive_usize(BUILTIN_CHANGES_SINCE, dict, "limit")?;
631    let guard = index.lock().expect("code_index mutex poisoned");
632    let records = match guard.as_ref() {
633        Some(state) => state.versions.changes_since(seq, limit),
634        None => Vec::new(),
635    };
636    Ok(VmValue::List(Rc::new(
637        records
638            .into_iter()
639            .map(|r| {
640                build_dict([
641                    ("path", str_value(&r.path)),
642                    ("seq", VmValue::Int(r.seq as i64)),
643                    ("agent_id", VmValue::Int(r.agent_id as i64)),
644                    ("op", str_value(r.op.as_str())),
645                    ("hash", str_value(r.hash.to_string())),
646                    ("size", VmValue::Int(r.size as i64)),
647                    ("timestamp_ms", VmValue::Int(r.timestamp_ms)),
648                ])
649            })
650            .collect(),
651    )))
652}
653
654pub(super) fn run_version_record(
655    index: &SharedIndex,
656    args: &[VmValue],
657) -> Result<VmValue, HostlibError> {
658    let raw = dict_arg(BUILTIN_VERSION_RECORD, args)?;
659    let dict = raw.as_ref();
660    let agent_id = require_non_negative_u64(BUILTIN_VERSION_RECORD, dict, "agent_id")?;
661    let path = require_string(BUILTIN_VERSION_RECORD, dict, "path")?;
662    let op_str =
663        optional_string(BUILTIN_VERSION_RECORD, dict, "op")?.unwrap_or_else(|| "write".to_string());
664    let op = EditOp::parse(&op_str).unwrap_or(EditOp::Write);
665    let hash = parse_hash(BUILTIN_VERSION_RECORD, dict, "hash")?;
666    let size = optional_non_negative_u64(BUILTIN_VERSION_RECORD, dict, "size", 0)?;
667    let now = now_unix_ms();
668    let mut guard = index.lock().expect("code_index mutex poisoned");
669    let state = ensure_state(BUILTIN_VERSION_RECORD, &mut guard)?;
670    let normalized = normalize_relative_path(state, &path);
671    let seq = state
672        .versions
673        .record(normalized, agent_id, op, hash, size, now);
674    state.agents.note_edit(agent_id, now);
675    Ok(VmValue::Int(seq as i64))
676}
677
678// === Agent registry + locks ===
679
680pub(super) fn run_agent_register(
681    index: &SharedIndex,
682    args: &[VmValue],
683) -> Result<VmValue, HostlibError> {
684    let raw = dict_arg(BUILTIN_AGENT_REGISTER, args)?;
685    let dict = raw.as_ref();
686    let name = optional_string(BUILTIN_AGENT_REGISTER, dict, "name")?
687        .unwrap_or_else(|| "agent".to_string());
688    let requested_id = optional_positive_u64(BUILTIN_AGENT_REGISTER, dict, "agent_id")?;
689    let now = now_unix_ms();
690    let mut guard = index.lock().expect("code_index mutex poisoned");
691    let state = ensure_state(BUILTIN_AGENT_REGISTER, &mut guard)?;
692    let id = match requested_id {
693        Some(id) => state.agents.register_with_id(id, name, now),
694        None => state.agents.register(name, now),
695    };
696    Ok(VmValue::Int(id as i64))
697}
698
699pub(super) fn run_agent_heartbeat(
700    index: &SharedIndex,
701    args: &[VmValue],
702) -> Result<VmValue, HostlibError> {
703    let raw = dict_arg(BUILTIN_AGENT_HEARTBEAT, args)?;
704    let id = require_positive_u64(BUILTIN_AGENT_HEARTBEAT, raw.as_ref(), "agent_id")?;
705    let now = now_unix_ms();
706    let mut guard = index.lock().expect("code_index mutex poisoned");
707    let state = ensure_state(BUILTIN_AGENT_HEARTBEAT, &mut guard)?;
708    state.agents.heartbeat(id, now);
709    Ok(VmValue::Bool(true))
710}
711
712pub(super) fn run_agent_unregister(
713    index: &SharedIndex,
714    args: &[VmValue],
715) -> Result<VmValue, HostlibError> {
716    let raw = dict_arg(BUILTIN_AGENT_UNREGISTER, args)?;
717    let id = require_positive_u64(BUILTIN_AGENT_UNREGISTER, raw.as_ref(), "agent_id")?;
718    let mut guard = index.lock().expect("code_index mutex poisoned");
719    let state = ensure_state(BUILTIN_AGENT_UNREGISTER, &mut guard)?;
720    state.agents.unregister(id);
721    Ok(VmValue::Bool(true))
722}
723
724pub(super) fn run_lock_try(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
725    let raw = dict_arg(BUILTIN_LOCK_TRY, args)?;
726    let dict = raw.as_ref();
727    let agent_id = require_positive_u64(BUILTIN_LOCK_TRY, dict, "agent_id")?;
728    let path = require_string(BUILTIN_LOCK_TRY, dict, "path")?;
729    let ttl = optional_positive_i64(BUILTIN_LOCK_TRY, dict, "ttl_ms")?;
730    let now = now_unix_ms();
731    let mut guard = index.lock().expect("code_index mutex poisoned");
732    let state = ensure_state(BUILTIN_LOCK_TRY, &mut guard)?;
733    let granted = state.agents.try_lock(agent_id, &path, ttl, now);
734    if granted {
735        return Ok(build_dict([
736            ("locked", VmValue::Bool(true)),
737            ("holder", VmValue::Int(agent_id as i64)),
738        ]));
739    }
740    let holder = state.agents.lock_holder(&path, now);
741    Ok(build_dict([
742        ("locked", VmValue::Bool(false)),
743        (
744            "holder",
745            holder
746                .map(|id| VmValue::Int(id as i64))
747                .unwrap_or(VmValue::Nil),
748        ),
749    ]))
750}
751
752pub(super) fn run_lock_release(
753    index: &SharedIndex,
754    args: &[VmValue],
755) -> Result<VmValue, HostlibError> {
756    let raw = dict_arg(BUILTIN_LOCK_RELEASE, args)?;
757    let dict = raw.as_ref();
758    let agent_id = require_positive_u64(BUILTIN_LOCK_RELEASE, dict, "agent_id")?;
759    let path = require_string(BUILTIN_LOCK_RELEASE, dict, "path")?;
760    let mut guard = index.lock().expect("code_index mutex poisoned");
761    let state = ensure_state(BUILTIN_LOCK_RELEASE, &mut guard)?;
762    state.agents.release_lock(agent_id, &path);
763    Ok(VmValue::Bool(true))
764}
765
766pub(super) fn run_status(index: &SharedIndex, _args: &[VmValue]) -> Result<VmValue, HostlibError> {
767    let guard = index.lock().expect("code_index mutex poisoned");
768    match guard.as_ref() {
769        Some(state) => Ok(build_dict([
770            ("file_count", VmValue::Int(state.files.len() as i64)),
771            (
772                "current_seq",
773                VmValue::Int(state.versions.current_seq as i64),
774            ),
775            ("last_indexed_at_ms", VmValue::Int(state.last_built_unix_ms)),
776            (
777                "git_head",
778                state
779                    .git_head
780                    .as_deref()
781                    .map(str_value)
782                    .unwrap_or(VmValue::Nil),
783            ),
784            (
785                "agents",
786                VmValue::List(Rc::new(
787                    state
788                        .agents
789                        .agents()
790                        .map(|info| {
791                            build_dict([
792                                ("id", VmValue::Int(info.id as i64)),
793                                ("name", str_value(&info.name)),
794                                (
795                                    "state",
796                                    str_value(match info.state {
797                                        super::agents::AgentState::Active => "active",
798                                        super::agents::AgentState::Crashed => "crashed",
799                                        super::agents::AgentState::Gone => "gone",
800                                    }),
801                                ),
802                                ("last_seen_ms", VmValue::Int(info.last_seen_ms)),
803                                ("edit_count", VmValue::Int(info.edit_count as i64)),
804                                ("lock_count", VmValue::Int(info.locked_paths.len() as i64)),
805                            ])
806                        })
807                        .collect(),
808                )),
809            ),
810        ])),
811        None => Ok(build_dict([
812            ("file_count", VmValue::Int(0)),
813            ("current_seq", VmValue::Int(0)),
814            ("last_indexed_at_ms", VmValue::Int(0)),
815            ("git_head", VmValue::Nil),
816            ("agents", VmValue::List(Rc::new(Vec::new()))),
817        ])),
818    }
819}
820
821pub(super) fn run_current_agent_id(
822    slot: &Arc<Mutex<Option<AgentId>>>,
823    _args: &[VmValue],
824) -> Result<VmValue, HostlibError> {
825    let guard = slot.lock().expect("current_agent slot poisoned");
826    Ok(match *guard {
827        Some(id) => VmValue::Int(id as i64),
828        None => VmValue::Nil,
829    })
830}
831
832// === Symbol graph: cypher, branch_overlay, freshness (issue #2434) ===
833
834pub(super) fn run_cypher(index: &SharedIndex, args: &[VmValue]) -> Result<VmValue, HostlibError> {
835    let raw = dict_arg(BUILTIN_CYPHER, args)?;
836    let dict = raw.as_ref();
837    let query = require_string(BUILTIN_CYPHER, dict, "query")?;
838
839    let guard = index.lock().expect("code_index mutex poisoned");
840    let Some(state) = guard.as_ref() else {
841        return Ok(build_dict([
842            ("rows", VmValue::List(Rc::new(Vec::new()))),
843            ("overlay", VmValue::Nil),
844        ]));
845    };
846
847    let graph = state.overlays.graph(&state.symbols);
848    let rows = super::cypher::execute(&query, graph).map_err(|err| HostlibError::Backend {
849        builtin: BUILTIN_CYPHER,
850        message: err.to_string(),
851    })?;
852
853    let rows_vm: Vec<VmValue> = rows
854        .into_iter()
855        .map(|row| {
856            let mut map: BTreeMap<String, VmValue> = BTreeMap::new();
857            for (k, v) in row {
858                map.insert(k, v.to_vm());
859            }
860            VmValue::Dict(Rc::new(map))
861        })
862        .collect();
863
864    Ok(build_dict([
865        ("rows", VmValue::List(Rc::new(rows_vm))),
866        (
867            "overlay",
868            match state.overlays.active() {
869                Some(name) => str_value(name),
870                None => VmValue::Nil,
871            },
872        ),
873    ]))
874}
875
876pub(super) fn run_branch_overlay(
877    index: &SharedIndex,
878    args: &[VmValue],
879) -> Result<VmValue, HostlibError> {
880    let raw = dict_arg(BUILTIN_BRANCH_OVERLAY, args)?;
881    let dict = raw.as_ref();
882    let branch = optional_string(BUILTIN_BRANCH_OVERLAY, dict, "branch")?;
883    let activate = optional_bool(BUILTIN_BRANCH_OVERLAY, dict, "activate", true)?;
884    let action = optional_string(BUILTIN_BRANCH_OVERLAY, dict, "action")?;
885
886    let mut guard = index.lock().expect("code_index mutex poisoned");
887    let state = ensure_state(BUILTIN_BRANCH_OVERLAY, &mut guard)?;
888
889    let mut reuse: f64 = 1.0;
890    match action.as_deref().unwrap_or("activate") {
891        "deactivate" => {
892            state.overlays.activate(None);
893        }
894        "create" => {
895            let branch_name = branch.ok_or(HostlibError::MissingParameter {
896                builtin: BUILTIN_BRANCH_OVERLAY,
897                param: "branch",
898            })?;
899            let mut overlay = super::overlay::BranchOverlay::new(&branch_name);
900            overlay.materialize(&state.symbols);
901            state.overlays.set(overlay);
902            if activate {
903                state.overlays.activate(Some(branch_name));
904            }
905            reuse = state.overlays.reuse_fraction(&state.symbols);
906        }
907        "activate" => {
908            let branch_name = branch.ok_or(HostlibError::MissingParameter {
909                builtin: BUILTIN_BRANCH_OVERLAY,
910                param: "branch",
911            })?;
912            // If the overlay doesn't exist, create an empty pass-through
913            // one — the base graph then serves it untouched, giving the
914            // 100% reuse / 0-change baseline.
915            if state.overlays.get(&branch_name).is_none() {
916                let mut overlay = super::overlay::BranchOverlay::new(&branch_name);
917                overlay.materialize(&state.symbols);
918                state.overlays.set(overlay);
919            }
920            state.overlays.activate(Some(branch_name));
921            reuse = state.overlays.reuse_fraction(&state.symbols);
922        }
923        other => {
924            return Err(HostlibError::InvalidParameter {
925                builtin: BUILTIN_BRANCH_OVERLAY,
926                param: "action",
927                message: format!("expected one of activate|deactivate|create, got `{other}`"),
928            })
929        }
930    }
931
932    Ok(build_dict([
933        (
934            "active",
935            match state.overlays.active() {
936                Some(name) => str_value(name),
937                None => VmValue::Nil,
938            },
939        ),
940        ("reuse_fraction", VmValue::Float(reuse)),
941    ]))
942}
943
944pub(super) fn run_freshness(
945    index: &SharedIndex,
946    args: &[VmValue],
947) -> Result<VmValue, HostlibError> {
948    let raw = dict_arg(BUILTIN_FRESHNESS, args)?;
949    let dict = raw.as_ref();
950    let path = require_string(BUILTIN_FRESHNESS, dict, "path")?;
951
952    let guard = index.lock().expect("code_index mutex poisoned");
953    let state = guard.as_ref().ok_or_else(|| HostlibError::Backend {
954        builtin: BUILTIN_FRESHNESS,
955        message: "code index has not been initialised — call \
956            `hostlib_code_index_rebuild` first"
957            .to_string(),
958    })?;
959
960    let normalized = normalize_relative_path(state, &path);
961    let file = state
962        .lookup_path(&normalized)
963        .and_then(|id| state.files.get(&id));
964    let Some(file) = file else {
965        return Ok(unknown_freshness_response(&path));
966    };
967
968    let abs = state.root.join(&file.relative_path);
969    let (disk_mtime, disk_hash) = match std::fs::read(&abs) {
970        Ok(bytes) => {
971            let hash = fnv1a64(&bytes);
972            let mtime = std::fs::metadata(&abs)
973                .ok()
974                .and_then(|m| m.modified().ok())
975                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
976                .map(|d| d.as_millis() as i64)
977                .unwrap_or(0);
978            (mtime, Some(hash))
979        }
980        Err(_) => (0, None),
981    };
982    let stale = disk_hash != Some(file.content_hash);
983    Ok(build_dict([
984        ("path", str_value(&file.relative_path)),
985        ("known", VmValue::Bool(true)),
986        ("stale", VmValue::Bool(stale)),
987        (
988            "indexed_hash",
989            VmValue::String(Rc::from(format!("{:016x}", file.content_hash).as_str())),
990        ),
991        ("indexed_mtime_ms", VmValue::Int(file.mtime_ms)),
992        (
993            "disk_hash",
994            match disk_hash {
995                Some(h) => VmValue::String(Rc::from(format!("{h:016x}").as_str())),
996                None => VmValue::Nil,
997            },
998        ),
999        ("disk_mtime_ms", VmValue::Int(disk_mtime)),
1000    ]))
1001}
1002
1003// === Helpers ===
1004
1005fn ensure_state<'a>(
1006    builtin: &'static str,
1007    guard: &'a mut std::sync::MutexGuard<'_, Option<IndexState>>,
1008) -> Result<&'a mut IndexState, HostlibError> {
1009    if guard.is_none() {
1010        return Err(HostlibError::Backend {
1011            builtin,
1012            message: "code index has not been initialised — call \
1013                 `hostlib_code_index_rebuild` or restore from a snapshot first"
1014                .to_string(),
1015        });
1016    }
1017    Ok(guard.as_mut().unwrap())
1018}
1019
1020fn parse_hash(
1021    builtin: &'static str,
1022    dict: &BTreeMap<String, VmValue>,
1023    key: &'static str,
1024) -> Result<u64, HostlibError> {
1025    match dict.get(key) {
1026        None | Some(VmValue::Nil) => Ok(0),
1027        Some(VmValue::Int(n)) if *n >= 0 => Ok(*n as u64),
1028        Some(VmValue::Int(n)) => Err(HostlibError::InvalidParameter {
1029            builtin,
1030            param: key,
1031            message: format!("must be >= 0, got {n}"),
1032        }),
1033        Some(VmValue::String(s)) => s
1034            .parse::<u64>()
1035            .map_err(|_| HostlibError::InvalidParameter {
1036                builtin,
1037                param: key,
1038                message: format!("expected u64-parseable string, got {s:?}"),
1039            }),
1040        Some(other) => Err(HostlibError::InvalidParameter {
1041            builtin,
1042            param: key,
1043            message: format!(
1044                "expected integer or numeric string, got {}",
1045                other.type_name()
1046            ),
1047        }),
1048    }
1049}
1050
1051fn require_positive_u64(
1052    builtin: &'static str,
1053    dict: &BTreeMap<String, VmValue>,
1054    key: &'static str,
1055) -> Result<u64, HostlibError> {
1056    let raw = require_non_negative_u64(builtin, dict, key)?;
1057    if raw == 0 {
1058        return Err(HostlibError::InvalidParameter {
1059            builtin,
1060            param: key,
1061            message: "must be >= 1".to_string(),
1062        });
1063    }
1064    Ok(raw)
1065}
1066
1067fn require_positive_file_id(
1068    builtin: &'static str,
1069    dict: &BTreeMap<String, VmValue>,
1070    key: &'static str,
1071) -> Result<FileId, HostlibError> {
1072    let raw = require_positive_u64(builtin, dict, key)?;
1073    FileId::try_from(raw).map_err(|_| HostlibError::InvalidParameter {
1074        builtin,
1075        param: key,
1076        message: "does not fit in file id".to_string(),
1077    })
1078}
1079
1080fn require_non_negative_u64(
1081    builtin: &'static str,
1082    dict: &BTreeMap<String, VmValue>,
1083    key: &'static str,
1084) -> Result<u64, HostlibError> {
1085    match dict.get(key) {
1086        Some(VmValue::Int(n)) if *n >= 0 => Ok(*n as u64),
1087        Some(VmValue::Float(f)) if f.fract() == 0.0 && *f >= 0.0 => Ok(*f as u64),
1088        Some(VmValue::Int(n)) => Err(HostlibError::InvalidParameter {
1089            builtin,
1090            param: key,
1091            message: format!("must be >= 0, got {n}"),
1092        }),
1093        Some(other) => Err(HostlibError::InvalidParameter {
1094            builtin,
1095            param: key,
1096            message: format!("expected integer, got {}", other.type_name()),
1097        }),
1098        None => Err(HostlibError::MissingParameter {
1099            builtin,
1100            param: key,
1101        }),
1102    }
1103}
1104
1105fn optional_positive_u64(
1106    builtin: &'static str,
1107    dict: &BTreeMap<String, VmValue>,
1108    key: &'static str,
1109) -> Result<Option<u64>, HostlibError> {
1110    match dict.get(key) {
1111        None | Some(VmValue::Nil) => Ok(None),
1112        Some(_) => require_positive_u64(builtin, dict, key).map(Some),
1113    }
1114}
1115
1116fn optional_non_negative_u64(
1117    builtin: &'static str,
1118    dict: &BTreeMap<String, VmValue>,
1119    key: &'static str,
1120    default: u64,
1121) -> Result<u64, HostlibError> {
1122    match dict.get(key) {
1123        None | Some(VmValue::Nil) => Ok(default),
1124        Some(_) => require_non_negative_u64(builtin, dict, key),
1125    }
1126}
1127
1128fn optional_positive_i64(
1129    builtin: &'static str,
1130    dict: &BTreeMap<String, VmValue>,
1131    key: &'static str,
1132) -> Result<Option<i64>, HostlibError> {
1133    match dict.get(key) {
1134        None | Some(VmValue::Nil) => Ok(None),
1135        Some(VmValue::Int(n)) if *n >= 1 => Ok(Some(*n)),
1136        Some(VmValue::Float(f)) if f.fract() == 0.0 && *f >= 1.0 => Ok(Some(*f as i64)),
1137        Some(VmValue::Int(n)) => Err(HostlibError::InvalidParameter {
1138            builtin,
1139            param: key,
1140            message: format!("must be >= 1, got {n}"),
1141        }),
1142        Some(other) => Err(HostlibError::InvalidParameter {
1143            builtin,
1144            param: key,
1145            message: format!("expected integer, got {}", other.type_name()),
1146        }),
1147    }
1148}
1149
1150fn optional_positive_usize(
1151    builtin: &'static str,
1152    dict: &BTreeMap<String, VmValue>,
1153    key: &'static str,
1154) -> Result<Option<usize>, HostlibError> {
1155    match optional_positive_u64(builtin, dict, key)? {
1156        Some(value) => {
1157            usize::try_from(value)
1158                .map(Some)
1159                .map_err(|_| HostlibError::InvalidParameter {
1160                    builtin,
1161                    param: key,
1162                    message: "does not fit in usize".to_string(),
1163                })
1164        }
1165        None => Ok(None),
1166    }
1167}
1168
1169fn normalize_relative_path(state: &IndexState, path: &str) -> String {
1170    if let Some(rel) = state
1171        .lookup_path(path)
1172        .and_then(|id| state.files.get(&id))
1173        .map(|f| f.relative_path.clone())
1174    {
1175        return rel;
1176    }
1177    let p = std::path::Path::new(path);
1178    if p.is_absolute() {
1179        if let Ok(rel) = p.strip_prefix(&state.root) {
1180            return rel.to_string_lossy().replace('\\', "/");
1181        }
1182    }
1183    path.to_string()
1184}
1185
1186fn candidates_for(state: &IndexState, needle: &str) -> Vec<FileId> {
1187    if needle.len() >= 3 {
1188        let trigrams = trigram::query_trigrams(needle);
1189        return state.trigrams.query(&trigrams).into_iter().collect();
1190    }
1191    state.files.keys().copied().collect()
1192}
1193
1194fn read_file_text(root: &std::path::Path, relative: &str) -> Option<String> {
1195    let path = root.join(relative);
1196    match crate::fs::read_to_string(&path, None) {
1197        Some(result) => result.ok(),
1198        None => std::fs::read_to_string(path).ok(),
1199    }
1200}
1201
1202fn count_matches(haystack: &str, needle: &str, case_sensitive: bool) -> u64 {
1203    if case_sensitive {
1204        haystack.matches(needle).count() as u64
1205    } else {
1206        let lower_h = haystack.to_lowercase();
1207        let lower_n = needle.to_lowercase();
1208        lower_h.matches(&lower_n).count() as u64
1209    }
1210}
1211
1212fn scope_allows(scope: &[String], relative: &str) -> bool {
1213    if scope.is_empty() {
1214        return true;
1215    }
1216    scope
1217        .iter()
1218        .any(|s| relative == s || relative.starts_with(&format!("{s}/")) || s.is_empty())
1219}
1220
1221struct Hit {
1222    path: String,
1223    score: f64,
1224    match_count: u64,
1225}
1226
1227fn hit_to_value(hit: Hit) -> VmValue {
1228    let Hit {
1229        path,
1230        score,
1231        match_count,
1232    } = hit;
1233    build_dict([
1234        ("path", str_value(&path)),
1235        ("score", VmValue::Float(score)),
1236        ("match_count", VmValue::Int(match_count as i64)),
1237    ])
1238}
1239
1240fn import_entry(module: &str, resolved: Option<&str>, kind: &str) -> VmValue {
1241    let mut map: BTreeMap<String, VmValue> = BTreeMap::new();
1242    map.insert("module".into(), str_value(module));
1243    map.insert(
1244        "resolved_path".into(),
1245        match resolved {
1246            Some(p) => str_value(p),
1247            None => VmValue::Nil,
1248        },
1249    );
1250    map.insert("kind".into(), str_value(kind));
1251    VmValue::Dict(Rc::new(map))
1252}
1253
1254fn empty_query_response() -> VmValue {
1255    build_dict([
1256        ("results", VmValue::List(Rc::new(Vec::new()))),
1257        ("truncated", VmValue::Bool(false)),
1258    ])
1259}
1260
1261fn empty_stats_response() -> VmValue {
1262    build_dict([
1263        ("indexed_files", VmValue::Int(0)),
1264        ("trigrams", VmValue::Int(0)),
1265        ("words", VmValue::Int(0)),
1266        ("memory_bytes", VmValue::Int(0)),
1267        ("last_rebuild_unix_ms", VmValue::Nil),
1268    ])
1269}
1270
1271fn empty_imports_response(path: &str) -> VmValue {
1272    build_dict([
1273        ("path", str_value(path)),
1274        ("imports", VmValue::List(Rc::new(Vec::new()))),
1275    ])
1276}
1277
1278fn empty_importers_response(module: &str) -> VmValue {
1279    build_dict([
1280        ("module", str_value(module)),
1281        ("importers", VmValue::List(Rc::new(Vec::new()))),
1282    ])
1283}
1284
1285fn unknown_freshness_response(path: &str) -> VmValue {
1286    build_dict([
1287        ("path", str_value(path)),
1288        ("known", VmValue::Bool(false)),
1289        ("stale", VmValue::Bool(true)),
1290        ("indexed_hash", VmValue::Nil),
1291        ("indexed_mtime_ms", VmValue::Nil),
1292        ("disk_hash", VmValue::Nil),
1293        ("disk_mtime_ms", VmValue::Nil),
1294    ])
1295}