Skip to main content

code_kb_core/
sync.rs

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
31/// Extraction level code-kb asks for on a new artifact: symbol core plus structural facts,
32/// without the identifier, literal, and source-region tables code-kb never reads.
33pub const EXTRACTION_LEVEL: &str = "facts";
34
35static CACHED_JULIE_BIN: std::sync::OnceLock<Option<(PathBuf, String)>> =
36    std::sync::OnceLock::new();
37
38/// Discovers the `julie-extract` binary and caches the result.
39///
40/// The first candidate whose version matches the pin wins. When none matches, the first
41/// candidate found is used and a warning is logged.
42pub fn find_julie_extract_binary() -> Option<PathBuf> {
43    installed_extractor().map(|(bin, _)| bin)
44}
45
46/// Version reported by the `julie-extract` binary in use, or the pin when none was found.
47pub 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
127/// Run julie-extract command with arguments.
128pub 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        // Retry on database lock / busy collisions with backoff
144        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
159/// Tier 1 & Incremental Update: updates a single file in the database.
160pub 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
171/// Delete a file's extraction records from the database.
172pub 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
183/// Initial or full scan to build/refresh the database.
184///
185/// An artifact the extractor cannot read (empty, torn, or older schema) is removed and
186/// rebuilt from scratch.
187pub 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
235/// Rebuilds the index when it was written by a `julie-extract` other than the one in use or
236/// at another extraction level. Returns `true` when a rebuild ran. The old artifact is removed first because the
237/// extractor refuses to write into an artifact with an older schema.
238pub 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
277/// A `julie-extract update` run by a newer binary stamps its version into `artifact_metadata`
278/// but leaves every unchanged file's rows as the older binary wrote them, so the guard also
279/// checks the revision that last wrote each file.
280fn 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
307/// Check if disk content matches the stored hash in the database.
308pub 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
324/// Tier 2: Just-In-Time Staleness Guard
325/// Checks if a file on disk has been modified since it was indexed in SQLite.
326/// If dirty or missing from index, re-extracts the file synchronously (< 5ms).
327pub 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    // Look up file in SQLite files table
357    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, // Not yet in index
364        Some(f) => {
365            // Fast check: byte count difference
366            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/// Cold-Start Reconciliation Report
397#[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
414/// Cold-start background sweep: checks filesystem against SQLite `files` records.
415/// Streams disk checks and uses an in-memory SQLite index to eliminate repository-wide heap HashMaps.
416/// Creates a missing index. A git worktree copies its parent repository's index and
417/// reconciles it against the worktree files, which is much faster than a full scan;
418/// anything else runs a full scan.
419pub 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
428/// Creates the index directory. A `.code-kb` directory also gets a `.gitignore` that
429/// hides it from git, so no project `.gitignore` edit is ever needed. Any other
430/// directory is left alone: a `*` ignore file there would hide the user's own files
431/// from the extractor and from git.
432fn 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    // Ensure artifact_metadata root_path matches workspace canonical root
509    // (self-heals worktrees where artifact.db was copied from a parent repository)
510    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    // In-memory table to track paths seen on disk without allocating repository-wide HashMaps in heap
526    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        // If the indexed file belongs to an unreadable directory, preserve it
659        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            // Trigger bulk scan if large changes
694            scan_workspace(workspace, db_path, false)?;
695        } else {
696            // Incremental single-file updates
697            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
722/// A file the extractor reports as unsupported keeps no `files` row, so every reconcile
723/// would send it to the extractor again. `skipped_files` remembers such a file by path,
724/// size, and mtime until it changes.
725const 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        // Create a real file on disk
844        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}