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