1use rusqlite::Connection;
2use sha2::Digest;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use thiserror::Error;
6use tracing::{error, info, warn};
7
8use crate::queries;
9use crate::workspace::Workspace;
10
11#[derive(Debug, Error)]
12pub enum SyncError {
13 #[error(
14 "julie-extract not found. Put it on PATH or set JULIE_EXTRACT_BIN. Download: https://github.com/anortham/julie-extractors/releases"
15 )]
16 BinaryNotFound,
17 #[error("Extractor failed with status {0}: {1}")]
18 ExtractionFailed(i32, String),
19 #[error("IO error during synchronization: {0}")]
20 Io(#[from] std::io::Error),
21 #[error("Database error during synchronization: {0}")]
22 Db(#[from] rusqlite::Error),
23 #[error("Database error: {0}")]
24 DbInit(#[from] crate::db::DbError),
25 #[error("workspace traversal failed: {0}")]
26 Walk(#[from] ignore::Error),
27 #[error("extractor did not index the requested file: {0}")]
28 TargetNotIndexed(String),
29}
30
31pub const PINNED_JULIE_VERSION: &str = "3.9.0";
32
33pub const EXTRACTION_LEVEL: &str = "references";
37
38static CACHED_JULIE_BIN: std::sync::OnceLock<Option<(PathBuf, String)>> =
39 std::sync::OnceLock::new();
40static HEADER_SCAN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
41
42pub fn find_julie_extract_binary() -> Option<PathBuf> {
47 installed_extractor().map(|(bin, _)| bin)
48}
49
50pub fn installed_extractor_version() -> String {
52 installed_extractor()
53 .map(|(_, version)| version)
54 .unwrap_or_else(|| PINNED_JULIE_VERSION.to_string())
55}
56
57fn installed_extractor() -> Option<(PathBuf, String)> {
58 CACHED_JULIE_BIN
59 .get_or_init(|| {
60 let candidates: Vec<(PathBuf, String)> = julie_extract_candidates()
61 .into_iter()
62 .filter_map(|bin| extractor_version(&bin).map(|version| (bin, version)))
63 .collect();
64 let pinned = candidates
65 .iter()
66 .find(|(_, version)| version == PINNED_JULIE_VERSION)
67 .cloned();
68 if pinned.is_some() {
69 return pinned;
70 }
71 let first = candidates.into_iter().next()?;
72 tracing::warn!(
73 found = %first.1,
74 pinned = %PINNED_JULIE_VERSION,
75 binary = %first.0.display(),
76 "julie-extract version differs from pinned version; AST facts may drift"
77 );
78 Some(first)
79 })
80 .clone()
81}
82
83pub(crate) fn extractor_version(bin: &Path) -> Option<String> {
84 let output = Command::new(bin).arg("--version").output().ok()?;
85 let text = String::from_utf8_lossy(&output.stdout);
86 text.split_whitespace().last().map(str::to_string)
87}
88
89fn julie_extract_candidates() -> Vec<PathBuf> {
90 let exe_name = if cfg!(windows) {
91 "julie-extract.exe"
92 } else {
93 "julie-extract"
94 };
95 let mut candidates = Vec::new();
96
97 if let Ok(path_str) = std::env::var("JULIE_EXTRACT_BIN") {
98 candidates.push(PathBuf::from(path_str));
99 }
100
101 if let Some(exe_dir) = std::env::current_exe()
104 .ok()
105 .and_then(|p| p.parent().map(|d| d.to_path_buf()))
106 {
107 candidates.push(exe_dir.join(exe_name));
108 candidates.extend(
109 exe_dir
110 .ancestors()
111 .map(|dir| dir.join(".tools").join(exe_name)),
112 );
113 }
114
115 if let Ok(p) = which::which("julie-extract") {
116 candidates.push(p);
117 }
118
119 candidates
120 .into_iter()
121 .filter(|p| p.is_file())
122 .map(|p| crate::workspace::normalize_path(&p))
123 .collect()
124}
125
126const JULIE_CACHE_KIB: &str = "-1048576";
130
131pub fn execute_julie_extract(args: &[&str]) -> Result<String, SyncError> {
134 let bin = find_julie_extract_binary().ok_or(SyncError::BinaryNotFound)?;
135 let cache_kib =
136 std::env::var("JULIE_BULK_CACHE_KIB").unwrap_or_else(|_| JULIE_CACHE_KIB.to_string());
137
138 let mut attempts = 0;
139 loop {
140 let output = Command::new(&bin)
141 .args(args)
142 .env("JULIE_BULK_CACHE_KIB", &cache_kib)
143 .output()
144 .map_err(SyncError::Io)?;
145
146 if output.status.success() {
147 return Ok(String::from_utf8_lossy(&output.stdout).to_string());
148 }
149
150 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
151 if (stderr.contains("database is locked")
153 || stderr.contains("busy")
154 || stderr.contains("SQLITE_BUSY"))
155 && attempts < 5
156 {
157 attempts += 1;
158 std::thread::sleep(std::time::Duration::from_millis(50 * (1 << attempts)));
159 continue;
160 }
161
162 let code = output.status.code().unwrap_or(-1);
163 return Err(SyncError::ExtractionFailed(code, stderr));
164 }
165}
166
167fn is_header_path(rel_path: &str) -> bool {
168 Path::new(rel_path)
169 .extension()
170 .is_some_and(|extension| extension.eq_ignore_ascii_case("h"))
171}
172
173pub fn update_file(workspace: &Workspace, db_path: &Path, rel_path: &str) -> Result<(), SyncError> {
175 if db_path.is_file() && is_header_path(rel_path) {
176 return scan_header_file(workspace, db_path, rel_path);
177 }
178
179 let root_str = workspace.canonical_root.to_string_lossy();
180 let db_str = db_path.to_string_lossy();
181
182 write_incrementally(
183 workspace,
184 db_path,
185 &[
186 "update", "--root", &root_str, "--db", &db_str, "--file", rel_path,
187 ],
188 )
189}
190
191fn write_incrementally(
195 workspace: &Workspace,
196 db_path: &Path,
197 args: &[&str],
198) -> Result<(), SyncError> {
199 match execute_julie_extract(args) {
200 Err(SyncError::ExtractionFailed(_, stderr)) if is_fingerprint_mismatch(&stderr) => {
201 warn!("Index was written by another julie-extract build; rescanning it");
202 scan_workspace(workspace, db_path, true)
203 }
204 Err(error) => Err(error),
205 Ok(_) => crate::db::link_impl_owners_path(db_path).map_err(|e| {
206 error!(db = %db_path.display(), "Linking impl blocks to their types failed: {e}");
207 e.into()
208 }),
209 }
210}
211
212fn is_fingerprint_mismatch(stderr: &str) -> bool {
213 stderr.contains("fingerprint_mismatch")
214}
215
216fn needs_new_artifact(stderr: &str) -> bool {
219 stderr.contains("schema_incompatible") || stderr.contains("schema_migration_required")
220}
221
222fn scan_header_file(
223 workspace: &Workspace,
224 db_path: &Path,
225 rel_path: &str,
226) -> Result<(), SyncError> {
227 let _scan_lock = HEADER_SCAN_LOCK
229 .lock()
230 .unwrap_or_else(|poisoned| poisoned.into_inner());
231 if header_hash_matches_disk(workspace, db_path, rel_path)? {
232 return Ok(());
233 }
234 scan_workspace(workspace, db_path, false)?;
236
237 if !header_hash_matches_disk(workspace, db_path, rel_path)? {
238 return Err(SyncError::TargetNotIndexed(rel_path.to_string()));
239 }
240
241 Ok(())
242}
243
244fn header_hash_matches_disk(
245 workspace: &Workspace,
246 db_path: &Path,
247 rel_path: &str,
248) -> Result<bool, SyncError> {
249 let stored_hash = {
250 let conn = crate::db::open_read_only(db_path)?;
251 queries::get_file(&conn, rel_path)
252 .map_err(|error| match error {
253 queries::QueryError::Sqlite(error) => SyncError::Db(error),
254 _ => SyncError::TargetNotIndexed(rel_path.to_string()),
255 })?
256 .map(|file| file.content_hash)
257 };
258 let Some(stored_hash) = stored_hash else {
259 return Ok(false);
260 };
261 let disk_bytes = match std::fs::read(workspace.canonical_root.join(rel_path)) {
262 Ok(bytes) => bytes,
263 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
264 Err(error) => return Err(error.into()),
265 };
266
267 Ok(compute_content_hash_matches(&disk_bytes, &stored_hash))
268}
269
270pub fn delete_file(workspace: &Workspace, db_path: &Path, rel_path: &str) -> Result<(), SyncError> {
272 let root_str = workspace.canonical_root.to_string_lossy();
273 let db_str = db_path.to_string_lossy();
274
275 write_incrementally(
276 workspace,
277 db_path,
278 &[
279 "delete", "--root", &root_str, "--db", &db_str, "--file", rel_path,
280 ],
281 )
282}
283
284pub fn scan_workspace(workspace: &Workspace, db_path: &Path, force: bool) -> Result<(), SyncError> {
289 let root_str = workspace.canonical_root.to_string_lossy();
290 let db_str = db_path.to_string_lossy();
291 let own_pid = std::process::id().to_string();
292
293 ensure_index_dir(db_path)?;
294 let excludes = write_hard_excludes()?;
295 let excludes_str = excludes.path().to_string_lossy();
296
297 let scan_args = |new_artifact: bool| {
298 let mut args = vec![
299 "scan",
300 "--root",
301 &*root_str,
302 "--db",
303 &*db_str,
304 "--ignore-file",
305 &*excludes_str,
306 ];
307 if new_artifact {
308 args.extend(["--level", EXTRACTION_LEVEL]);
309 }
310 if cfg!(unix) {
311 args.extend(["--parent-pid", &*own_pid]);
312 }
313 if force {
314 args.push("--force");
315 }
316 args
317 };
318
319 match execute_julie_extract(&scan_args(!db_path.exists())) {
320 Ok(_) => {}
321 Err(SyncError::ExtractionFailed(_, stderr))
322 if needs_new_artifact(&stderr) && db_path.exists() =>
323 {
324 warn!("Extractor cannot use the existing artifact; rebuilding from scratch");
325 remove_artifact_files(db_path)?;
326 execute_julie_extract(&scan_args(true))?;
327 }
328 Err(SyncError::ExtractionFailed(_, stderr))
329 if is_fingerprint_mismatch(&stderr) && !force && db_path.exists() =>
330 {
331 warn!("Index was written by another julie-extract build; rescanning it");
332 return scan_workspace(workspace, db_path, true);
333 }
334 Err(SyncError::ExtractionFailed(1, stderr))
335 if stderr.starts_with("partial") && db_path.exists() =>
336 {
337 warn!(
338 "Extractor skipped files it could not read; the next reconcile retries them:\n{stderr}"
339 );
340 }
341 Err(e) => return Err(e),
342 }
343
344 crate::db::ensure_fts_index_path(db_path).map_err(|e| {
345 error!(db = %db_path.display(), "FTS index preparation failed: {e}");
346 e
347 })?;
348
349 Ok(())
350}
351
352pub fn ensure_index_matches_extractor(
357 workspace: &Workspace,
358 db_path: &Path,
359 extractor_version: &str,
360) -> Result<bool, SyncError> {
361 if !db_path.exists() {
362 return Ok(false);
363 }
364 let _rebuild_lock = std::fs::File::create(format!("{}.rebuild.lock", db_path.display()))
367 .and_then(|file| file.lock().map(|()| file))
368 .map_err(|e| warn!(db = %db_path.display(), "Index rebuild lock unavailable: {e}"))
369 .ok();
370 let Some((recorded, level)) = other_writer(db_path, extractor_version) else {
371 return Ok(false);
372 };
373 if let Some(newer) = newest_writer_version(db_path, &recorded)
374 .filter(|newest| version_is_older(extractor_version, newest))
375 {
376 warn!(
377 written_by = %newer,
378 installed = %extractor_version,
379 "Index was written by a newer julie-extract than the one in use; keeping it"
380 );
381 return Ok(false);
382 }
383 info!(
384 recorded = %recorded,
385 installed = %extractor_version,
386 level = %level,
387 wanted_level = %EXTRACTION_LEVEL,
388 "Index holds rows from a different julie-extract version or level; rebuilding"
389 );
390 rebuild_index(workspace, db_path)?;
391 Ok(true)
392}
393
394pub fn index_written_by_other_extractor(db_path: &Path, extractor_version: &str) -> bool {
398 other_writer(db_path, extractor_version).is_some()
399}
400
401fn other_writer(db_path: &Path, extractor_version: &str) -> Option<(String, String)> {
403 let metadata = |key: &str| -> Option<String> {
404 let conn = crate::db::open_read_only(db_path).ok()?;
405 conn.query_row(
406 "SELECT value FROM artifact_metadata WHERE key = ?1",
407 [key],
408 |r| r.get(0),
409 )
410 .ok()
411 };
412 let recorded = metadata("binary_version")?;
413 let level = metadata("index_level").unwrap_or_else(|| "full".to_string());
414 let current = recorded == extractor_version
415 && level == EXTRACTION_LEVEL
416 && !has_file_written_by_another_extractor(db_path, extractor_version);
417 (!current).then_some((recorded, level))
418}
419
420fn rebuild_index(workspace: &Workspace, db_path: &Path) -> Result<(), SyncError> {
423 let rebuilt = PathBuf::from(format!("{}.rebuild", db_path.display()));
424 remove_artifact_files(&rebuilt)?;
425 let scanned = scan_workspace(workspace, &rebuilt, true).and_then(|()| {
426 let conn = crate::db::open_read_write(&rebuilt)?;
427 crate::db::checkpoint_truncate(&conn)?;
428 Ok(())
429 });
430 if let Err(e) = scanned {
431 let _ = remove_artifact_files(&rebuilt);
432 return Err(e);
433 }
434 remove_artifact_files(db_path)?;
435 for suffix in ["-wal", "-shm"] {
436 let _ = std::fs::remove_file(format!("{}{suffix}", rebuilt.display()));
437 }
438 std::fs::rename(&rebuilt, db_path)?;
439 Ok(())
440}
441
442fn newest_writer_version(db_path: &Path, recorded: &str) -> Option<String> {
444 let revisions: Vec<String> = crate::db::open_read_only(db_path)
445 .ok()
446 .and_then(|conn| {
447 let mut stmt = conn
448 .prepare("SELECT DISTINCT binary_version FROM extraction_revisions")
449 .ok()?;
450 let versions = stmt
451 .query_map([], |r| r.get(0))
452 .ok()?
453 .filter_map(Result::ok)
454 .collect();
455 Some(versions)
456 })
457 .unwrap_or_default();
458 std::iter::once(recorded.to_string())
459 .chain(revisions)
460 .filter(|version| parse_version(version).is_some())
461 .max_by_key(|version| parse_version(version))
462}
463
464fn parse_version(version: &str) -> Option<Vec<u64>> {
465 version.split('.').map(|part| part.parse().ok()).collect()
466}
467
468fn version_is_older(candidate: &str, than: &str) -> bool {
470 matches!((parse_version(candidate), parse_version(than)), (Some(a), Some(b)) if a < b)
471}
472
473fn has_file_written_by_another_extractor(db_path: &Path, extractor_version: &str) -> bool {
477 let Ok(conn) = crate::db::open_read_only(db_path) else {
478 return false;
479 };
480 conn.query_row(
481 "SELECT 1 FROM files f
482 JOIN extraction_revisions r ON r.revision_id = f.last_revision_id
483 WHERE r.binary_version != ?1
484 LIMIT 1",
485 [extractor_version],
486 |_| Ok(true),
487 )
488 .unwrap_or(false)
489}
490
491fn remove_artifact_files(db_path: &Path) -> Result<(), SyncError> {
492 for suffix in ["", "-wal", "-shm"] {
493 let sidecar = PathBuf::from(format!("{}{suffix}", db_path.display()));
494 match std::fs::remove_file(&sidecar) {
495 Ok(()) => {}
496 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
497 Err(e) => return Err(e.into()),
498 }
499 }
500 Ok(())
501}
502
503pub fn compute_content_hash_matches(disk_bytes: &[u8], stored_hash: &str) -> bool {
505 if stored_hash.starts_with("blake3:") {
506 let b3 = format!("blake3:{}", blake3::hash(disk_bytes).to_hex());
507 b3 == stored_hash
508 } else {
509 let b3 = blake3::hash(disk_bytes).to_hex().to_string();
510 if b3 == stored_hash {
511 return true;
512 }
513 let mut hasher = sha2::Sha256::new();
514 sha2::Digest::update(&mut hasher, disk_bytes);
515 let sha = hex::encode(sha2::Digest::finalize(hasher));
516 sha == stored_hash || format!("sha256:{sha}") == stored_hash
517 }
518}
519
520pub fn ensure_fresh_file(
524 workspace: &Workspace,
525 db_path: &Path,
526 conn: &Connection,
527 rel_path: &str,
528) -> Result<bool, SyncError> {
529 let abs_path = workspace.canonical_root.join(rel_path);
530 let meta = match std::fs::metadata(&abs_path) {
531 Ok(meta) => meta,
532 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
533 let existing_file = queries::get_file(conn, rel_path).map_err(|e| match e {
534 queries::QueryError::Sqlite(err) => SyncError::Db(err),
535 _ => SyncError::Db(rusqlite::Error::QueryReturnedNoRows),
536 })?;
537 if existing_file.is_some() {
538 delete_file(workspace, db_path, rel_path)?;
539 return Ok(true);
540 }
541 return Ok(false);
542 }
543 Err(error) => return Err(SyncError::Io(error)),
544 };
545
546 if meta.is_dir() {
547 return Ok(false);
548 }
549
550 let disk_bytes = meta.len() as i64;
551
552 let existing_file = queries::get_file(conn, rel_path).map_err(|e| match e {
554 queries::QueryError::Sqlite(err) => SyncError::Db(err),
555 _ => SyncError::Db(rusqlite::Error::QueryReturnedNoRows),
556 })?;
557
558 let is_dirty = match existing_file {
559 None => true, Some(f) => {
561 if f.content_bytes != disk_bytes {
563 true
564 } else {
565 let disk_content = std::fs::read(&abs_path)?;
566 !compute_content_hash_matches(&disk_content, &f.content_hash)
567 }
568 }
569 };
570
571 if is_dirty {
572 let stored_root: Option<String> = conn
573 .query_row(
574 "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
575 [],
576 |r| r.get(0),
577 )
578 .ok();
579 if let Some(r) = stored_root
580 && !crate::workspace::paths_equal(Path::new(&r), &workspace.canonical_root)
581 {
582 crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)?;
583 }
584
585 update_file(workspace, db_path, rel_path)?;
586 return Ok(true);
587 }
588
589 Ok(false)
590}
591
592#[derive(Debug, Default)]
594pub struct ReconcileReport {
595 pub added: Vec<String>,
596 pub modified: Vec<String>,
597 pub deleted: Vec<String>,
598}
599
600fn extract_error_path(err: &ignore::Error) -> Option<&Path> {
601 match err {
602 ignore::Error::WithPath { path, .. } => Some(path.as_path()),
603 ignore::Error::WithDepth { err, .. } => extract_error_path(err),
604 ignore::Error::WithLineNumber { err, .. } => extract_error_path(err),
605 ignore::Error::Loop { ancestor, .. } => Some(ancestor.as_path()),
606 _ => None,
607 }
608}
609
610pub fn create_index(workspace: &Workspace, db_path: &Path) -> Result<(), SyncError> {
616 if let Some(parent_db) = parent_repository_db(&workspace.canonical_root)
617 && copy_parent_index(workspace, db_path, &parent_db)
618 {
619 ensure_index_matches_extractor(workspace, db_path, &installed_extractor_version())?;
620 return Ok(());
621 }
622 scan_workspace(workspace, db_path, false)
623}
624
625fn write_hard_excludes() -> std::io::Result<tempfile::NamedTempFile> {
628 use std::io::Write;
629 let dirs = crate::workspace::HARD_EXCLUDED_DIRS
630 .iter()
631 .map(|dir| format!("{dir}/\n"));
632 let suffixes = crate::workspace::HARD_EXCLUDED_SUFFIXES
633 .iter()
634 .map(|suffix| format!("*{suffix}\n"));
635 let mut file = tempfile::NamedTempFile::new()?;
636 file.write_all(dirs.chain(suffixes).collect::<String>().as_bytes())?;
637 file.flush()?;
638 Ok(file)
639}
640
641pub(crate) fn ensure_index_dir(db_path: &Path) -> std::io::Result<()> {
646 let Some(dir) = db_path.parent() else {
647 return Ok(());
648 };
649 std::fs::create_dir_all(dir)?;
650 if dir.file_name().is_none_or(|name| name != ".code-kb") {
651 return Ok(());
652 }
653 let gitignore = dir.join(".gitignore");
654 if !gitignore.exists() {
655 std::fs::write(gitignore, "*\n")?;
656 }
657 Ok(())
658}
659
660fn parent_repository_db(root: &Path) -> Option<PathBuf> {
661 let git_marker = root.join(".git");
662 if !git_marker.is_file() {
663 return None;
664 }
665 let git_content = std::fs::read_to_string(&git_marker).ok()?;
666 let gitdir = git_content
667 .lines()
668 .find_map(|l| l.strip_prefix("gitdir:"))?
669 .trim();
670 let gitdir = Path::new(gitdir);
671 let mut probe = if gitdir.is_absolute() {
672 gitdir.to_path_buf()
673 } else {
674 root.join(gitdir)
675 };
676 while let Some(parent) = probe.parent() {
677 if parent == probe {
678 break;
679 }
680 if parent.join(".git").exists() {
681 let db = parent.join(".code-kb").join("artifact.db");
682 return db.exists().then_some(db);
683 }
684 probe = parent.to_path_buf();
685 }
686 None
687}
688
689fn copy_parent_index(workspace: &Workspace, db_path: &Path, parent_db: &Path) -> bool {
690 let flushed = crate::db::open_read_write(parent_db)
691 .map(|conn| crate::db::checkpoint_truncate(&conn).is_ok())
692 .unwrap_or(false);
693 if !flushed {
694 return false;
695 }
696 if ensure_index_dir(db_path).is_err() || std::fs::copy(parent_db, db_path).is_err() {
697 return false;
698 }
699 info!(from = %parent_db.display(), to = %db_path.display(), "Worktree fast-path: copied parent database, reconciling");
700 let reconciled = crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)
701 .and_then(|_| crate::db::ensure_fts_index_path(db_path))
702 .is_ok()
703 && crate::db::open_read_only(db_path)
704 .ok()
705 .and_then(|conn| reconcile_offline_edits(workspace, db_path, &conn).ok())
706 .is_some();
707 if !reconciled {
708 warn!(
709 "Failed to retarget worktree database root; removing copied db and falling back to full scan"
710 );
711 let _ = std::fs::remove_file(db_path);
712 }
713 reconciled
714}
715
716pub fn reconcile_offline_edits(
717 workspace: &Workspace,
718 db_path: &Path,
719 conn: &Connection,
720) -> Result<ReconcileReport, SyncError> {
721 let stored_root: Option<String> = conn
724 .query_row(
725 "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
726 [],
727 |r| r.get(0),
728 )
729 .ok();
730 if let Some(r) = stored_root
731 && !crate::workspace::paths_equal(Path::new(&r), &workspace.canonical_root)
732 {
733 crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)?;
734 }
735
736 let mut report = ReconcileReport::default();
737
738 let temp_conn = Connection::open_in_memory().map_err(SyncError::Db)?;
740 temp_conn
741 .execute(
742 "CREATE TABLE _seen (path TEXT COLLATE NOCASE PRIMARY KEY)",
743 [],
744 )
745 .map_err(SyncError::Db)?;
746
747 let mut insert_seen_stmt = temp_conn
748 .prepare("INSERT OR IGNORE INTO _seen (path) VALUES (?1)")
749 .map_err(SyncError::Db)?;
750
751 let mut check_file_stmt = conn
752 .prepare("SELECT content_bytes, content_hash FROM files WHERE path = ?1")
753 .map_err(SyncError::Db)?;
754 let mut skipped_stmt = if crate::queries::has_table(conn, "skipped_files") {
755 Some(
756 conn.prepare(
757 "SELECT 1 FROM skipped_files WHERE path = ?1 AND content_bytes = ?2 AND mtime_ns = ?3",
758 )
759 .map_err(SyncError::Db)?,
760 )
761 } else {
762 None
763 };
764
765 let mut walker = ignore::WalkBuilder::new(&workspace.canonical_root);
766 walker
767 .standard_filters(true)
768 .hidden(false)
769 .add_custom_ignore_filename(".julieignore")
770 .add_custom_ignore_filename(".code-kb-ignore")
771 .add_custom_ignore_filename(".codekbignore")
772 .filter_entry(|entry| {
773 let name = entry.file_name().to_string_lossy();
774 !crate::workspace::is_hard_excluded(&name)
775 });
776 let walker = walker.build();
777
778 temp_conn
779 .execute("BEGIN TRANSACTION", [])
780 .map_err(SyncError::Db)?;
781
782 let mut unreadable_prefixes: Vec<String> = Vec::new();
783
784 for result in walker {
785 let entry = match result {
786 Ok(e) => e,
787 Err(e) => {
788 warn!("Reconciliation walker encountered error: {e}");
789 if let Some(path) = extract_error_path(&e) {
790 let norm_path = dunce::simplified(path);
791 if let Some(rel) =
792 crate::workspace::strip_prefix_lossy(norm_path, &workspace.canonical_root)
793 {
794 unreadable_prefixes.push(crate::workspace::to_forward_slash(rel));
795 }
796 }
797 continue;
798 }
799 };
800
801 if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
802 let path = entry.path();
803 let norm_path = dunce::simplified(path);
804 if let Some(rel) =
805 crate::workspace::strip_prefix_lossy(norm_path, &workspace.canonical_root)
806 {
807 let rel_str = crate::workspace::to_forward_slash(rel);
808 if crate::workspace::is_hard_excluded(&rel_str) {
809 continue;
810 }
811 let meta = match entry.metadata() {
812 Ok(m) => m,
813 Err(e) => {
814 warn!("Failed to read metadata for '{}': {e}", path.display());
815 let _ = insert_seen_stmt.execute([&rel_str]);
816 continue;
817 }
818 };
819 let bytes = meta.len() as i64;
820
821 insert_seen_stmt
822 .execute([&rel_str])
823 .map_err(SyncError::Db)?;
824
825 let mut rows = check_file_stmt.query([&rel_str]).map_err(SyncError::Db)?;
826
827 if let Some(row) = rows.next().map_err(SyncError::Db)? {
828 let indexed_bytes: i64 = row.get(0).map_err(SyncError::Db)?;
829 let stored_hash: String = row.get(1).map_err(SyncError::Db)?;
830
831 let is_modified = if indexed_bytes != bytes {
832 true
833 } else {
834 match std::fs::read(path) {
835 Ok(content) => !compute_content_hash_matches(&content, &stored_hash),
836 Err(e) => {
837 warn!(
838 "Failed to read '{}' for hash verification: {e}",
839 path.display()
840 );
841 false
842 }
843 }
844 };
845
846 if is_modified {
847 report.modified.push(rel_str);
848 }
849 } else if !skipped_before(skipped_stmt.as_mut(), &rel_str, bytes, mtime_ns(&meta))?
850 {
851 report.added.push(rel_str);
852 }
853 }
854 }
855 }
856
857 temp_conn.execute("COMMIT", []).map_err(SyncError::Db)?;
858
859 let mut files_stmt = conn
860 .prepare("SELECT path FROM files")
861 .map_err(SyncError::Db)?;
862
863 let mut exists_seen_stmt = temp_conn
864 .prepare("SELECT 1 FROM _seen WHERE path = ?1")
865 .map_err(SyncError::Db)?;
866
867 let mut file_rows = files_stmt.query([]).map_err(SyncError::Db)?;
868 while let Some(row) = file_rows.next().map_err(SyncError::Db)? {
869 let indexed_path: String = row.get(0).map_err(SyncError::Db)?;
870
871 let in_unreadable_prefix = unreadable_prefixes.iter().any(|prefix| {
873 indexed_path == *prefix || indexed_path.starts_with(&format!("{prefix}/"))
874 });
875 if in_unreadable_prefix {
876 continue;
877 }
878
879 let mut seen_rows = exists_seen_stmt
880 .query([&indexed_path])
881 .map_err(SyncError::Db)?;
882 if seen_rows.next().map_err(SyncError::Db)?.is_none() {
883 report.deleted.push(indexed_path);
884 }
885 }
886
887 drop(file_rows);
888 drop(files_stmt);
889 drop(check_file_stmt);
890 drop(skipped_stmt);
891 drop(exists_seen_stmt);
892 drop(insert_seen_stmt);
893 drop(temp_conn);
894
895 let total_changes = report.added.len() + report.modified.len() + report.deleted.len();
896 if total_changes > 0 {
897 info!(
898 "Cold start reconciliation detected {} changes (+{}, ~{}, -{})",
899 total_changes,
900 report.added.len(),
901 report.modified.len(),
902 report.deleted.len()
903 );
904
905 let changed_paths = report.added.iter().chain(&report.modified);
906 let changed_headers = changed_paths.clone().any(|path| is_header_path(path));
907 if total_changes > 50 || changed_headers {
908 scan_workspace(workspace, db_path, false)?;
909 remember_skipped_files(workspace, db_path, conn, changed_paths)?;
910 } else {
911 let mut updated: Vec<&String> = Vec::new();
912 for added in &report.added {
913 match update_file(workspace, db_path, added) {
914 Ok(()) => updated.push(added),
915 Err(e) => warn!("Failed to index added file '{}': {e}", added),
916 }
917 }
918 for modified in &report.modified {
919 match update_file(workspace, db_path, modified) {
920 Ok(()) => updated.push(modified),
921 Err(e) => warn!("Failed to index modified file '{}': {e}", modified),
922 }
923 }
924 for deleted in &report.deleted {
925 if let Err(e) = delete_file(workspace, db_path, deleted) {
926 warn!("Failed to remove deleted file '{}': {e}", deleted);
927 }
928 }
929 remember_skipped_files(workspace, db_path, conn, updated.into_iter())?;
930 }
931 }
932
933 Ok(report)
934}
935
936const SKIPPED_FILES_DDL: &str = "CREATE TABLE IF NOT EXISTS skipped_files (
940 path TEXT PRIMARY KEY, content_bytes INTEGER NOT NULL, mtime_ns INTEGER NOT NULL)";
941
942fn mtime_ns(meta: &std::fs::Metadata) -> i64 {
943 meta.modified()
944 .ok()
945 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
946 .map_or(0, |d| d.as_nanos() as i64)
947}
948
949fn skipped_before(
950 stmt: Option<&mut rusqlite::Statement<'_>>,
951 path: &str,
952 bytes: i64,
953 mtime: i64,
954) -> Result<bool, SyncError> {
955 let Some(stmt) = stmt else {
956 return Ok(false);
957 };
958 stmt.exists(rusqlite::params![path, bytes, mtime])
959 .map_err(SyncError::Db)
960}
961
962fn remember_skipped_files<'a>(
963 workspace: &Workspace,
964 db_path: &Path,
965 conn: &Connection,
966 paths: impl Iterator<Item = &'a String>,
967) -> Result<(), SyncError> {
968 let mut indexed = conn
969 .prepare("SELECT 1 FROM files WHERE path = ?1")
970 .map_err(SyncError::Db)?;
971 let mut writer: Option<Connection> = None;
972 for path in paths {
973 if indexed.exists([path]).map_err(SyncError::Db)? {
974 continue;
975 }
976 let Ok(meta) = std::fs::metadata(workspace.canonical_root.join(path)) else {
977 continue;
978 };
979 if writer.is_none() {
980 let conn = crate::db::open_read_write(db_path)?;
981 conn.execute_batch(SKIPPED_FILES_DDL)
982 .map_err(SyncError::Db)?;
983 writer = Some(conn);
984 }
985 writer
986 .as_ref()
987 .expect("opened above")
988 .execute(
989 "INSERT OR REPLACE INTO skipped_files (path, content_bytes, mtime_ns) VALUES (?1, ?2, ?3)",
990 rusqlite::params![path, meta.len() as i64, mtime_ns(&meta)],
991 )
992 .map_err(SyncError::Db)?;
993 }
994 Ok(())
995}
996
997#[cfg(test)]
998mod tests {
999 #[test]
1000 fn pinned_version_matches_the_pins_file() {
1001 let pins = include_str!("../../../scripts/julie-pins.json");
1002 assert!(pins.contains(&format!("\"version\": \"{}\"", super::PINNED_JULIE_VERSION)));
1003 }
1004
1005 use super::*;
1006
1007 #[test]
1008 fn test_find_julie_extract_binary() {
1009 let path = find_julie_extract_binary()
1010 .expect("julie-extract binary must be present for tests (see scripts/julie-pins.json)");
1011 assert!(path.exists(), "Discovered path must exist: {:?}", path);
1012 }
1013
1014 #[test]
1015 fn ensure_index_dir_writes_self_ignoring_gitignore() {
1016 let temp = crate::safe_tempdir();
1017 let db_path = temp.path().join(".code-kb").join("artifact.db");
1018 ensure_index_dir(&db_path).unwrap();
1019 let gitignore = db_path.parent().unwrap().join(".gitignore");
1020 assert_eq!(std::fs::read_to_string(&gitignore).unwrap(), "*\n");
1021 std::fs::write(&gitignore, "custom\n").unwrap();
1022 ensure_index_dir(&db_path).unwrap();
1023 assert_eq!(std::fs::read_to_string(&gitignore).unwrap(), "custom\n");
1024 }
1025
1026 #[test]
1027 fn ensure_index_dir_leaves_non_code_kb_directories_alone() {
1028 let temp = crate::safe_tempdir();
1029 let db_path = temp.path().join("test.db");
1030 ensure_index_dir(&db_path).unwrap();
1031 assert!(!temp.path().join(".gitignore").exists());
1032 }
1033
1034 #[test]
1035 fn test_reconcile_offline_edits_drive_case_mismatch() {
1036 let temp = crate::safe_tempdir();
1037 let db_path = temp.path().join("test.db");
1038 let conn = rusqlite::Connection::open(&db_path).unwrap();
1039 conn.execute_batch(
1040 "CREATE TABLE files (
1041 file_id TEXT PRIMARY KEY,
1042 path TEXT NOT NULL,
1043 language TEXT,
1044 content_hash TEXT,
1045 content_bytes INTEGER,
1046 line_count INTEGER,
1047 indexed_at TEXT
1048 );
1049 CREATE TABLE symbols (
1050 symbol_id TEXT PRIMARY KEY,
1051 file_id TEXT,
1052 path TEXT NOT NULL
1053 );",
1054 )
1055 .unwrap();
1056
1057 let src_dir = temp.path().join("src");
1059 std::fs::create_dir_all(&src_dir).unwrap();
1060 let file_path = src_dir.join("main.rs");
1061 let content = "fn main() {}\n";
1062 std::fs::write(&file_path, content).unwrap();
1063
1064 let hash = sha2::Sha256::digest(content.as_bytes());
1065 let hash_hex = hex::encode(hash);
1066
1067 conn.execute(
1068 "INSERT INTO files VALUES ('f1', 'src/main.rs', 'rust', ?1, ?2, 1, '2026-09-14T00:00:00Z')",
1069 rusqlite::params![hash_hex, content.len() as i64],
1070 )
1071 .unwrap();
1072
1073 #[allow(unused_mut)]
1074 let mut ws = Workspace::new(temp.path().to_path_buf());
1075 #[cfg(windows)]
1076 {
1077 let root_str = ws.canonical_root.to_string_lossy().to_string();
1078 if let Some(first_char) = root_str.chars().next() {
1079 let flipped = if first_char.is_ascii_uppercase() {
1080 first_char.to_ascii_lowercase()
1081 } else {
1082 first_char.to_ascii_uppercase()
1083 };
1084 let altered_root = format!("{}{}", flipped, &root_str[1..]);
1085 ws.canonical_root = PathBuf::from(altered_root);
1086 }
1087 }
1088
1089 let report = reconcile_offline_edits(&ws, &db_path, &conn).unwrap();
1090 assert!(
1091 report.deleted.is_empty(),
1092 "Files should not be marked deleted due to drive casing difference: {:?}",
1093 report.deleted
1094 );
1095 }
1096}