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