LazyBoy2/crates/lazyboy-core/src/config.rs

202 lines
6.0 KiB
Rust
Raw Normal View History

use std::env;
2026-09-14 09:08:35 +00:00
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Config {
pub api_key: String,
pub base_url: String,
pub model: String,
}
2026-09-15 05:20:44 +00:00
/// Copy GROKBOY_* process env into LAZYBOY_* when the new name is unset.
pub fn alias_legacy_env() {
let inherited: Vec<(String, String)> = env::vars()
.filter_map(|(key, value)| {
key.strip_prefix("GROKBOY_")
.map(|rest| (format!("LAZYBOY_{rest}"), value))
})
.collect();
for (key, value) in inherited {
if env::var_os(&key).is_none() {
unsafe { env::set_var(key, value) };
}
}
}
/// ~/.lazyboy, or ~/.grokboy if that already has data and the new dir does not.
pub fn home_config_dir() -> Option<PathBuf> {
let home = home_dir()?;
let neu = home.join(".lazyboy");
let old = home.join(".grokboy");
if neu.exists() || !old.exists() {
Some(neu)
} else {
Some(old)
}
}
/// Load `.env` files and map legacy GROKBOY_* names to LAZYBOY_*.
pub fn bootstrap_env() {
load_dotenv_files();
alias_legacy_env();
}
impl Config {
2026-09-14 09:08:35 +00:00
/// Resolve from environment, then dotenv files, then a logged-in Grok CLI session.
pub fn from_env() -> Result<Self, String> {
2026-09-15 05:20:44 +00:00
bootstrap_env();
let api_key = env::var("LAZYBOY_API_KEY")
.or_else(|_| env::var("XAI_API_KEY"))
.or_else(|_| env::var("OPENAI_API_KEY"))
2026-09-14 09:08:35 +00:00
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(grok_cli_auth_key)
.ok_or_else(|| {
2026-09-15 05:20:44 +00:00
"沒有 API key。設 LAZYBOY_API_KEY或 XAI_API_KEY或先用 Grok CLI 登入(~/.grok/auth.json"
2026-09-14 09:08:35 +00:00
.to_string()
})?;
2026-09-15 05:20:44 +00:00
let base_url = env::var("LAZYBOY_BASE_URL")
.or_else(|_| env::var("OPENAI_BASE_URL"))
.unwrap_or_else(|_| "https://api.x.ai/v1".into());
2026-09-15 05:20:44 +00:00
let model = env::var("LAZYBOY_MODEL").unwrap_or_else(|_| "grok-4.6".into());
Ok(Self {
api_key,
base_url: base_url.trim_end_matches('/').to_string(),
model,
})
}
}
2026-09-14 09:08:35 +00:00
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn load_dotenv_files() {
let mut paths = Vec::new();
if let Ok(cwd) = env::current_dir() {
paths.push(cwd.join(".env"));
}
if let Some(home) = home_dir() {
2026-09-15 05:20:44 +00:00
paths.push(home.join(".lazyboy").join(".env"));
paths.push(home.join(".lazyboy").join("env"));
2026-09-14 09:08:35 +00:00
paths.push(home.join(".grokboy").join(".env"));
paths.push(home.join(".grokboy").join("env"));
}
for path in paths {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
2026-09-15 05:20:44 +00:00
let canonical = if let Some(rest) = key.strip_prefix("GROKBOY_") {
format!("LAZYBOY_{rest}")
} else {
key.to_string()
};
if !canonical.starts_with("LAZYBOY_")
2026-09-15 03:20:42 +00:00
&& !matches!(
2026-09-15 05:20:44 +00:00
canonical.as_str(),
2026-09-15 03:20:42 +00:00
"XAI_API_KEY" | "OPENAI_API_KEY" | "OPENAI_BASE_URL"
)
{
2026-09-14 09:08:35 +00:00
continue;
}
2026-09-15 05:20:44 +00:00
if env::var_os(&canonical).is_some() {
2026-09-14 09:08:35 +00:00
continue;
}
let value = value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
if !value.is_empty() {
// dotenv only fills keys that are not already in the process environment.
2026-09-15 05:20:44 +00:00
unsafe { env::set_var(canonical, value) };
2026-09-14 09:08:35 +00:00
}
}
}
}
fn grok_cli_auth_key() -> Option<String> {
let path = home_dir()?.join(".grok").join("auth.json");
let raw = std::fs::read_to_string(path).ok()?;
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
grok_cli_key_from_value(&value)
}
pub(crate) fn grok_cli_key_from_value(value: &serde_json::Value) -> Option<String> {
let obj = value.as_object()?;
let mut newest: Option<(String, String)> = None;
for (_id, entry) in obj {
let Some(key) = entry
.get("key")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
else {
continue;
};
let created = entry
.get("create_time")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if newest
.as_ref()
.is_none_or(|(prev, _)| created.as_str() >= prev.as_str())
{
newest = Some((created, key.to_string()));
}
}
newest.map(|(_, key)| key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_model_is_grok_4_6() {
let cfg = Config {
api_key: "x".into(),
base_url: "https://api.x.ai/v1".into(),
model: "grok-4.6".into(),
};
assert_eq!(cfg.model, "grok-4.6");
}
#[test]
fn rejects_missing_key() {
let _ = Config {
api_key: "x".into(),
base_url: "https://api.x.ai/v1".into(),
model: "grok-4.6".into(),
};
}
2026-09-14 09:08:35 +00:00
#[test]
fn grok_cli_auth_json_yields_key() {
let value = serde_json::json!({
"https://auth.x.ai::older": {
"key": "old-token",
"create_time": "2026-01-01T00:00:00.000000Z"
},
"https://auth.x.ai::newer": {
"key": "new-token",
"create_time": "2026-09-01T00:00:00.000000Z"
}
});
assert_eq!(grok_cli_key_from_value(&value).as_deref(), Some("new-token"));
}
}