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    #[error("extractor did not index the requested file: {0}")]
28    TargetNotIndexed(String),
29}
30
31pub const PINNED_JULIE_VERSION: &str = "3.3.1";
32
33/// Extraction level code-kb asks for on a new artifact: symbol core plus structural facts,
34/// without the identifier, literal, and source-region tables code-kb never reads.
35pub 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
41/// Discovers the `julie-extract` binary and caches the result.
42///
43/// The first candidate whose version matches the pin wins. When none matches, the first
44/// candidate found is used and a warning is logged.
45pub fn find_julie_extract_binary() -> Option<PathBuf> {
46    installed_extractor().map(|(bin, _)| bin)
47}
48
49/// Version reported by the `julie-extract` binary in use, or the pin when none was found.
50pub 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
130/// Run julie-extract command with arguments.
131pub 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        // Retry on database lock / busy collisions with backoff
147        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
168/// Tier 1 & Incremental Update: updates one file, except headers use a content-aware scan for language detection.
169pub 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    // ponytail: serializes header scans per process; use workspace-scoped locks if concurrent workspaces need throughput.
190    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    // Julie 3.3.1 classifies `.h` files as C in `update`, while a content-aware scan preserves C++ Qt facts.
197    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
232/// Delete a file's extraction records from the database.
233pub 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
244/// Initial or full scan to build/refresh the database.
245///
246/// An artifact the extractor cannot read (empty, torn, or older schema) is removed and
247/// rebuilt from scratch.
248pub 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
296/// Rebuilds the index when it was written by a `julie-extract` other than the one in use or
297/// at another extraction level. Returns `true` when a rebuild ran. The old artifact is removed first because the
298/// extractor refuses to write into an artifact with an older schema.
299pub 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
338/// A `julie-extract update` run by a newer binary stamps its version into `artifact_metadata`
339/// but leaves every unchanged file's rows as the older binary wrote them, so the guard also
340/// checks the revision that last wrote each file.
341fn 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
368/// Check if disk content matches the stored hash in the database.
369pub 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
385/// Tier 2: Just-In-Time Staleness Guard
386/// Checks if a file on disk has been modified since it was indexed in SQLite.
387/// If dirty or missing from index, re-extracts the file synchronously (< 5ms).
388pub 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    // Look up file in SQLite files table
418    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, // Not yet in index
425        Some(f) => {
426            // Fast check: byte count difference
427            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/// Cold-Start Reconciliation Report
458#[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
475/// Cold-start background sweep: checks filesystem against SQLite `files` records.
476/// Streams disk checks and uses an in-memory SQLite index to eliminate repository-wide heap HashMaps.
477/// Creates a missing index. A git worktree copies its parent repository's index and
478/// reconciles it against the worktree files, which is much faster than a full scan;
479/// anything else runs a full scan.
480pub 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
489/// Creates the index directory. A `.code-kb` directory also gets a `.gitignore` that
490/// hides it from git, so no project `.gitignore` edit is ever needed. Any other
491/// directory is left alone: a `*` ignore file there would hide the user's own files
492/// from the extractor and from git.
493fn 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    // Ensure artifact_metadata root_path matches workspace canonical root
570    // (self-heals worktrees where artifact.db was copied from a parent repository)
571    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    // In-memory table to track paths seen on disk without allocating repository-wide HashMaps in heap
587    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        // If the indexed file belongs to an unreadable directory, preserve it
720        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
784/// A file the extractor reports as unsupported keeps no `files` row, so every reconcile
785/// would send it to the extractor again. `skipped_files` remembers such a file by path,
786/// size, and mtime until it changes.
787const 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        // Create a real file on disk
906        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}