tessera_codegraph/
watch.rs1use 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}