use std::path::PathBuf;
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum LlmProvider {
Claude { api_key: String },
Ollama { host: String, model: String },
}
#[derive(Debug, Clone)]
pub struct AuthState {
pub provider: LlmProvider,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct AuthConfig {
pub claude: Option<ClaudeConfig>,
pub ollama: Option<OllamaConfig>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ClaudeConfig {
pub api_key: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct OllamaConfig {
pub host: String,
pub default_model: String,
}
pub fn auth_toml_path() -> PathBuf {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".code-graph").join("auth.toml")
}
pub fn load_auth_config() -> Result<Option<AuthConfig>> {
let path = auth_toml_path();
if !path.exists() {
return Ok(None);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(&path)?;
let mode = meta.permissions().mode() & 0o777;
if mode != 0o600 {
eprintln!(
"warning: {} has permissions {:04o}, expected 0600 — consider running: chmod 0600 {}",
path.display(),
mode,
path.display()
);
}
}
let content = std::fs::read_to_string(&path)?;
let config: AuthConfig = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("failed to parse {}: {}", path.display(), e))?;
Ok(Some(config))
}
pub fn resolve_api_key() -> Option<String> {
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY")
&& !key.is_empty()
{
return Some(key);
}
if let Ok(Some(config)) = load_auth_config()
&& let Some(claude) = config.claude
&& !claude.api_key.is_empty()
{
return Some(claude.api_key);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[allow(dead_code)]
fn write_temp_auth_toml(content: &str) -> TempDir {
let dir = TempDir::new().expect("temp dir");
let toml_path = dir.path().join("auth.toml");
let mut f = std::fs::File::create(&toml_path).expect("create auth.toml");
f.write_all(content.as_bytes()).expect("write auth.toml");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&toml_path, std::fs::Permissions::from_mode(0o600))
.expect("set permissions");
}
dir
}
#[test]
fn auth_toml_path_returns_code_graph_auth_toml() {
let path = auth_toml_path();
assert!(
path.ends_with(".code-graph/auth.toml"),
"expected path ending in .code-graph/auth.toml, got: {}",
path.display()
);
}
#[test]
fn resolve_api_key_returns_env_var_when_set() {
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-test-env-key") };
let key = resolve_api_key();
unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
assert_eq!(key, Some("sk-ant-test-env-key".to_string()));
}
#[test]
fn resolve_api_key_returns_none_when_nothing_set() {
let tmp = TempDir::new().expect("temp dir");
let original_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path().to_str().unwrap()) };
unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
let key = resolve_api_key();
if let Some(h) = original_home {
unsafe { std::env::set_var("HOME", h) };
} else {
unsafe { std::env::remove_var("HOME") };
}
assert!(
key.is_none(),
"expected None when no env var and no auth.toml, got: {:?}",
key
);
}
#[test]
fn load_auth_config_returns_none_for_missing_file() {
let tmp = TempDir::new().expect("temp dir");
let original_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path().to_str().unwrap()) };
let result = load_auth_config();
if let Some(h) = original_home {
unsafe { std::env::set_var("HOME", h) };
} else {
unsafe { std::env::remove_var("HOME") };
}
assert!(result.is_ok(), "should return Ok");
assert!(
result.unwrap().is_none(),
"should return None for missing file"
);
}
#[test]
fn load_auth_config_parses_claude_section() {
let tmp = TempDir::new().expect("temp dir");
let cg_dir = tmp.path().join(".code-graph");
std::fs::create_dir_all(&cg_dir).expect("create .code-graph");
let toml_path = cg_dir.join("auth.toml");
std::fs::write(&toml_path, "[claude]\napi_key = \"sk-ant-file-key\"\n")
.expect("write auth.toml");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&toml_path, std::fs::Permissions::from_mode(0o600))
.expect("set permissions");
}
let original_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path().to_str().unwrap()) };
let result = load_auth_config();
if let Some(h) = original_home {
unsafe { std::env::set_var("HOME", h) };
} else {
unsafe { std::env::remove_var("HOME") };
}
let config = result.expect("should succeed").expect("should be Some");
assert_eq!(config.claude.as_ref().unwrap().api_key, "sk-ant-file-key");
}
#[test]
fn resolve_api_key_falls_back_to_auth_toml() {
unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
let tmp = TempDir::new().expect("temp dir");
let cg_dir = tmp.path().join(".code-graph");
std::fs::create_dir_all(&cg_dir).expect("create .code-graph");
let toml_path = cg_dir.join("auth.toml");
std::fs::write(&toml_path, "[claude]\napi_key = \"sk-ant-from-file\"\n")
.expect("write auth.toml");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&toml_path, std::fs::Permissions::from_mode(0o600))
.expect("set permissions");
}
let original_home = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", tmp.path().to_str().unwrap()) };
let key = resolve_api_key();
if let Some(h) = original_home {
unsafe { std::env::set_var("HOME", h) };
} else {
unsafe { std::env::remove_var("HOME") };
}
assert_eq!(key, Some("sk-ant-from-file".to_string()));
}
#[test]
fn auth_state_holds_provider() {
let state = AuthState {
provider: LlmProvider::Claude {
api_key: "test-key".to_string(),
},
};
assert!(matches!(state.provider, LlmProvider::Claude { .. }));
}
#[test]
fn llm_provider_ollama_fields() {
let provider = LlmProvider::Ollama {
host: "http://localhost:11434".to_string(),
model: "llama3.2".to_string(),
};
match provider {
LlmProvider::Ollama { host, model } => {
assert_eq!(host, "http://localhost:11434");
assert_eq!(model, "llama3.2");
}
_ => panic!("expected Ollama variant"),
}
}
}