lean-ctx-engine 3.12.3

LeanCTX | OSS library: the open-source runtime for the Context Gateway for AI agents behind the lean-ctx CLI, MCP server and proxy integrations.
Documentation
use std::path::{Path, PathBuf};

// MD5 stays here on purpose: these digests are names, not integrity checks.
// `namespace_hash` names hosted index bundles that other devices and older
// releases look up, `partition_hash` and `legacy_vectors_hash` name the
// existing `vectors/` directories. Changing the hash would orphan every index
// (a full rebuild per project, and a missed restore of every hosted bundle)
// for no security gain: the seed is the user's own path and repository
// identity, so a collision cannot be forced on another user.
use md5::{Digest, Md5};

/// The local index directory of `project_root`: one per checkout. Two
/// clones or worktrees of one repository, or two repositories with the same
/// package name, share a [`namespace_hash`] but never a directory.
///
/// Releases before 3.11.5 named the directory by the namespace alone, so such
/// projects shared one index. Those directories are not reused: each project
/// rebuilds its index once in its own partition.
pub fn vectors_dir(project_root: &Path) -> PathBuf {
    let partition = partition_hash(project_root);
    let legacy = legacy_vectors_hash(project_root);

    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
        let old_dir = data_dir.join("vectors").join(&legacy);
        let new_dir = data_dir.join("vectors").join(&partition);
        migrate_dir_if_needed(&old_dir, &new_dir, project_root);
        let namespace_dir = data_dir.join("vectors").join(namespace_hash(project_root));
        adopt_namespace_index(&namespace_dir, &new_dir, project_root);
        return new_dir;
    }

    PathBuf::from(".").join("vectors").join(partition)
}

/// The project-portable namespace (repository identity, optionally the
/// branch). It names a hosted index bundle so a restore on another device
/// finds its project; it never names a local directory.
pub fn namespace_hash(project_root: &Path) -> String {
    let seed = namespace_seed(project_root);
    let mut hasher = Md5::new();
    hasher.update(seed.as_bytes());
    crate::core::agent_identity::hex_encode(&hasher.finalize())
}

fn namespace_seed(project_root: &Path) -> String {
    let root_s = project_root.to_string_lossy().to_string();
    let base = crate::core::project_hash::project_identity(&root_s)
        .unwrap_or_else(|| crate::core::graph_index::normalize_project_root(&root_s));

    if !branch_aware_enabled() {
        return base;
    }

    let branch = git_branch(project_root).unwrap_or_else(|| "HEAD".to_string());
    format!("{base}|branch:{branch}")
}

fn branch_aware_enabled() -> bool {
    let Ok(v) = std::env::var("LEANCTX_INDEX_BRANCH_AWARE") else {
        return false;
    };
    matches!(
        v.trim().to_lowercase().as_str(),
        "1" | "true" | "yes" | "on"
    )
}

fn git_branch(project_root: &Path) -> Option<String> {
    let out = std::process::Command::new("git")
        .args(["rev-parse", "--abbrev-ref", "HEAD"])
        .current_dir(project_root)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let s = String::from_utf8(out.stdout).ok()?;
    let s = s.trim().to_string();
    if s.is_empty() { None } else { Some(s) }
}

/// [`namespace_hash`] bound to this checkout's path
/// (`project_hash::hash_project_root`).
fn partition_hash(project_root: &Path) -> String {
    // Canonical, like the namespace: `/var/…` and `/private/var/…` (or a
    // symlinked checkout) are one checkout and one directory.
    let root_s = crate::core::graph_index::normalize_project_root(&project_root.to_string_lossy());
    let seed = format!(
        "partition:{}|{}",
        crate::core::project_hash::hash_project_root(&root_s),
        namespace_seed(project_root)
    );
    let mut hasher = Md5::new();
    hasher.update(seed.as_bytes());
    crate::core::agent_identity::hex_encode(&hasher.finalize())
}

fn legacy_vectors_hash(project_root: &Path) -> String {
    let mut hasher = Md5::new();
    hasher.update(project_root.to_string_lossy().as_bytes());
    crate::core::agent_identity::hex_encode(&hasher.finalize())
}

fn migrate_dir_if_needed(old_dir: &Path, new_dir: &Path, project_root: &Path) {
    if old_dir == new_dir {
        return;
    }
    if !old_dir.exists() || new_dir.exists() {
        return;
    }
    // The stored root is the authority: never adopt another project's index.
    if std::fs::read_to_string(old_dir.join("project_root.txt"))
        .is_ok_and(|stored| stored.trim() != project_root.to_string_lossy())
    {
        return;
    }
    if !verify_index_ownership(old_dir) {
        tracing::warn!(
            "lean-ctx: skipping index migration — ownership check failed for {old_dir:?}"
        );
        return;
    }
    if let Err(e) = copy_dir_contents(old_dir, new_dir) {
        tracing::error!("lean-ctx: index migration failed: {e}");
    }
}

