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.6.2";
32
33pub const EXTRACTION_LEVEL: &str = "facts";
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 execute_julie_extract(&[
173 "update", "--root", &root_str, "--db", &db_str, "--file", rel_path,
174 ])?;
175
176 Ok(())
177}
178
179fn scan_header_file(
180 workspace: &Workspace,
181 db_path: &Path,
182 rel_path: &str,
183) -> Result<(), SyncError> {
184 let _scan_lock = HEADER_SCAN_LOCK
186 .lock()
187 .unwrap_or_else(|poisoned| poisoned.into_inner());
188 if header_hash_matches_disk(workspace, db_path, rel_path)? {
189 return Ok(());
190 }
191 scan_workspace(workspace, db_path, false)?;
193
194 if !header_hash_matches_disk(workspace, db_path, rel_path)? {
195 return Err(SyncError::TargetNotIndexed(rel_path.to_string()));
196 }
197
198 Ok(())
199}
200
201fn header_hash_matches_disk(
202 workspace: &Workspace,
203 db_path: &Path,
204 rel_path: &str,
205) -> Result<bool, SyncError> {
206 let stored_hash = {
207 let conn = crate::db::open_read_only(db_path)?;
208 queries::get_file(&conn, rel_path)
209 .map_err(|error| match error {
210 queries::QueryError::Sqlite(error) => SyncError::Db(error),
211 _ => SyncError::TargetNotIndexed(rel_path.to_string()),
212 })?
213 .map(|file| file.content_hash)
214 };
215 let Some(stored_hash) = stored_hash else {
216 return Ok(false);
217 };
218 let disk_bytes = match std::fs::read(workspace.canonical_root.join(rel_path)) {
219 Ok(bytes) => bytes,
220 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
221 Err(error) => return Err(error.into()),
222 };
223
224 Ok(compute_content_hash_matches(&disk_bytes, &stored_hash))
225}
226
227pub fn delete_file(workspace: &Workspace, db_path: &Path, rel_path: &str) -> Result<(), SyncError> {
229 let root_str = workspace.canonical_root.to_string_lossy();
230 let db_str = db_path.to_string_lossy();
231
232 execute_julie_extract(&[
233 "delete", "--root", &root_str, "--db", &db_str, "--file", rel_path,
234 ])?;
235
236 Ok(())
237}
238
239pub fn scan_workspace(workspace: &Workspace, db_path: &Path, force: bool) -> Result<(), SyncError> {
244 let root_str = workspace.canonical_root.to_string_lossy();
245 let db_str = db_path.to_string_lossy();
246 let own_pid = std::process::id().to_string();
247
248 ensure_index_dir(db_path)?;
249
250 let scan_args = |new_artifact: bool| {
251 let mut args = vec!["scan", "--root", &*root_str, "--db", &*db_str];
252 if new_artifact {
253 args.extend(["--level", EXTRACTION_LEVEL]);
254 }
255 if cfg!(unix) {
256 args.extend(["--parent-pid", &*own_pid]);
257 }
258 if force {
259 args.push("--force");
260 }
261 args
262 };
263
264 match execute_julie_extract(&scan_args(!db_path.exists())) {
265 Ok(_) => {}
266 Err(SyncError::ExtractionFailed(_, stderr))
267 if stderr.contains("schema_incompatible") && db_path.exists() =>
268 {
269 warn!("Extractor cannot read the existing artifact; rebuilding from scratch");
270 remove_artifact_files(db_path)?;
271 execute_julie_extract(&scan_args(true))?;
272 }
273 Err(SyncError::ExtractionFailed(1, stderr))
274 if stderr.starts_with("partial") && db_path.exists() =>
275 {
276 warn!(
277 "Extractor skipped files it could not read; the next reconcile retries them:\n{stderr}"
278 );
279 }
280 Err(e) => return Err(e),
281 }
282
283 crate::db::ensure_fts_index_path(db_path).map_err(|e| {
284 error!(db = %db_path.display(), "FTS index preparation failed: {e}");
285 e
286 })?;
287
288 Ok(())
289}
290
291pub fn ensure_index_matches_extractor(
296 workspace: &Workspace,
297 db_path: &Path,
298 extractor_version: &str,
299) -> Result<bool, SyncError> {
300 if !db_path.exists() {
301 return Ok(false);
302 }
303 let metadata = |key: &str| -> Option<String> {
304 let conn = crate::db::open_read_only(db_path).ok()?;
305 conn.query_row(
306 "SELECT value FROM artifact_metadata WHERE key = ?1",
307 [key],
308 |r| r.get(0),
309 )
310 .ok()
311 };
312 let Some(recorded) = metadata("binary_version") else {
313 return Ok(false);
314 };
315 let level = metadata("index_level").unwrap_or_else(|| "full".to_string());
316 if recorded == extractor_version
317 && level == EXTRACTION_LEVEL
318 && !has_file_written_by_another_extractor(db_path, extractor_version)
319 {
320 return Ok(false);
321 }
322 if let Some(newer) = newest_writer_version(db_path, &recorded)
323 .filter(|newest| version_is_older(extractor_version, newest))
324 {
325 warn!(
326 written_by = %newer,
327 installed = %extractor_version,
328 "Index was written by a newer julie-extract than the one in use; keeping it"
329 );
330 return Ok(false);
331 }
332 info!(
333 recorded = %recorded,
334 installed = %extractor_version,
335 level = %level,
336 wanted_level = %EXTRACTION_LEVEL,
337 "Index holds rows from a different julie-extract version or level; rebuilding"
338 );
339 rebuild_index(workspace, db_path)?;
340 Ok(true)
341}
342
343fn rebuild_index(workspace: &Workspace, db_path: &Path) -> Result<(), SyncError> {
346 let rebuilt = PathBuf::from(format!("{}.rebuild", db_path.display()));
347 remove_artifact_files(&rebuilt)?;
348 let scanned = scan_workspace(workspace, &rebuilt, true).and_then(|()| {
349 let conn = crate::db::open_read_write(&rebuilt)?;
350 crate::db::checkpoint_truncate(&conn)?;
351 Ok(())
352 });
353 if let Err(e) = scanned {
354 let _ = remove_artifact_files(&rebuilt);
355 return Err(e);
356 }
357 remove_artifact_files(db_path)?;
358 for suffix in ["-wal", "-shm"] {
359 let _ = std::fs::remove_file(format!("{}{suffix}", rebuilt.display()));
360 }
361 std::fs::rename(&rebuilt, db_path)?;
362 Ok(())
363}
364
365fn newest_writer_version(db_path: &Path, recorded: &str) -> Option<String> {
367 let revisions: Vec<String> = crate::db::open_read_only(db_path)
368 .ok()
369 .and_then(|conn| {
370 let mut stmt = conn
371 .prepare("SELECT DISTINCT binary_version FROM extraction_revisions")
372 .ok()?;
373 let versions = stmt
374 .query_map([], |r| r.get(0))
375 .ok()?
376 .filter_map(Result::ok)
377 .collect();
378 Some(versions)
379 })
380 .unwrap_or_default();
381 std::iter::once(recorded.to_string())
382 .chain(revisions)
383 .filter(|version| parse_version(version).is_some())
384 .max_by_key(|version| parse_version(version))
385}
386
387fn parse_version(version: &str) -> Option<Vec<u64>> {
388 version.split('.').map(|part| part.parse().ok()).collect()
389}
390
391fn version_is_older(candidate: &str, than: &str) -> bool {
393 matches!((parse_version(candidate), parse_version(than)), (Some(a), Some(b)) if a < b)
394}
395
396fn has_file_written_by_another_extractor(db_path: &Path, extractor_version: &str) -> bool {
400 let Ok(conn) = crate::db::open_read_only(db_path) else {
401 return false;
402 };
403 conn.query_row(
404 "SELECT 1 FROM files f
405 JOIN extraction_revisions r ON r.revision_id = f.last_revision_id
406 WHERE r.binary_version != ?1
407 LIMIT 1",
408 [extractor_version],
409 |_| Ok(true),
410 )
411 .unwrap_or(false)
412}
413
414fn remove_artifact_files(db_path: &Path) -> Result<(), SyncError> {
415 for suffix in ["", "-wal", "-shm"] {
416 let sidecar = PathBuf::from(format!("{}{suffix}", db_path.display()));
417 match std::fs::remove_file(&sidecar) {
418 Ok(()) => {}
419 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
420 Err(e) => return Err(e.into()),
421 }
422 }
423 Ok(())
424}
425
426pub fn compute_content_hash_matches(disk_bytes: &[u8], stored_hash: &str) -> bool {
428 if stored_hash.starts_with("blake3:") {
429 let b3 = format!("blake3:{}", blake3::hash(disk_bytes).to_hex());
430 b3 == stored_hash
431 } else {
432 let b3 = blake3::hash(disk_bytes).to_hex().to_string();
433 if b3 == stored_hash {
434 return true;
435 }
436 let mut hasher = sha2::Sha256::new();
437 sha2::Digest::update(&mut hasher, disk_bytes);
438 let sha = hex::encode(sha2::Digest::finalize(hasher));
439 sha == stored_hash || format!("sha256:{sha}") == stored_hash
440 }
441}
442
443pub fn ensure_fresh_file(
447 workspace: &Workspace,
448 db_path: &Path,
449 conn: &Connection,
450 rel_path: &str,
451) -> Result<bool, SyncError> {
452 let abs_path = workspace.canonical_root.join(rel_path);
453 let meta = match std::fs::metadata(&abs_path) {
454 Ok(meta) => meta,
455 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
456 let existing_file = queries::get_file(conn, rel_path).map_err(|e| match e {
457 queries::QueryError::Sqlite(err) => SyncError::Db(err),
458 _ => SyncError::Db(rusqlite::Error::QueryReturnedNoRows),
459 })?;
460 if existing_file.is_some() {
461 delete_file(workspace, db_path, rel_path)?;
462 return Ok(true);
463 }
464 return Ok(false);
465 }
466 Err(error) => return Err(SyncError::Io(error)),
467 };
468
469 if meta.is_dir() {
470 return Ok(false);
471 }
472
473 let disk_bytes = meta.len() as i64;
474
475 let existing_file = queries::get_file(conn, rel_path).map_err(|e| match e {
477 queries::QueryError::Sqlite(err) => SyncError::Db(err),
478 _ => SyncError::Db(rusqlite::Error::QueryReturnedNoRows),
479 })?;
480
481 let is_dirty = match existing_file {
482 None => true, Some(f) => {
484 if f.content_bytes != disk_bytes {
486 true
487 } else {
488 let disk_content = std::fs::read(&abs_path)?;
489 !compute_content_hash_matches(&disk_content, &f.content_hash)
490 }
491 }
492 };
493
494 if is_dirty {
495 let stored_root: Option<String> = conn
496 .query_row(
497 "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
498 [],
499 |r| r.get(0),
500 )
501 .ok();
502 if let Some(r) = stored_root
503 && !crate::workspace::paths_equal(Path::new(&r), &workspace.canonical_root)
504 {
505 crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)?;
506 }
507
508 update_file(workspace, db_path, rel_path)?;
509 return Ok(true);
510 }
511
512 Ok(false)
513}
514
515#[derive(Debug, Default)]
517pub struct ReconcileReport {
518 pub added: Vec<String>,
519 pub modified: Vec<String>,
520 pub deleted: Vec<String>,
521}
522
523fn extract_error_path(err: &ignore::Error) -> Option<&Path> {
524 match err {
525 ignore::Error::WithPath { path, .. } => Some(path.as_path()),
526 ignore::Error::WithDepth { err, .. } => extract_error_path(err),
527 ignore::Error::WithLineNumber { err, .. } => extract_error_path(err),
528 ignore::Error::Loop { ancestor, .. } => Some(ancestor.as_path()),
529 _ => None,
530 }
531}
532
533pub fn create_index(workspace: &Workspace, db_path: &Path) -> Result<(), SyncError> {
539 if let Some(parent_db) = parent_repository_db(&workspace.canonical_root)
540 && copy_parent_index(workspace, db_path, &parent_db)
541 {
542 ensure_index_matches_extractor(workspace, db_path, &installed_extractor_version())?;
543 return Ok(());
544 }
545 scan_workspace(workspace, db_path, false)
546}
547
548fn ensure_index_dir(db_path: &Path) -> std::io::Result<()> {
553 let Some(dir) = db_path.parent() else {
554 return Ok(());
555 };
556 std::fs::create_dir_all(dir)?;
557 if dir.file_name().is_none_or(|name| name != ".code-kb") {
558 return Ok(());
559 }
560 let gitignore = dir.join(".gitignore");
561 if !gitignore.exists() {
562 std::fs::write(gitignore, "*\n")?;
563 }
564 Ok(())
565}
566
567fn parent_repository_db(root: &Path) -> Option<PathBuf> {
568 let git_marker = root.join(".git");
569 if !git_marker.is_file() {
570 return None;
571 }
572 let git_content = std::fs::read_to_string(&git_marker).ok()?;
573 let gitdir = git_content
574 .lines()
575 .find_map(|l| l.strip_prefix("gitdir:"))?
576 .trim();
577 let gitdir = Path::new(gitdir);
578 let mut probe = if gitdir.is_absolute() {
579 gitdir.to_path_buf()
580 } else {
581 root.join(gitdir)
582 };
583 while let Some(parent) = probe.parent() {
584 if parent == probe {
585 break;
586 }
587 if parent.join(".git").exists() {
588 let db = parent.join(".code-kb").join("artifact.db");
589 return db.exists().then_some(db);
590 }
591 probe = parent.to_path_buf();
592 }
593 None
594}
595
596fn copy_parent_index(workspace: &Workspace, db_path: &Path, parent_db: &Path) -> bool {
597 let flushed = crate::db::open_read_write(parent_db)
598 .map(|conn| crate::db::checkpoint_truncate(&conn).is_ok())
599 .unwrap_or(false);
600 if !flushed {
601 return false;
602 }
603 if ensure_index_dir(db_path).is_err() || std::fs::copy(parent_db, db_path).is_err() {
604 return false;
605 }
606 info!(from = %parent_db.display(), to = %db_path.display(), "Worktree fast-path: copied parent database, reconciling");
607 let reconciled = crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)
608 .and_then(|_| crate::db::ensure_fts_index_path(db_path))
609 .is_ok()
610 && crate::db::open_read_only(db_path)
611 .ok()
612 .and_then(|conn| reconcile_offline_edits(workspace, db_path, &conn).ok())
613 .is_some();
614 if !reconciled {
615 warn!(
616 "Failed to retarget worktree database root; removing copied db and falling back to full scan"
617 );
618 let _ = std::fs::remove_file(db_path);
619 }
620 reconciled
621}
622
623pub fn reconcile_offline_edits(
624 workspace: &Workspace,
625 db_path: &Path,
626 conn: &Connection,
627) -> Result<ReconcileReport, SyncError> {
628 let stored_root: Option<String> = conn
631 .query_row(
632 "SELECT value FROM artifact_metadata WHERE key = 'root_path'",
633 [],
634 |r| r.get(0),
635 )
636 .ok();
637 if let Some(r) = stored_root
638 && !crate::workspace::paths_equal(Path::new(&r), &workspace.canonical_root)
639 {
640 crate::db::retarget_artifact_root(db_path, &workspace.canonical_root)?;
641 }
642
643 let mut report = ReconcileReport::default();
644
645 let temp_conn = Connection::open_in_memory().map_err(SyncError::Db)?;
647 temp_conn
648 .execute(
649 "CREATE TABLE _seen (path TEXT COLLATE NOCASE PRIMARY KEY)",
650 [],
651 )
652 .map_err(SyncError::Db)?;
653
654 let mut insert_seen_stmt = temp_conn
655 .prepare("INSERT OR IGNORE INTO _seen (path) VALUES (?1)")
656 .map_err(SyncError::Db)?;
657
658 let mut check_file_stmt = conn
659 .prepare("SELECT content_bytes, content_hash FROM files WHERE path = ?1")
660 .map_err(SyncError::Db)?;
661 let mut skipped_stmt = if crate::queries::has_table(conn, "skipped_files") {
662 Some(
663 conn.prepare(
664 "SELECT 1 FROM skipped_files WHERE path = ?1 AND content_bytes = ?2 AND mtime_ns = ?3",
665 )
666 .map_err(SyncError::Db)?,
667 )
668 } else {
669 None
670 };
671
672 let mut walker = ignore::WalkBuilder::new(&workspace.canonical_root);
673 walker
674 .standard_filters(true)
675 .hidden(false)
676 .add_custom_ignore_filename(".julieignore")
677 .add_custom_ignore_filename(".code-kb-ignore")
678 .add_custom_ignore_filename(".codekbignore")
679 .filter_entry(|entry| {
680 let name = entry.file_name().to_string_lossy();
681 !crate::workspace::is_hard_excluded(&name)
682 });
683 let walker = walker.build();
684
685 temp_conn
686 .execute("BEGIN TRANSACTION", [])
687 .map_err(SyncError::Db)?;
688
689 let mut unreadable_prefixes: Vec<String> = Vec::new();
690
691 for result in walker {
692 let entry = match result {
693 Ok(e) => e,
694 Err(e) => {
695 warn!("Reconciliation walker encountered error: {e}");
696 if let Some(path) = extract_error_path(&e) {
697 let norm_path = dunce::simplified(path);
698 if let Some(rel) =
699 crate::workspace::strip_prefix_lossy(norm_path, &workspace.canonical_root)
700 {
701 unreadable_prefixes.push(crate::workspace::to_forward_slash(rel));
702 }
703 }
704 continue;
705 }
706 };
707
708 if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
709 let path = entry.path();
710 let norm_path = dunce::simplified(path);
711 if let Some(rel) =
712 crate::workspace::strip_prefix_lossy(norm_path, &workspace.canonical_root)
713 {
714 let rel_str = crate::workspace::to_forward_slash(rel);
715 if crate::workspace::is_hard_excluded(&rel_str) {
716 continue;
717 }
718 let meta = match entry.metadata() {
719 Ok(m) => m,
720 Err(e) => {
721 warn!("Failed to read metadata for '{}': {e}", path.display());
722 let _ = insert_seen_stmt.execute([&rel_str]);
723 continue;
724 }
725 };
726 let bytes = meta.len() as i64;
727
728 insert_seen_stmt
729 .execute([&rel_str])
730 .map_err(SyncError::Db)?;
731
732 let mut rows = check_file_stmt.query([&rel_str]).map_err(SyncError::Db)?;
733
734 if let Some(row) = rows.next().map_err(SyncError::Db)? {
735 let indexed_bytes: i64 = row.get(0).map_err(SyncError::Db)?;
736 let stored_hash: String = row.get(1).map_err(SyncError::Db)?;
737
738 let is_modified = if indexed_bytes != bytes {
739 true
740 } else {
741 match std::fs::read(path) {
742 Ok(content) => !compute_content_hash_matches(&content, &stored_hash),
743 Err(e) => {
744 warn!(
745 "Failed to read '{}' for hash verification: {e}",
746 path.display()
747 );
748 false
749 }
750 }
751 };
752
753 if is_modified {
754 report.modified.push(rel_str);
755 }
756 } else if !skipped_before(skipped_stmt.as_mut(), &rel_str, bytes, mtime_ns(&meta))?
757 {
758 report.added.push(rel_str);
759 }
760 }
761 }
762 }
763
764 temp_conn.execute("COMMIT", []).map_err(SyncError::Db)?;
765
766 let mut files_stmt = conn
767 .prepare("SELECT path FROM files")
768 .map_err(SyncError::Db)?;
769
770 let mut exists_seen_stmt = temp_conn
771 .prepare("SELECT 1 FROM _seen WHERE path = ?1")
772 .map_err(SyncError::Db)?;
773
774 let mut file_rows = files_stmt.query([]).map_err(SyncError::Db)?;
775 while let Some(row) = file_rows.next().map_err(SyncError::Db)? {
776 let indexed_path: String = row.get(0).map_err(SyncError::Db)?;
777
778 let in_unreadable_prefix = unreadable_prefixes.iter().any(|prefix| {
780 indexed_path == *prefix || indexed_path.starts_with(&format!("{prefix}/"))
781 });
782 if in_unreadable_prefix {
783 continue;
784 }
785
786 let mut seen_rows = exists_seen_stmt
787 .query([&indexed_path])
788 .map_err(SyncError::Db)?;
789 if seen_rows.next().map_err(SyncError::Db)?.is_none() {
790 report.deleted.push(indexed_path);
791 }
792 }
793
794 drop(file_rows);
795 drop(files_stmt);
796 drop(check_file_stmt);
797 drop(skipped_stmt);
798 drop(exists_seen_stmt);
799 drop(insert_seen_stmt);
800 drop(temp_conn);
801
802 let total_changes = report.added.len() + report.modified.len() + report.deleted.len();
803 if total_changes > 0 {
804 info!(
805 "Cold start reconciliation detected {} changes (+{}, ~{}, -{})",
806 total_changes,
807 report.added.len(),
808 report.modified.len(),
809 report.deleted.len()
810 );
811
812 let changed_paths = report.added.iter().chain(&report.modified);
813 let changed_headers = changed_paths.clone().any(|path| is_header_path(path));
814 if total_changes > 50 || changed_headers {
815 scan_workspace(workspace, db_path, false)?;
816 remember_skipped_files(workspace, db_path, conn, changed_paths)?;
817 } else {
818 let mut updated: Vec<&String> = Vec::new();
819 for added in &report.added {
820 match update_file(workspace, db_path, added) {
821 Ok(()) => updated.push(added),
822 Err(e) => warn!("Failed to index added file '{}': {e}", added),
823 }
824 }
825 for modified in &report.modified {
826 match update_file(workspace, db_path, modified) {
827 Ok(()) => updated.push(modified),
828 Err(e) => warn!("Failed to index modified file '{}': {e}", modified),
829 }
830 }
831 for deleted in &report.deleted {
832 if let Err(e) = delete_file(workspace, db_path, deleted) {
833 warn!("Failed to remove deleted file '{}': {e}", deleted);
834 }
835 }
836 remember_skipped_files(workspace, db_path, conn, updated.into_iter())?;
837 }
838 }
839
840 Ok(report)
841}
842
843const SKIPPED_FILES_DDL: &str = "CREATE TABLE IF NOT EXISTS skipped_files (
847 path TEXT PRIMARY KEY, content_bytes INTEGER NOT NULL, mtime_ns INTEGER NOT NULL)";
848
849fn mtime_ns(meta: &std::fs::Metadata) -> i64 {
850 meta.modified()
851 .ok()
852 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
853 .map_or(0, |d| d.as_nanos() as i64)
854}
855
856fn skipped_before(
857 stmt: Option<&mut rusqlite::Statement<'_>>,
858 path: &str,
859 bytes: i64,
860 mtime: i64,
861) -> Result<bool, SyncError> {
862 let Some(stmt) = stmt else {
863 return Ok(false);
864 };
865 stmt.exists(rusqlite::params![path, bytes, mtime])
866 .map_err(SyncError::Db)
867}
868
869fn remember_skipped_files<'a>(
870 workspace: &Workspace,
871 db_path: &Path,
872 conn: &Connection,
873 paths: impl Iterator<Item = &'a String>,
874) -> Result<(), SyncError> {
875 let mut indexed = conn
876 .prepare("SELECT 1 FROM files WHERE path = ?1")
877 .map_err(SyncError::Db)?;
878 let mut writer: Option<Connection> = None;
879 for path in paths {
880 if indexed.exists([path]).map_err(SyncError::Db)? {
881 continue;
882 }
883 let Ok(meta) = std::fs::metadata(workspace.canonical_root.join(path)) else {
884 continue;
885 };
886 if writer.is_none() {
887 let conn = crate::db::open_read_write(db_path)?;
888 conn.execute_batch(SKIPPED_FILES_DDL)
889 .map_err(SyncError::Db)?;
890 writer = Some(conn);
891 }
892 writer
893 .as_ref()
894 .expect("opened above")
895 .execute(
896 "INSERT OR REPLACE INTO skipped_files (path, content_bytes, mtime_ns) VALUES (?1, ?2, ?3)",
897 rusqlite::params![path, meta.len() as i64, mtime_ns(&meta)],
898 )
899 .map_err(SyncError::Db)?;
900 }
901 Ok(())
902}
903
904#[cfg(test)]
905mod tests {
906 #[test]
907 fn pinned_version_matches_the_pins_file() {
908 let pins = include_str!("../../../scripts/julie-pins.json");
909 assert!(pins.contains(&format!("\"version\": \"{}\"", super::PINNED_JULIE_VERSION)));
910 }
911
912 use super::*;
913
914 #[test]
915 fn test_find_julie_extract_binary() {
916 let path = find_julie_extract_binary()
917 .expect("julie-extract binary must be present for tests (see scripts/julie-pins.json)");
918 assert!(path.exists(), "Discovered path must exist: {:?}", path);
919 }
920
921 #[test]
922 fn ensure_index_dir_writes_self_ignoring_gitignore() {
923 let temp = crate::safe_tempdir();
924 let db_path = temp.path().join(".code-kb").join("artifact.db");
925 ensure_index_dir(&db_path).unwrap();
926 let gitignore = db_path.parent().unwrap().join(".gitignore");
927 assert_eq!(std::fs::read_to_string(&gitignore).unwrap(), "*\n");
928 std::fs::write(&gitignore, "custom\n").unwrap();
929 ensure_index_dir(&db_path).unwrap();
930 assert_eq!(std::fs::read_to_string(&gitignore).unwrap(), "custom\n");
931 }
932
933 #[test]
934 fn ensure_index_dir_leaves_non_code_kb_directories_alone() {
935 let temp = crate::safe_tempdir();
936 let db_path = temp.path().join("test.db");
937 ensure_index_dir(&db_path).unwrap();
938 assert!(!temp.path().join(".gitignore").exists());
939 }
940
941 #[test]
942 fn test_reconcile_offline_edits_drive_case_mismatch() {
943 let temp = crate::safe_tempdir();
944 let db_path = temp.path().join("test.db");
945 let conn = rusqlite::Connection::open(&db_path).unwrap();
946 conn.execute_batch(
947 "CREATE TABLE files (
948 file_id TEXT PRIMARY KEY,
949 path TEXT NOT NULL,
950 language TEXT,
951 content_hash TEXT,
952 content_bytes INTEGER,
953 line_count INTEGER,
954 indexed_at TEXT
955 );
956 CREATE TABLE symbols (
957 symbol_id TEXT PRIMARY KEY,
958 file_id TEXT,
959 path TEXT NOT NULL
960 );",
961 )
962 .unwrap();
963
964 let src_dir = temp.path().join("src");
966 std::fs::create_dir_all(&src_dir).unwrap();
967 let file_path = src_dir.join("main.rs");
968 let content = "fn main() {}\n";
969 std::fs::write(&file_path, content).unwrap();
970
971 let hash = sha2::Sha256::digest(content.as_bytes());
972 let hash_hex = hex::encode(hash);
973
974 conn.execute(
975 "INSERT INTO files VALUES ('f1', 'src/main.rs', 'rust', ?1, ?2, 1, '2026-09-14T00:00:00Z')",
976 rusqlite::params![hash_hex, content.len() as i64],
977 )
978 .unwrap();
979
980 #[allow(unused_mut)]
981 let mut ws = Workspace::new(temp.path().to_path_buf());
982 #[cfg(windows)]
983 {
984 let root_str = ws.canonical_root.to_string_lossy().to_string();
985 if let Some(first_char) = root_str.chars().next() {
986 let flipped = if first_char.is_ascii_uppercase() {
987 first_char.to_ascii_lowercase()
988 } else {
989 first_char.to_ascii_uppercase()
990 };
991 let altered_root = format!("{}{}", flipped, &root_str[1..]);
992 ws.canonical_root = PathBuf::from(altered_root);
993 }
994 }
995
996 let report = reconcile_offline_edits(&ws, &db_path, &conn).unwrap();
997 assert!(
998 report.deleted.is_empty(),
999 "Files should not be marked deleted due to drive casing difference: {:?}",
1000 report.deleted
1001 );
1002 }
1003}