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