Skip to main content

harn_hostlib/scanner/
mod.rs

1//! Repo scanner host capability.
2//!
3//! Deterministic project-wide file enumeration honoring `.gitignore` and
4//! the [`extensions::EXCLUDED_DIRS`] table, symbol extraction,
5//! import-derived dependency graph, reference + churn + importance
6//! scoring, source/test pairing, folder aggregates, project metadata
7//! (language stats + detected test commands + code-pattern hints),
8//! sub-project detection, and a token-budgeted text repo map.
9//!
10//! `scan_project` returns the full [`result::ScanResult`] alongside an
11//! opaque `snapshot_token` derived from the canonicalized root path. The
12//! result is persisted to `<root>/.harn/hostlib/scanner-snapshot.json` so
13//! that `scan_incremental` can diff against it later — without forcing the
14//! caller to pass the previous result back over the wire.
15
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::sync::Arc;
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use harn_vm::ignore_policy::IgnorePolicy;
22use harn_vm::VmValue;
23
24use crate::error::HostlibError;
25use crate::registry::{BuiltinRegistry, HostlibCapability};
26use crate::tools::args::{
27    build_dict, dict_arg, optional_bool, optional_int, optional_string, require_string, str_value,
28};
29
30mod commands;
31mod discover;
32mod extensions;
33mod fingerprint;
34mod folders;
35mod git;
36mod imports;
37mod manifest;
38mod result;
39mod scoring;
40mod snapshot;
41mod subproject;
42mod symbols;
43mod test_mapping;
44
45fn strip_ambient_git_env(cmd: &mut Command) {
46    // Git exports repository-specific GIT_* variables while running hooks.
47    // Scanner probes must honor their explicit `-C <root>` argument instead.
48    for (key, _) in std::env::vars() {
49        if key.starts_with("GIT_") {
50            cmd.env_remove(&key);
51        }
52    }
53}
54
55pub use git::GitCapabilities;
56pub use result::{
57    DependencyEdge, FileRecord, FolderRecord, LanguageStat, ProjectMetadata, ScanDelta, ScanResult,
58    SubProject, SymbolKind, SymbolRecord,
59};
60
61const SCAN_PROJECT_BUILTIN: &str = "hostlib_scanner_scan_project";
62const SCAN_INCREMENTAL_BUILTIN: &str = "hostlib_scanner_scan_incremental";
63
64/// Scanner capability handle.
65#[derive(Default)]
66pub struct ScannerCapability;
67
68impl HostlibCapability for ScannerCapability {
69    fn module_name(&self) -> &'static str {
70        "scanner"
71    }
72
73    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
74        registry.register_fn(
75            "scanner",
76            SCAN_PROJECT_BUILTIN,
77            "scan_project",
78            scan_project_handler,
79        );
80        registry.register_fn(
81            "scanner",
82            SCAN_INCREMENTAL_BUILTIN,
83            "scan_incremental",
84            scan_incremental_handler,
85        );
86    }
87}
88
89// MARK: - Public Rust API (used by tests + by harn-cli embedders).
90
91/// Tunable knobs accepted by [`scan_project`].
92#[derive(Clone, Debug)]
93pub struct ScanProjectOptions {
94    /// Include hidden (`.`) entries during walking.
95    pub include_hidden: bool,
96    /// How much of the shared ignore stack applies.
97    pub ignore_policy: IgnorePolicy,
98    /// Hard cap on file count (0 = unlimited).
99    pub max_files: usize,
100    /// Run `git log` to compute churn scores.
101    pub include_git_history: bool,
102    /// Approximate token budget for the text repo map.
103    pub repo_map_token_budget: usize,
104}
105
106impl Default for ScanProjectOptions {
107    fn default() -> Self {
108        Self {
109            include_hidden: false,
110            ignore_policy: IgnorePolicy::default(),
111            max_files: 0,
112            include_git_history: true,
113            repo_map_token_budget: 1200,
114        }
115    }
116}
117
118/// Run a full scan of `root`, persist a snapshot, and return the result.
119pub fn scan_project(root: &Path, opts: ScanProjectOptions) -> ScanResult {
120    scan_project_with_git(root, opts, &git::CliGitCapabilities)
121}
122
123/// Run a full scan using caller-supplied Git data.
124///
125/// Embedders normally call [`scan_project`]. Tests and hosts that already
126/// virtualize Git can use this entry point to keep scanner behavior
127/// deterministic without depending on ambient process state.
128pub fn scan_project_with_git(
129    root: &Path,
130    opts: ScanProjectOptions,
131    git: &dyn GitCapabilities,
132) -> ScanResult {
133    let canonical = canonicalize(root);
134    let discover_opts = discover::DiscoverOptions {
135        include_hidden: opts.include_hidden,
136        ignore_policy: opts.ignore_policy,
137    };
138    let mut discovered = discover::discover_files(&canonical, discover_opts, git);
139    let truncated = if opts.max_files > 0 && discovered.len() > opts.max_files {
140        discovered.truncate(opts.max_files);
141        true
142    } else {
143        false
144    };
145
146    let (mut files, mut symbols, mut dependencies) = extract_per_file(&discovered);
147
148    scoring::compute_reference_counts(&mut symbols, &files);
149
150    if opts.include_git_history {
151        let churn = git.churn_scores(&canonical);
152        scoring::apply_churn(&mut files, &churn);
153    }
154    scoring::compute_importance_scores(&mut symbols, &files);
155
156    test_mapping::map_test_files(&mut files);
157
158    let folder_records = folders::build_folder_records(&files, &symbols);
159    let test_commands = commands::detect_test_commands(&canonical);
160    let code_patterns = commands::detect_code_patterns(&files, &canonical);
161    let mut project = folders::build_project_metadata(
162        &canonical,
163        &files,
164        test_commands,
165        code_patterns,
166        now_iso8601(),
167    );
168    let repo_map = folders::build_repo_map(&symbols, &files, opts.repo_map_token_budget);
169    let mut sub_projects = subproject::detect_subprojects(&canonical, 2);
170    attach_manifest_dependencies(&canonical, &mut project, &mut sub_projects);
171    let codebase_fingerprint =
172        fingerprint::build(&canonical, &project, &files, &symbols, &dependencies);
173
174    sort_for_output(&mut files, &mut symbols, &mut dependencies);
175
176    let token = snapshot::root_to_token(&canonical);
177    let result = ScanResult {
178        snapshot_token: token,
179        truncated,
180        project,
181        folders: folder_records,
182        files,
183        symbols,
184        dependencies,
185        sub_projects,
186        repo_map,
187        codebase_fingerprint,
188    };
189    snapshot::save(&canonical, &result);
190    result
191}
192
193/// Result returned by [`scan_incremental`].
194#[derive(Clone, Debug)]
195pub struct IncrementalScan {
196    /// Refreshed scan result.
197    pub result: ScanResult,
198    /// Path delta computed against the snapshot.
199    pub delta: ScanDelta,
200}
201
202/// Refresh the snapshot named by `token`. If the snapshot is missing, the
203/// diff is too large (>30%), or `changed_paths` is empty after `>30%` of
204/// the workspace mtime-mismatched, falls back to a full rescan.
205pub fn scan_incremental(
206    token: &str,
207    explicit_changed: Option<&[String]>,
208    opts: ScanProjectOptions,
209) -> IncrementalScan {
210    scan_incremental_with_git(token, explicit_changed, opts, &git::CliGitCapabilities)
211}
212
213/// Refresh a snapshot using caller-supplied Git data.
214pub fn scan_incremental_with_git(
215    token: &str,
216    explicit_changed: Option<&[String]>,
217    opts: ScanProjectOptions,
218    git: &dyn GitCapabilities,
219) -> IncrementalScan {
220    let root = snapshot::token_to_root(token);
221    let canonical = canonicalize(&root);
222
223    let cached = snapshot::load(&canonical);
224    let cached = match cached {
225        Some(c) => c,
226        None => {
227            let result = scan_project_with_git(&canonical, opts, git);
228            return IncrementalScan {
229                result,
230                delta: ScanDelta {
231                    full_rescan: true,
232                    ..ScanDelta::default()
233                },
234            };
235        }
236    };
237
238    let discover_opts = discover::DiscoverOptions {
239        include_hidden: opts.include_hidden,
240        ignore_policy: opts.ignore_policy,
241    };
242    let mut current = discover::discover_files(&canonical, discover_opts, git);
243    if opts.max_files > 0 && current.len() > opts.max_files {
244        current.truncate(opts.max_files);
245    }
246
247    let delta = compute_delta(&current, &cached, explicit_changed);
248    let total = current.len();
249    let needs_full_rescan =
250        total > 0 && (delta.added.len() + delta.modified.len()) * 10 > total * 3;
251
252    if needs_full_rescan {
253        let result = scan_project_with_git(&canonical, opts, git);
254        return IncrementalScan {
255            result,
256            delta: ScanDelta {
257                full_rescan: true,
258                ..delta
259            },
260        };
261    }
262
263    if delta.added.is_empty() && delta.modified.is_empty() && delta.removed.is_empty() {
264        return IncrementalScan {
265            result: cached,
266            delta,
267        };
268    }
269
270    // Incremental path: rebuild only the touched files, then re-finalize.
271    let mut files = cached.files;
272    let mut symbols = cached.symbols;
273    let mut dependencies = cached.dependencies;
274
275    let removed_set: std::collections::HashSet<&str> =
276        delta.removed.iter().map(|s| s.as_str()).collect();
277    let touched_set: std::collections::HashSet<&str> = delta
278        .added
279        .iter()
280        .chain(delta.modified.iter())
281        .map(|s| s.as_str())
282        .collect();
283
284    files.retain(|f| !removed_set.contains(f.relative_path.as_str()));
285    symbols.retain(|s| {
286        !removed_set.contains(s.file_path.as_str()) && !touched_set.contains(s.file_path.as_str())
287    });
288    dependencies.retain(|d| {
289        !removed_set.contains(d.from_file.as_str()) && !touched_set.contains(d.from_file.as_str())
290    });
291
292    let touched_entries: Vec<discover::DiscoveredFile> = current
293        .iter()
294        .filter(|e| touched_set.contains(e.relative_path.as_str()))
295        .cloned()
296        .collect();
297    let (new_files, new_symbols, new_deps) = extract_per_file(&touched_entries);
298
299    let mut by_path: std::collections::BTreeMap<String, FileRecord> = files
300        .into_iter()
301        .map(|f| (f.relative_path.clone(), f))
302        .collect();
303    for new_file in new_files {
304        by_path.insert(new_file.relative_path.clone(), new_file);
305    }
306    let mut files: Vec<FileRecord> = by_path.into_values().collect();
307    symbols.extend(new_symbols);
308    dependencies.extend(new_deps);
309
310    scoring::compute_reference_counts(&mut symbols, &files);
311    if opts.include_git_history {
312        let churn = git.churn_scores(&canonical);
313        scoring::apply_churn(&mut files, &churn);
314    }
315    scoring::compute_importance_scores(&mut symbols, &files);
316    test_mapping::map_test_files(&mut files);
317
318    let folder_records = folders::build_folder_records(&files, &symbols);
319    let test_commands = commands::detect_test_commands(&canonical);
320    let code_patterns = commands::detect_code_patterns(&files, &canonical);
321    let mut project = folders::build_project_metadata(
322        &canonical,
323        &files,
324        test_commands,
325        code_patterns,
326        now_iso8601(),
327    );
328    let repo_map = folders::build_repo_map(&symbols, &files, opts.repo_map_token_budget);
329    let mut sub_projects = subproject::detect_subprojects(&canonical, 2);
330    attach_manifest_dependencies(&canonical, &mut project, &mut sub_projects);
331    let codebase_fingerprint =
332        fingerprint::build(&canonical, &project, &files, &symbols, &dependencies);
333
334    sort_for_output(&mut files, &mut symbols, &mut dependencies);
335
336    let token = snapshot::root_to_token(&canonical);
337    let result = ScanResult {
338        snapshot_token: token,
339        truncated: cached.truncated,
340        project,
341        folders: folder_records,
342        files,
343        symbols,
344        dependencies,
345        sub_projects,
346        repo_map,
347        codebase_fingerprint,
348    };
349    snapshot::save(&canonical, &result);
350    IncrementalScan { result, delta }
351}
352
353// MARK: - Internals
354
355fn canonicalize(root: &Path) -> PathBuf {
356    std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
357}
358
359/// Compute package-manifest dependencies for the root and each detected
360/// sub-project. Centralized here so manifest parsing (in [`manifest`]) is
361/// invoked exactly once per project directory by both the full and the
362/// incremental scan paths.
363fn attach_manifest_dependencies(
364    canonical: &Path,
365    project: &mut ProjectMetadata,
366    sub_projects: &mut [SubProject],
367) {
368    project.available_dependencies = manifest::directory_dependencies(canonical);
369    for sp in sub_projects.iter_mut() {
370        sp.dependencies = manifest::directory_dependencies(Path::new(&sp.path));
371    }
372}
373
374fn extract_per_file(
375    discovered: &[discover::DiscoveredFile],
376) -> (Vec<FileRecord>, Vec<SymbolRecord>, Vec<DependencyEdge>) {
377    let mut files: Vec<FileRecord> = Vec::with_capacity(discovered.len());
378    let mut symbols: Vec<SymbolRecord> = Vec::new();
379    let mut dependencies: Vec<DependencyEdge> = Vec::new();
380
381    for entry in discovered {
382        let metadata = std::fs::metadata(&entry.absolute_path);
383        let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
384        let modified = metadata
385            .as_ref()
386            .ok()
387            .and_then(|m| m.modified().ok())
388            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
389            .map(|d| d.as_millis() as i64)
390            .unwrap_or(0);
391
392        let content = std::fs::read_to_string(&entry.absolute_path).unwrap_or_default();
393        if content.is_empty() && size != 0 {
394            // Likely a non-utf8 binary; skip symbol/import extraction but still record the file.
395        }
396        let language = extensions::file_extension(&entry.relative_path);
397        let imports = imports::extract_imports(&content, &language);
398        let file_symbols = symbols::extract_symbols(&content, &language, &entry.relative_path);
399        let line_count = crate::text::count_lines(content.as_bytes()) as usize;
400
401        for imp in &imports {
402            dependencies.push(DependencyEdge {
403                from_file: entry.relative_path.clone(),
404                to_module: imp.clone(),
405            });
406        }
407        symbols.extend(file_symbols);
408
409        files.push(FileRecord {
410            id: entry.relative_path.clone(),
411            relative_path: entry.relative_path.clone(),
412            file_name: extensions::file_name(&entry.relative_path).to_string(),
413            language,
414            line_count,
415            size_bytes: size,
416            last_modified_unix_ms: modified,
417            imports,
418            churn_score: 0.0,
419            corresponding_test_file: None,
420        });
421    }
422
423    (files, symbols, dependencies)
424}
425
426fn sort_for_output(
427    files: &mut [FileRecord],
428    symbols: &mut [SymbolRecord],
429    dependencies: &mut [DependencyEdge],
430) {
431    files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
432    symbols.sort_by(|a, b| a.id.cmp(&b.id));
433    dependencies.sort_by(|a, b| {
434        a.from_file
435            .cmp(&b.from_file)
436            .then_with(|| a.to_module.cmp(&b.to_module))
437    });
438}
439
440fn compute_delta(
441    current: &[discover::DiscoveredFile],
442    cached: &ScanResult,
443    explicit_changed: Option<&[String]>,
444) -> ScanDelta {
445    let cached_files: std::collections::BTreeMap<&str, &FileRecord> = cached
446        .files
447        .iter()
448        .map(|f| (f.relative_path.as_str(), f))
449        .collect();
450    let current_paths: std::collections::HashSet<&str> =
451        current.iter().map(|e| e.relative_path.as_str()).collect();
452
453    let added: Vec<String> = current
454        .iter()
455        .filter(|e| !cached_files.contains_key(e.relative_path.as_str()))
456        .map(|e| e.relative_path.clone())
457        .collect();
458    let removed: Vec<String> = cached
459        .files
460        .iter()
461        .filter(|f| !current_paths.contains(f.relative_path.as_str()))
462        .map(|f| f.relative_path.clone())
463        .collect();
464
465    let modified: Vec<String> = if let Some(explicit) = explicit_changed {
466        explicit
467            .iter()
468            .filter(|p| cached_files.contains_key(p.as_str()) && current_paths.contains(p.as_str()))
469            .cloned()
470            .collect()
471    } else {
472        let mut out = Vec::new();
473        for entry in current {
474            if let Some(prev) = cached_files.get(entry.relative_path.as_str()) {
475                let meta = std::fs::metadata(&entry.absolute_path).ok();
476                let mtime = meta
477                    .as_ref()
478                    .and_then(|m| m.modified().ok())
479                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
480                    .map(|d| d.as_millis() as i64)
481                    .unwrap_or(0);
482                let size = meta.as_ref().map(|m| m.len()).unwrap_or(prev.size_bytes);
483                // A newer mtime is the cheap common signal, but mtime
484                // granularity collides on same-turn/same-second edits (and on
485                // coarse-granularity filesystems), silently dropping the edit.
486                // A changed byte size is an mtime-independent modification
487                // signal that catches the overwhelmingly common add/remove edit
488                // for free — `meta.len()` is already in hand. Without it, an
489                // agent that writes a file and re-scans in the same instant
490                // keeps reading the pre-edit symbol facts.
491                if mtime > prev.last_modified_unix_ms || size != prev.size_bytes {
492                    out.push(entry.relative_path.clone());
493                }
494            }
495        }
496        out
497    };
498
499    ScanDelta {
500        added,
501        modified,
502        removed,
503        full_rescan: false,
504    }
505}
506
507fn now_iso8601() -> String {
508    let now = SystemTime::now()
509        .duration_since(UNIX_EPOCH)
510        .unwrap_or_default();
511    let secs = now.as_secs() as i64;
512    let nanos = now.subsec_nanos();
513    let (year, month, day, hour, minute, second) = unix_to_civil(secs);
514    format!(
515        "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z",
516        millis = nanos / 1_000_000
517    )
518}
519
520/// Convert a unix timestamp (seconds, UTC) to civil date components. Uses
521/// Howard Hinnant's algorithm so we don't pull in `chrono` for one
522/// formatter.
523fn unix_to_civil(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
524    let days = secs.div_euclid(86_400);
525    let day_secs = secs.rem_euclid(86_400);
526    let hour = (day_secs / 3600) as u32;
527    let minute = ((day_secs % 3600) / 60) as u32;
528    let second = (day_secs % 60) as u32;
529
530    // Days from 1970-01-01.
531    let z = days + 719_468;
532    let era = z.div_euclid(146_097);
533    let doe = z.rem_euclid(146_097) as u64;
534    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
535    let y = yoe as i64 + era * 400;
536    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
537    let mp = (5 * doy + 2) / 153;
538    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
539    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
540    let year = if month <= 2 { y + 1 } else { y };
541    (year, month, day, hour, minute, second)
542}
543
544// MARK: - Builtin handlers (Harn dict ↔ Rust struct).
545
546fn scan_project_handler(args: &[VmValue]) -> Result<VmValue, HostlibError> {
547    let raw = dict_arg(SCAN_PROJECT_BUILTIN, args)?;
548    let dict = raw.as_ref();
549    let root = require_string(SCAN_PROJECT_BUILTIN, dict, "root")?;
550    let opts = parse_options(SCAN_PROJECT_BUILTIN, dict)?;
551    let result = scan_project(Path::new(&root), opts);
552    Ok(scan_result_to_value(&result, None))
553}
554
555fn scan_incremental_handler(args: &[VmValue]) -> Result<VmValue, HostlibError> {
556    let raw = dict_arg(SCAN_INCREMENTAL_BUILTIN, args)?;
557    let dict = raw.as_ref();
558    let token = require_string(SCAN_INCREMENTAL_BUILTIN, dict, "snapshot_token")?;
559    let opts = parse_options(SCAN_INCREMENTAL_BUILTIN, dict)?;
560    let changed = parse_changed_paths(SCAN_INCREMENTAL_BUILTIN, dict)?;
561    let scan = scan_incremental(&token, changed.as_deref(), opts);
562    Ok(scan_result_to_value(&scan.result, Some(&scan.delta)))
563}
564
565fn ignore_policy_arg(
566    builtin: &'static str,
567    dict: &harn_vm::value::DictMap,
568) -> Result<IgnorePolicy, HostlibError> {
569    let Some(raw) = optional_string(builtin, dict, IgnorePolicy::OPTION_KEY)? else {
570        return Ok(IgnorePolicy::default());
571    };
572    IgnorePolicy::parse_for(builtin, &raw).map_err(|message| HostlibError::InvalidParameter {
573        builtin,
574        param: "ignore_policy",
575        message,
576    })
577}
578
579fn parse_options(
580    builtin: &'static str,
581    dict: &harn_vm::value::DictMap,
582) -> Result<ScanProjectOptions, HostlibError> {
583    let include_hidden = optional_bool(builtin, dict, "include_hidden", false)?;
584    let ignore_policy = ignore_policy_arg(builtin, dict)?;
585    let max_files = optional_int(builtin, dict, "max_files", 0)?;
586    let include_git_history_default = builtin == SCAN_PROJECT_BUILTIN;
587    let include_git_history = optional_bool(
588        builtin,
589        dict,
590        "include_git_history",
591        include_git_history_default,
592    )?;
593    let repo_map_token_budget = optional_int(builtin, dict, "repo_map_token_budget", 1200)?;
594    if max_files < 0 {
595        return Err(HostlibError::InvalidParameter {
596            builtin,
597            param: "max_files",
598            message: "must be >= 0".to_string(),
599        });
600    }
601    if repo_map_token_budget < 0 {
602        return Err(HostlibError::InvalidParameter {
603            builtin,
604            param: "repo_map_token_budget",
605            message: "must be >= 0".to_string(),
606        });
607    }
608    Ok(ScanProjectOptions {
609        include_hidden,
610        ignore_policy,
611        max_files: max_files as usize,
612        include_git_history,
613        repo_map_token_budget: repo_map_token_budget as usize,
614    })
615}
616
617fn parse_changed_paths(
618    builtin: &'static str,
619    dict: &harn_vm::value::DictMap,
620) -> Result<Option<Vec<String>>, HostlibError> {
621    let value = match dict.get("changed_paths") {
622        None | Some(VmValue::Nil) => return Ok(None),
623        Some(v) => v,
624    };
625    let list = match value {
626        VmValue::List(items) => items,
627        other => {
628            return Err(HostlibError::InvalidParameter {
629                builtin,
630                param: "changed_paths",
631                message: format!("expected list of strings, got {}", other.type_name()),
632            });
633        }
634    };
635    let mut out = Vec::with_capacity(list.len());
636    for item in list.iter() {
637        match item {
638            VmValue::String(s) => out.push(s.to_string()),
639            other => {
640                return Err(HostlibError::InvalidParameter {
641                    builtin,
642                    param: "changed_paths",
643                    message: format!("non-string entry: {}", other.type_name()),
644                });
645            }
646        }
647    }
648    Ok(Some(out))
649}
650
651fn scan_result_to_value(result: &ScanResult, delta: Option<&ScanDelta>) -> VmValue {
652    let mut entries: Vec<(&'static str, VmValue)> = vec![
653        ("snapshot_token", str_value(&result.snapshot_token)),
654        ("truncated", VmValue::Bool(result.truncated)),
655        ("project", project_to_value(&result.project)),
656        ("folders", list_of(&result.folders, folder_to_value)),
657        ("files", list_of(&result.files, file_to_value)),
658        ("symbols", list_of(&result.symbols, symbol_to_value)),
659        (
660            "dependencies",
661            list_of(&result.dependencies, dependency_to_value),
662        ),
663        (
664            "sub_projects",
665            list_of(&result.sub_projects, subproject_to_value),
666        ),
667        ("repo_map", str_value(&result.repo_map)),
668        (
669            "codebase_fingerprint",
670            str_value(&result.codebase_fingerprint),
671        ),
672    ];
673    if let Some(d) = delta {
674        entries.push(("delta", delta_to_value(d)));
675    }
676    build_dict(entries)
677}
678
679fn list_of<T>(items: &[T], to_value: fn(&T) -> VmValue) -> VmValue {
680    let list: Vec<VmValue> = items.iter().map(to_value).collect();
681    VmValue::List(Arc::new(list))
682}
683
684fn project_to_value(project: &ProjectMetadata) -> VmValue {
685    let test_commands_entries: Vec<(String, VmValue)> = project
686        .test_commands
687        .iter()
688        .map(|(k, v)| (k.clone(), str_value(v)))
689        .collect();
690    let test_commands_dict = build_dict(test_commands_entries);
691
692    let detected: VmValue = project
693        .detected_test_command
694        .as_deref()
695        .map(str_value)
696        .unwrap_or(VmValue::Nil);
697
698    let code_patterns: Vec<VmValue> = project.code_patterns.iter().map(str_value).collect();
699    let available_dependencies: Vec<VmValue> = project
700        .available_dependencies
701        .iter()
702        .map(str_value)
703        .collect();
704
705    build_dict([
706        ("name", str_value(&project.name)),
707        ("root_path", str_value(&project.root_path)),
708        ("languages", list_of(&project.languages, language_to_value)),
709        ("test_commands", test_commands_dict),
710        ("detected_test_command", detected),
711        ("code_patterns", VmValue::List(Arc::new(code_patterns))),
712        ("total_files", VmValue::Int(project.total_files as i64)),
713        ("total_lines", VmValue::Int(project.total_lines as i64)),
714        ("last_scanned_at", str_value(&project.last_scanned_at)),
715        (
716            "available_dependencies",
717            VmValue::List(Arc::new(available_dependencies)),
718        ),
719    ])
720}
721
722fn language_to_value(stat: &LanguageStat) -> VmValue {
723    build_dict([
724        ("name", str_value(&stat.name)),
725        ("file_count", VmValue::Int(stat.file_count as i64)),
726        ("line_count", VmValue::Int(stat.line_count as i64)),
727        ("percentage", VmValue::Float(stat.percentage)),
728    ])
729}
730
731fn folder_to_value(folder: &FolderRecord) -> VmValue {
732    let names: Vec<VmValue> = folder.key_symbol_names.iter().map(str_value).collect();
733    build_dict([
734        ("id", str_value(&folder.id)),
735        ("relative_path", str_value(&folder.relative_path)),
736        ("file_count", VmValue::Int(folder.file_count as i64)),
737        ("line_count", VmValue::Int(folder.line_count as i64)),
738        ("dominant_language", str_value(&folder.dominant_language)),
739        ("key_symbol_names", VmValue::List(Arc::new(names))),
740    ])
741}
742
743fn file_to_value(file: &FileRecord) -> VmValue {
744    let imports: Vec<VmValue> = file.imports.iter().map(str_value).collect();
745    let test_pair = file
746        .corresponding_test_file
747        .as_deref()
748        .map(str_value)
749        .unwrap_or(VmValue::Nil);
750    build_dict([
751        ("id", str_value(&file.id)),
752        ("relative_path", str_value(&file.relative_path)),
753        ("file_name", str_value(&file.file_name)),
754        ("language", str_value(&file.language)),
755        ("line_count", VmValue::Int(file.line_count as i64)),
756        ("size_bytes", VmValue::Int(file.size_bytes as i64)),
757        (
758            "last_modified_unix_ms",
759            VmValue::Int(file.last_modified_unix_ms),
760        ),
761        ("imports", VmValue::List(Arc::new(imports))),
762        ("churn_score", VmValue::Float(file.churn_score)),
763        ("corresponding_test_file", test_pair),
764    ])
765}
766
767fn symbol_to_value(symbol: &SymbolRecord) -> VmValue {
768    let container = symbol
769        .container
770        .as_deref()
771        .map(str_value)
772        .unwrap_or(VmValue::Nil);
773    build_dict([
774        ("id", str_value(&symbol.id)),
775        ("name", str_value(&symbol.name)),
776        ("kind", str_value(symbol.kind.keyword())),
777        ("file_path", str_value(&symbol.file_path)),
778        ("line", VmValue::Int(symbol.line as i64)),
779        ("signature", str_value(&symbol.signature)),
780        ("container", container),
781        (
782            "reference_count",
783            VmValue::Int(symbol.reference_count as i64),
784        ),
785        ("importance_score", VmValue::Float(symbol.importance_score)),
786    ])
787}
788
789fn dependency_to_value(dep: &DependencyEdge) -> VmValue {
790    build_dict([
791        ("from_file", str_value(&dep.from_file)),
792        ("to_module", str_value(&dep.to_module)),
793    ])
794}
795
796fn subproject_to_value(sp: &SubProject) -> VmValue {
797    let dependencies: Vec<VmValue> = sp.dependencies.iter().map(str_value).collect();
798    build_dict([
799        ("path", str_value(&sp.path)),
800        ("name", str_value(&sp.name)),
801        ("language", str_value(&sp.language)),
802        ("project_marker", str_value(&sp.project_marker)),
803        ("dependencies", VmValue::List(Arc::new(dependencies))),
804    ])
805}
806
807fn delta_to_value(delta: &ScanDelta) -> VmValue {
808    let added: Vec<VmValue> = delta.added.iter().map(str_value).collect();
809    let modified: Vec<VmValue> = delta.modified.iter().map(str_value).collect();
810    let removed: Vec<VmValue> = delta.removed.iter().map(str_value).collect();
811    build_dict([
812        ("added", VmValue::List(Arc::new(added))),
813        ("modified", VmValue::List(Arc::new(modified))),
814        ("removed", VmValue::List(Arc::new(removed))),
815        ("full_rescan", VmValue::Bool(delta.full_rescan)),
816    ])
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use filetime::{set_file_mtime, FileTime};
823    use std::fs;
824
825    #[test]
826    fn builtin_option_defaults_match_request_schemas() {
827        let dict = harn_vm::value::DictMap::new();
828
829        let scan_project = parse_options(SCAN_PROJECT_BUILTIN, &dict).unwrap();
830        let scan_incremental = parse_options(SCAN_INCREMENTAL_BUILTIN, &dict).unwrap();
831
832        assert!(scan_project.include_git_history);
833        assert!(!scan_incremental.include_git_history);
834    }
835
836    fn symbol_names(scan: &IncrementalScan) -> Vec<String> {
837        scan.result.symbols.iter().map(|s| s.name.clone()).collect()
838    }
839
840    /// Regression guard: an agent that writes a file and re-scans in the same
841    /// instant must see its own edit. `compute_delta`'s mtime comparison
842    /// collides on same-millisecond/same-second writes (and on
843    /// coarse-granularity filesystems), so the size-change fallback is what
844    /// keeps same-turn index freshness honest. Before the fallback this
845    /// returned the pre-edit symbol set, feeding fuzzy-match-stale loops on
846    /// cheap local models.
847    #[test]
848    fn scan_incremental_detects_same_mtime_size_changing_edit() {
849        let dir = tempfile::tempdir().unwrap();
850        fs::create_dir_all(dir.path().join("src")).unwrap();
851        let file = dir.path().join("src/lib.rs");
852        fs::write(&file, "pub fn old_symbol() {}\n").unwrap();
853
854        // Canonicalize so the snapshot token matches across calls.
855        let canonical = std::fs::canonicalize(dir.path()).unwrap();
856        let token = canonical.to_string_lossy().to_string();
857        let opts = ScanProjectOptions::default();
858
859        let first = scan_incremental(&token, None, opts.clone());
860        let cached_mtime = first
861            .result
862            .files
863            .iter()
864            .find(|r| r.relative_path == "src/lib.rs")
865            .expect("seed file indexed")
866            .last_modified_unix_ms;
867        assert!(symbol_names(&first).iter().any(|n| n == "old_symbol"));
868
869        // Add a symbol (byte size grows), then force the mtime back to the
870        // cached value to simulate a same-instant edit the OS couldn't
871        // distinguish by mtime.
872        fs::write(
873            &file,
874            "pub fn old_symbol() {}\npub fn brand_new_symbol() {}\n",
875        )
876        .unwrap();
877        let secs = cached_mtime / 1000;
878        let nanos = ((cached_mtime % 1000) * 1_000_000) as u32;
879        set_file_mtime(&file, FileTime::from_unix_time(secs, nanos)).unwrap();
880
881        let second = scan_incremental(&token, None, opts);
882        let names = symbol_names(&second);
883        assert!(
884            names.iter().any(|n| n == "brand_new_symbol"),
885            "same-mtime size-changing edit must be reindexed, got {names:?} (delta.modified={:?})",
886            second.delta.modified,
887        );
888    }
889
890    /// Companion guard: even when an edit changes nothing the scanner can
891    /// cheaply detect (same mtime AND same byte size — e.g. a length-preserving
892    /// one-character swap), passing the explicit `changed_paths` signal still
893    /// forces the reindex. The agent loop threads its own write through this
894    /// bypass, so freshness never depends on mtime/size heuristics for the
895    /// agent's own edits.
896    #[test]
897    fn scan_incremental_changed_paths_bypasses_metadata_heuristics() {
898        let dir = tempfile::tempdir().unwrap();
899        fs::create_dir_all(dir.path().join("src")).unwrap();
900        let file = dir.path().join("src/lib.rs");
901        // 23 bytes.
902        fs::write(&file, "pub fn alpha_name() {}\n").unwrap();
903
904        let canonical = std::fs::canonicalize(dir.path()).unwrap();
905        let token = canonical.to_string_lossy().to_string();
906        let opts = ScanProjectOptions::default();
907
908        let first = scan_incremental(&token, None, opts.clone());
909        let cached_mtime = first
910            .result
911            .files
912            .iter()
913            .find(|r| r.relative_path == "src/lib.rs")
914            .expect("seed file indexed")
915            .last_modified_unix_ms;
916        assert!(symbol_names(&first).iter().any(|n| n == "alpha_name"));
917
918        // Length-preserving rename (same 23 bytes), same forced mtime: neither
919        // the size nor the mtime heuristic can see this.
920        fs::write(&file, "pub fn omega_name() {}\n").unwrap();
921        let secs = cached_mtime / 1000;
922        let nanos = ((cached_mtime % 1000) * 1_000_000) as u32;
923        set_file_mtime(&file, FileTime::from_unix_time(secs, nanos)).unwrap();
924
925        // Without an explicit signal the heuristics legitimately miss this
926        // rare length-preserving same-instant case...
927        let heuristic_only = scan_incremental(&token, None, opts.clone());
928        assert!(
929            !heuristic_only
930                .delta
931                .modified
932                .contains(&"src/lib.rs".to_string()),
933            "documenting the heuristic's known blind spot",
934        );
935
936        // ...but the explicit changed-path signal the agent loop passes after
937        // its own write forces the reindex regardless.
938        let explicit = scan_incremental(&token, Some(&["src/lib.rs".to_string()]), opts);
939        assert!(
940            symbol_names(&explicit).iter().any(|n| n == "omega_name"),
941            "explicit changed_paths must always reindex, got {:?}",
942            symbol_names(&explicit),
943        );
944    }
945}