/// Copy the local index files a release before 3.11.5 kept in the shared
/// namespace directory, but only when that directory's recorded root is
/// exactly this checkout's: then the index was built from this checkout and
/// need not be rebuilt. `hosted-v2/` and every other subdirectory stay.
fn adopt_namespace_index(namespace_dir: &Path, new_dir: &Path, project_root: &Path) {
    if namespace_dir == new_dir || new_dir.exists() {
        return;
    }
    let Ok(stored) = std::fs::read_to_string(namespace_dir.join("project_root.txt")) else {
        return;
    };
    let canonical = |path: &Path| crate::core::pathutil::safe_canonicalize_bounded(path, 2000);
    if canonical(Path::new(stored.trim())) != canonical(project_root)
        || !verify_index_ownership(namespace_dir)
    {
        return;
    }
    let Ok(entries) = std::fs::read_dir(namespace_dir) else {
        return;
    };
    if std::fs::create_dir_all(new_dir).is_err() {
        return;
    }
    for entry in entries.flatten() {
        let path = entry.path();
        if entry.file_type().is_ok_and(|kind| kind.is_file())
            && let Err(error) = std::fs::copy(&path, new_dir.join(entry.file_name()))
        {
            tracing::warn!("lean-ctx: index adoption failed for {path:?}: {error}");
        }
    }
}

fn verify_index_ownership(dir: &Path) -> bool {
    let marker = if dir.join("bm25_index.bin.zst").exists() {
        dir.join("bm25_index.bin.zst")
    } else if dir.join("bm25_index.bin").exists() {
        dir.join("bm25_index.bin")
    } else {
        dir.join("bm25_index.json")
    };
    if !marker.exists() {
        return true;
    }
    let Ok(meta) = std::fs::metadata(&marker) else {
        return true;
    };
    if meta.len() == 0 || meta.len() > 500_000_000 {
        return false;
    }
    true
}

fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), String> {
    std::fs::create_dir_all(dst).map_err(|e| e.to_string())?;
    for entry in std::fs::read_dir(src).map_err(|e| e.to_string())?.flatten() {
        let src_path = entry.path();
        let dst_path = dst.join(entry.file_name());
        if src_path.is_dir() {
            copy_dir_contents(&src_path, &dst_path)?;
        } else {
            std::fs::copy(&src_path, &dst_path).map_err(|e| e.to_string())?;
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn namespace_hash_is_stable_across_clones_with_same_git_remote() {
        let _data = crate::core::data_dir::isolated_data_dir();
        let a = tempfile::tempdir().unwrap();
        let b = tempfile::tempdir().unwrap();

        std::fs::create_dir_all(a.path().join(".git")).unwrap();
        std::fs::write(
            a.path().join(".git").join("config"),
            "[remote \"origin\"]\n\turl = git@github.com:user/my-repo.git\n",
        )
        .unwrap();

        std::fs::create_dir_all(b.path().join(".git")).unwrap();
        std::fs::write(
            b.path().join(".git").join("config"),
            "[remote \"origin\"]\n\turl = git@github.com:user/my-repo.git\n",
        )
        .unwrap();

        let ha = namespace_hash(a.path());
        let hb = namespace_hash(b.path());
        assert_eq!(ha, hb);
        // AP05-G8: the two clones shared one index directory.
        assert_ne!(vectors_dir(a.path()), vectors_dir(b.path()));
    }

    #[test]
    fn vectors_dir_migrates_legacy_path_hash_directory() {
        let _env = crate::core::data_dir::test_env_lock();
        let data_dir = tempfile::tempdir().unwrap();
        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());

        let project = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(project.path().join(".git")).unwrap();
        std::fs::write(
            project.path().join(".git").join("config"),
            "[remote \"origin\"]\n\turl = git@github.com:user/my-repo.git\n",
        )
        .unwrap();

        let legacy = legacy_vectors_hash(project.path());
        let old_dir = data_dir.path().join("vectors").join(&legacy);
        std::fs::create_dir_all(&old_dir).unwrap();
        std::fs::write(old_dir.join("bm25_index.json"), "{\"doc_count\":0}").unwrap();

        let new_dir = vectors_dir(project.path());
        assert!(new_dir.exists());
        assert!(new_dir.join("bm25_index.json").exists());
    }
}