Skip to main content

tessera_codegraph/
watch.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::thread;
4use std::time::Duration;
5
6use anyhow::Result;
7use sha2::{Digest, Sha256};
8use walkdir::{DirEntry, WalkDir};
9
10use crate::indexer::{self, IndexOptions};
11use crate::types::Language;
12
13#[derive(Debug, Clone)]
14pub struct WatchOptions {
15    pub poll_interval: Duration,
16    pub debounce: Duration,
17    pub index_options: IndexOptions,
18    pub once: bool,
19}
20
21impl Default for WatchOptions {
22    fn default() -> Self {
23        Self {
24            poll_interval: Duration::from_millis(500),
25            debounce: Duration::from_millis(250),
26            index_options: IndexOptions::default(),
27            once: false,
28        }
29    }
30}
31
32pub fn watch_path(root: &Path, db_path: &Path, options: WatchOptions) -> Result<()> {
33    let root = root.canonicalize().unwrap_or_else(|_| PathBuf::from(root));
34    let mut last_fingerprint = fingerprint_sources(&root)?;
35    index_once(&root, db_path, options.index_options)?;
36
37    if options.once {
38        return Ok(());
39    }
40
41    println!(
42        "[watch] watching {} every {}ms",
43        root.display(),
44        options.poll_interval.as_millis()
45    );
46
47    loop {
48        thread::sleep(options.poll_interval);
49        let current = fingerprint_sources(&root)?;
50        if current == last_fingerprint {
51            continue;
52        }
53
54        thread::sleep(options.debounce);
55        let settled = fingerprint_sources(&root)?;
56        if settled == last_fingerprint {
57            continue;
58        }
59
60        last_fingerprint = settled;
61        index_once(&root, db_path, options.index_options)?;
62    }
63}
64
65fn index_once(root: &Path, db_path: &Path, options: IndexOptions) -> Result<()> {
66    let report = indexer::index_path_with(root, db_path, options)?;
67    let mode = match report.mode {
68        indexer::IndexMode::Full => "full",
69        indexer::IndexMode::Incremental => "incremental",
70    };
71    println!(
72        "[watch:{mode}] indexed {} files (+{} reused, -{} removed), {} symbols, {} references into {} in {}ms",
73        report.files_indexed,
74        report.files_reused,
75        report.files_removed,
76        report.symbols_indexed,
77        report.references_indexed,
78        db_path.display(),
79        report.elapsed_ms
80    );
81    if !report.warnings.is_empty() {
82        eprintln!("[watch] indexed with {} warning(s)", report.warnings.len());
83        for warning in report.warnings.iter().take(5) {
84            eprintln!("  {}: {}", warning.path, warning.message);
85        }
86    }
87    Ok(())
88}
89
90fn fingerprint_sources(root: &Path) -> Result<String> {
91    let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
92    for entry in WalkDir::new(root)
93        .into_iter()
94        .filter_entry(should_enter)
95        .filter_map(Result::ok)
96        .filter(|entry| entry.file_type().is_file())
97    {
98        let path = entry.path();
99        if language_for_path(path).is_none() {
100            continue;
101        }
102        let Ok(content) = fs::read(path) else {
103            continue;
104        };
105        let rel_path = path
106            .strip_prefix(root)
107            .unwrap_or(path)
108            .to_string_lossy()
109            .replace('\\', "/");
110        entries.push((rel_path, content));
111    }
112
113    entries.sort_by(|a, b| a.0.cmp(&b.0));
114    let mut hasher = Sha256::new();
115    for (path, content) in entries {
116        hasher.update(path.as_bytes());
117        hasher.update([0]);
118        hasher.update(content);
119        hasher.update([0]);
120    }
121    Ok(format!("{:x}", hasher.finalize()))
122}
123
124fn should_enter(entry: &DirEntry) -> bool {
125    let name = entry.file_name().to_string_lossy();
126    !matches!(
127        name.as_ref(),
128        ".git"
129            | ".hg"
130            | ".svn"
131            | "node_modules"
132            | "target"
133            | "dist"
134            | "build"
135            | ".next"
136            | ".venv"
137            | "venv"
138            | "__pycache__"
139            | ".tessera"
140    )
141}
142
143fn language_for_path(path: &Path) -> Option<Language> {
144    path.extension()
145        .and_then(|ext| ext.to_str())
146        .and_then(Language::from_extension)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn fingerprint_changes_when_source_changes() {
155        let temp = tempfile::tempdir().unwrap();
156        let path = temp.path().join("app.ts");
157        fs::write(&path, "export function a() { return 1; }\n").unwrap();
158
159        let first = fingerprint_sources(temp.path()).unwrap();
160        fs::write(&path, "export function a() { return 2; }\n").unwrap();
161        let second = fingerprint_sources(temp.path()).unwrap();
162
163        assert_ne!(first, second);
164    }
165
166    #[test]
167    fn fingerprint_ignores_unindexed_files() {
168        let temp = tempfile::tempdir().unwrap();
169        fs::write(temp.path().join("README.md"), "one\n").unwrap();
170
171        let first = fingerprint_sources(temp.path()).unwrap();
172        fs::write(temp.path().join("README.md"), "two\n").unwrap();
173        let second = fingerprint_sources(temp.path()).unwrap();
174
175        assert_eq!(first, second);
176    }
177}