codebase-graph 1.2.1

Native codebaseGraph CLI and MCP server for local code knowledge graphs.
use crate::api::contracts::RepoSelector;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub(crate) struct RepoRuntime {
    pub repo_root: PathBuf,
    pub db_path: PathBuf,
    pub manifest_path: PathBuf,
    pub config_path: Option<PathBuf>,
}

#[derive(Debug, Clone)]
pub(crate) struct RepoPaths {
    pub(crate) repo_name: String,
    pub(crate) state_dir: PathBuf,
    pub(crate) db_path: PathBuf,
    pub(crate) manifest_path: PathBuf,
    pub(crate) config_path: PathBuf,
}

impl RepoPaths {
    pub(crate) fn derive(repo_root: &Path) -> Self {
        let repo_name = safe_name(
            repo_root
                .file_name()
                .and_then(|value| value.to_str())
                .unwrap_or("repository"),
        );
        let state_dir = repo_root.join(".codebaseGraph");
        Self {
            repo_name: repo_name.clone(),
            state_dir: state_dir.clone(),
            db_path: state_dir.join(format!("{repo_name}_graph.ldb")),
            manifest_path: state_dir.join("manifest.json"),
            config_path: state_dir.join("config.json"),
        }
    }
}

pub(crate) fn resolve_runtime(selector: &RepoSelector) -> Result<RepoRuntime, String> {
    let repo_root = resolve_repository_root(selector.repo_root.as_deref())?;
    let paths = RepoPaths::derive(&repo_root);
    let config_path = selector
        .config_path
        .clone()
        .unwrap_or_else(|| paths.config_path.clone());
    let config = if config_path.exists() {
        Some(read_json_file(&config_path)?)
    } else {
        None
    };
    Ok(RepoRuntime {
        repo_root: repo_root.clone(),
        db_path: selector
            .db_path
            .clone()
            .or_else(|| config_path_value(config.as_ref(), "database_path"))
            .unwrap_or(paths.db_path),
        manifest_path: selector
            .manifest_path
            .clone()
            .or_else(|| config_path_value(config.as_ref(), "manifest_path"))
            .unwrap_or(paths.manifest_path),
        config_path: Some(config_path),
    })
}

pub(crate) fn resolve_repository_root(explicit: Option<&Path>) -> Result<PathBuf, String> {
    if let Some(path) = explicit {
        return path
            .canonicalize()
            .map_err(|error| format!("failed to resolve repo root: {error}"));
    }
    let current_dir = std::env::current_dir()
        .map_err(|error| format!("failed to read current directory: {error}"))?;
    for ancestor in current_dir.ancestors() {
        let config_path = ancestor.join(".codebaseGraph").join("config.json");
        if config_path.exists() {
            let config = read_json_file(&config_path)?;
            if let Some(repo_root) = config.get("repo_root").and_then(serde_json::Value::as_str) {
                let repo_root = PathBuf::from(repo_root);
                return Ok(repo_root.canonicalize().unwrap_or(repo_root));
            }
            return Ok(ancestor.to_path_buf());
        }
    }
    if let Some(git_root) = current_dir
        .ancestors()
        .find(|ancestor| ancestor.join(".git").exists())
    {
        return Ok(git_root.to_path_buf());
    }
    Ok(current_dir)
}

fn read_json_file(path: &Path) -> Result<serde_json::Value, String> {
    let text = fs::read_to_string(path)
        .map_err(|error| format!("failed to read JSON file {}: {error}", path.display()))?;
    serde_json::from_str(&text)
        .map_err(|error| format!("failed to parse JSON file {}: {error}", path.display()))
}

fn config_path_value(config: Option<&serde_json::Value>, key: &str) -> Option<PathBuf> {
    config
        .and_then(|value| value.get(key))
        .and_then(serde_json::Value::as_str)
        .map(PathBuf::from)
}

fn safe_name(value: &str) -> String {
    let normalized: String = value
        .chars()
        .map(|character| {
            if character.is_alphanumeric() || character == '-' || character == '_' {
                character
            } else {
                '_'
            }
        })
        .collect();
    let trimmed = normalized.trim_matches(['.', '_', '-']);
    if trimmed.is_empty() {
        "repository".to_string()
    } else {
        trimmed.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::{resolve_runtime, RepoPaths};
    use crate::api::contracts::RepoSelector;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn unique_temp_dir(prefix: &str) -> std::path::PathBuf {
        std::env::temp_dir().join(format!(
            "{prefix}-{}-{}",
            std::process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("clock should be after epoch")
                .as_nanos()
        ))
    }

    #[test]
    fn graph_paths_are_deterministic() {
        let paths = RepoPaths::derive(&std::path::PathBuf::from("/tmp/demo"));
        assert_eq!(paths.repo_name, "demo");
        assert_eq!(
            paths.state_dir,
            std::path::PathBuf::from("/tmp/demo/.codebaseGraph")
        );
        assert!(paths.db_path.to_string_lossy().ends_with("demo_graph.ldb"));
        assert_eq!(
            paths.manifest_path,
            std::path::PathBuf::from("/tmp/demo/.codebaseGraph/manifest.json")
        );
        assert_eq!(
            paths.config_path,
            std::path::PathBuf::from("/tmp/demo/.codebaseGraph/config.json")
        );
    }

    #[test]
    fn resolve_runtime_selects_configured_graph_and_manifest_paths() {
        let root = unique_temp_dir("codebase-graph-api-runtime");
        let state = root.join(".codebaseGraph");
        fs::create_dir_all(&state).expect("state directory should be created");
        let graph_path = state.join("configured.ldb");
        let manifest_path = state.join("configured-manifest.json");
        fs::write(
            state.join("config.json"),
            serde_json::to_vec(&serde_json::json!({
                "database_path": graph_path,
                "manifest_path": manifest_path,
            }))
            .expect("config should serialize"),
        )
        .expect("config should be written");

        let runtime = resolve_runtime(&RepoSelector {
            repo_root: Some(root.clone()),
            config_path: None,
            db_path: None,
            manifest_path: None,
        })
        .expect("runtime should resolve");

        assert_eq!(
            runtime.repo_root,
            root.canonicalize().expect("root should canonicalize")
        );
        assert_eq!(runtime.db_path, graph_path);
        assert_eq!(runtime.manifest_path, manifest_path);
        assert_eq!(
            runtime.config_path,
            Some(runtime.repo_root.join(".codebaseGraph").join("config.json"))
        );
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn resolve_runtime_prefers_explicit_graph_and_manifest_paths() {
        let root = unique_temp_dir("codebase-graph-api-runtime-explicit");
        fs::create_dir_all(&root).expect("repository should be created");
        let graph_path = root.join("explicit.ldb");
        let manifest_path = root.join("explicit-manifest.json");

        let runtime = resolve_runtime(&RepoSelector {
            repo_root: Some(root.clone()),
            config_path: None,
            db_path: Some(graph_path.clone()),
            manifest_path: Some(manifest_path.clone()),
        })
        .expect("runtime should resolve");

        assert_eq!(runtime.db_path, graph_path);
        assert_eq!(runtime.manifest_path, manifest_path);
        let _ = fs::remove_dir_all(root);
    }
}