crabmap 0.1.1

Rust code satellite map — index, query, and navigate your entire codebase
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::path::PathBuf;

const DEFAULT_API_URL: &str = "https://api.minimaxi.com/anthropic/v1/messages";
const DEFAULT_MODEL: &str = "MiniMax-M2.7-highspeed";
const DEFAULT_EMBEDDING_URL: &str = "https://api.siliconflow.cn/v1/embeddings";
const DEFAULT_EMBEDDING_MODEL: &str = "Qwen/Qwen3-Embedding-8B";
const DEFAULT_RERANK_URL: &str = "https://api.siliconflow.cn/v1/rerank";
const DEFAULT_RERANK_MODEL: &str = "Qwen/Qwen3-Reranker-8B";

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CodegraphConfig {
    pub api_url: String,
    pub model: String,
    pub api_key: Option<String>,
    #[serde(default)]
    pub embedding: Option<ModelProvider>,
    #[serde(default)]
    pub rerank: Option<ModelProvider>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ModelProvider {
    pub api_url: String,
    pub model: String,
    pub api_key: Option<String>,
}

impl Default for CodegraphConfig {
    fn default() -> Self {
        Self {
            api_url: DEFAULT_API_URL.to_string(),
            model: DEFAULT_MODEL.to_string(),
            api_key: None,
            embedding: Some(ModelProvider {
                api_url: DEFAULT_EMBEDDING_URL.to_string(),
                model: DEFAULT_EMBEDDING_MODEL.to_string(),
                api_key: None,
            }),
            rerank: Some(ModelProvider {
                api_url: DEFAULT_RERANK_URL.to_string(),
                model: DEFAULT_RERANK_MODEL.to_string(),
                api_key: None,
            }),
        }
    }
}

pub fn path() -> Result<PathBuf> {
    let home = std::env::var("HOME").context("HOME is not set; cannot locate crabmap config")?;
    Ok(PathBuf::from(home)
        .join(".config")
        .join("crabmap")
        .join("config.json"))
}

pub fn load() -> Result<CodegraphConfig> {
    let path = path()?;
    if !path.exists() {
        return Ok(CodegraphConfig::default());
    }
    Ok(serde_json::from_slice(
        &std::fs::read(&path)
            .with_context(|| format!("failed to read crabmap config at {}", path.display()))?,
    )?)
}

pub fn save(config: &CodegraphConfig) -> Result<PathBuf> {
    let path = path()?;
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    std::fs::write(&path, serde_json::to_vec_pretty(config)?)
        .with_context(|| format!("failed to write {}", path.display()))?;
    set_private_permissions(&path)?;
    Ok(path)
}

pub fn update(
    api_key: Option<String>,
    model: Option<String>,
    api_url: Option<String>,
    embedding_key: Option<String>,
    embedding_model: Option<String>,
    embedding_url: Option<String>,
    rerank_key: Option<String>,
    rerank_model: Option<String>,
    rerank_url: Option<String>,
) -> Result<Value> {
    let mut config = load()?;
    if let Some(api_key) = api_key {
        config.api_key = Some(api_key);
    }
    if let Some(model) = model {
        config.model = model;
    }
    if let Some(api_url) = api_url {
        config.api_url = api_url;
    }
    if embedding_key.is_some() || embedding_model.is_some() || embedding_url.is_some() {
        let mut embedding = config.embedding.unwrap_or_else(default_embedding);
        if let Some(api_key) = embedding_key {
            embedding.api_key = Some(api_key);
        }
        if let Some(model) = embedding_model {
            embedding.model = model;
        }
        if let Some(api_url) = embedding_url {
            embedding.api_url = api_url;
        }
        config.embedding = Some(embedding);
    }
    if rerank_key.is_some() || rerank_model.is_some() || rerank_url.is_some() {
        let mut rerank = config.rerank.unwrap_or_else(default_rerank);
        if let Some(api_key) = rerank_key {
            rerank.api_key = Some(api_key);
        }
        if let Some(model) = rerank_model {
            rerank.model = model;
        }
        if let Some(api_url) = rerank_url {
            rerank.api_url = api_url;
        }
        config.rerank = Some(rerank);
    }
    let path = save(&config)?;
    Ok(json!({
        "kind": "config",
        "path": path,
        "config": redacted(&config)
    }))
}

pub fn show() -> Result<Value> {
    Ok(json!({
        "kind": "config",
        "path": path()?,
        "config": redacted(&load()?)
    }))
}

pub fn redacted(config: &CodegraphConfig) -> Value {
    json!({
        "api_url": config.api_url,
        "model": config.model,
        "api_key": config.api_key.as_ref().map(|key| redact_key(key)),
        "embedding": config.embedding.as_ref().map(redacted_provider),
        "rerank": config.rerank.as_ref().map(redacted_provider)
    })
}

fn default_embedding() -> ModelProvider {
    ModelProvider {
        api_url: DEFAULT_EMBEDDING_URL.to_string(),
        model: DEFAULT_EMBEDDING_MODEL.to_string(),
        api_key: None,
    }
}

fn default_rerank() -> ModelProvider {
    ModelProvider {
        api_url: DEFAULT_RERANK_URL.to_string(),
        model: DEFAULT_RERANK_MODEL.to_string(),
        api_key: None,
    }
}

fn redacted_provider(provider: &ModelProvider) -> Value {
    json!({
        "api_url": provider.api_url,
        "model": provider.model,
        "api_key": provider.api_key.as_ref().map(|key| redact_key(key))
    })
}

fn redact_key(key: &str) -> String {
    if key.len() <= 10 {
        "***".to_string()
    } else {
        format!("{}...{}", &key[..6], &key[key.len() - 4..])
    }
}

#[cfg(unix)]
fn set_private_permissions(path: &std::path::Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
        .with_context(|| format!("failed to chmod 600 {}", path.display()))
}

#[cfg(not(unix))]
fn set_private_permissions(_: &std::path::Path) -> Result<()> {
    Ok(())
}