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

202 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::env;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Config {
pub api_key: String,
pub base_url: String,
pub model: String,
}
/// 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 {
/// Resolve from environment, then dotenv files, then a logged-in Grok CLI session.
pub fn from_env() -> Result<Self, String> {
bootstrap_env();
let api_key = env::var("LAZYBOY_API_KEY")
.or_else(|_| env::var("XAI_API_KEY"))
.or_else(|_| env::var("OPENAI_API_KEY"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(grok_cli_auth_key)
.ok_or_else(|| {
"沒有 API key。設 LAZYBOY_API_KEY或 XAI_API_KEY或先用 Grok CLI 登入(~/.grok/auth.json"
.to_string()
})?;
let base_url = env::var("LAZYBOY_BASE_URL")
.or_else(|_| env::var("OPENAI_BASE_URL"))
.unwrap_or_else(|_| "https://api.x.ai/v1".into());
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,
})
}
}
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() {
paths.push(home.join(".lazyboy").join(".env"));
paths.push(home.join(".lazyboy").join("env"));
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();
let canonical = if let Some(rest) = key.strip_prefix("GROKBOY_") {
format!("LAZYBOY_{rest}")
} else {
key.to_string()
};
if !canonical.starts_with("LAZYBOY_")
&& !matches!(
canonical.as_str(),
"XAI_API_KEY" | "OPENAI_API_KEY" | "OPENAI_BASE_URL"
)
{
continue;
}
if env::var_os(&canonical).is_some() {
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.
unsafe { env::set_var(canonical, value) };
}
}
}
}
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(),
};
}
#[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"));
}
}