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