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