focus/crates/focus-cli/src/config.rs

407 lines
15 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.

//! 配置加载。
//! Configuration loading.
//!
//! 读取 `~/.focus/config.json`(可通过 `FOCUS_CONFIG_DIR` 覆盖)。该文件列出
//! provider 与一个默认值;API-key 的值可以是 `$VAR`/`${VAR}` 模板,在加载时
//! 解析(见 [`focus_providers::resolve_config_value`])。
//! Reads `~/.focus/config.json` (overridable via `FOCUS_CONFIG_DIR`). The file
//! lists providers and a default; API-key values may be `$VAR`/`${VAR}`
//! templates resolved at load time (see [`focus_providers::resolve_config_value`]).
//!
//! 示例配置:
//! Example config:
//! ```json
//! {
//! "default": "zai-coding-cn",
//! "providers": {
//! "zai-coding-cn": {
//! "kind": "zai",
//! "region": "cn",
//! "model": "glm-5.2",
//! "path": "/api/coding/paas/v4/chat/completions",
//! "apiKey": "$ZAI_API_KEY"
//! }
//! }
//! }
//! ```
//!
//! `path` 字段用于选择接入档位:对 `xxxxxxxx.yyyyyyyy` 格式的 key 使用 GLM
//! Coding Plan(`/api/coding/paas/v4/chat/completions`),对按量付费 key 使用
//! 标准 PaaS(`/api/paas/v4/chat/completions`)。省略时使用 provider 的默认路径。
//! The `path` field selects the access tier: the GLM Coding Plan
//! (`/api/coding/paas/v4/chat/completions`) for `xxxxxxxx.yyyyyyyy` keys, or
//! the standard PaaS (`/api/paas/v4/chat/completions`) for pay-as-you-go keys.
//! When omitted, the provider's default path is used.
use focus_json::{parse, JsonValue};
use std::path::{Path, PathBuf};
/// 加载配置时出现的错误。
/// Errors while loading config.
#[derive(Debug)]
pub enum ConfigError {
/// I/O 错误(例如读取文件失败)。
/// An I/O error (e.g. failed to read the file).
Io(String),
/// JSON 解析错误。
/// A JSON parse error.
Parse(String),
/// 缺少必需字段。
/// A required field is missing.
Missing(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::Io(m) => write!(f, "config io error: {}", m),
ConfigError::Parse(m) => write!(f, "config parse error: {}", m),
ConfigError::Missing(m) => write!(f, "config missing: {}", m),
}
}
}
impl std::error::Error for ConfigError {}
/// 已解析的 provider 条目。
/// A resolved provider entry.
#[derive(Debug, Clone)]
pub struct ProviderEntry {
/// provider 种类(如 `zai`、`openai`、`anthropic`)。
/// Provider kind (e.g. `zai`, `openai`, `anthropic`).
pub kind: String,
/// 国内为 `"cn"`,国际为 `"intl"`(仅 zai 适用)。
/// `"cn"` for domestic, `"intl"` for international (zai only).
pub region: String,
/// 默认模型 id。
/// The default model id.
pub model: String,
/// 已解析的 API key(加载时由 `$VAR`/`${VAR}` 模板展开而来)。
/// Resolved API key (expanded from `$VAR`/`${VAR}` templates at load time).
pub api_key: String,
/// 可选的自定义 base host(覆盖区域默认值)。
/// Optional custom base host (overrides the region default).
pub base_url: Option<String>,
/// 可选的 chat-completions 路径覆盖(例如为 `xxxxxxxx.yyyyyyyy` 格式的 key
/// 指定 GLM Coding Plan 路径 `/api/coding/paas/v4/chat/completions`)。
/// 未设置时使用 provider 的默认路径。
/// Optional chat-completions path override (e.g. the GLM Coding Plan path
/// `/api/coding/paas/v4/chat/completions` for `xxxxxxxx.yyyyyyyy` keys).
/// When unset, the provider's default path is used.
pub path: Option<String>,
}
/// 已加载的配置。
/// The loaded configuration.
#[derive(Debug, Clone)]
pub struct Config {
/// 默认 provider 名称。
/// The default provider name.
pub default: String,
/// 全部 provider 条目(名称, 条目)的有序列表。
/// All provider entries as an ordered list of (name, entry).
pub providers: Vec<(String, ProviderEntry)>,
}
impl Config {
/// 一个没有任何 provider 的空配置。当配置文件尚不存在时作为回退,
/// 这样 CLI 可以打印引导信息而不是崩溃。
/// An empty config with no providers. Used as a fallback when the config
/// file does not exist yet, so the CLI can print guidance instead of
/// crashing.
pub fn empty() -> Self {
Self {
default: String::new(),
providers: Vec::new(),
}
}
/// 未配置任何 provider 时为真。
/// True if no providers are configured.
pub fn is_empty(&self) -> bool {
self.providers.is_empty()
}
/// 返回默认 provider 条目;若默认值未设置或未知则返回 None。
/// Get the default provider entry, or None if the default is unset/unknown.
#[cfg(test)]
pub fn default_provider(&self) -> Option<(&str, &ProviderEntry)> {
self.providers
.iter()
.find(|(name, _)| name == &self.default)
.map(|(n, e)| (n.as_str(), e))
}
/// 按名称获取 provider 条目。
/// Get a named provider entry.
pub fn provider(&self, name: &str) -> Option<&ProviderEntry> {
self.providers
.iter()
.find(|(n, _)| n == name)
.map(|(_, e)| e)
}
}
/// 解析配置目录:`$FOCUS_CONFIG_DIR` 或 `~/.focus`。
/// Resolve the config directory: `$FOCUS_CONFIG_DIR` or `~/.focus`.
pub fn config_dir() -> Result<PathBuf, ConfigError> {
if let Ok(dir) = std::env::var("FOCUS_CONFIG_DIR") {
return Ok(PathBuf::from(dir));
}
let home = std::env::var("HOME").map_err(|_| ConfigError::Io("HOME is not set".into()))?;
Ok(PathBuf::from(home).join(".focus"))
}
/// 从默认路径(`~/.focus/config.json`)加载配置。
/// Load config from the default path (`~/.focus/config.json`).
///
/// 若文件不存在,则返回内置默认 provider(这样 CLI 开箱即用:
/// `focus login zai` + `focus "你好"`)。文件存在但不可读、格式错误或缺少
/// 必需字段时仍视为错误。
/// If the file does not exist, returns the built-in default providers (so the
/// CLI works out of the box: `focus login zai` + `focus "你好"`). A file that
/// exists but is unreadable, malformed, or missing required fields is still an
/// error.
pub fn load() -> Result<Config, ConfigError> {
let path = config_dir()?.join("config.json");
if !path.exists() {
return Ok(builtin_defaults());
}
load_from(&path)
}
/// 当 `config.json` 不存在时使用的内置默认 provider。它们让 focus 对主要目标
/// (Z.ai / 智谱 GLM Coding Plan)及其它受支持厂商开箱即用。API key 通过
/// `focus login` 或 `--api-key` 单独提供。
/// The built-in default providers used when no `config.json` exists. These make
/// focus work out of the box for the primary target (Z.ai / 智谱 GLM Coding
/// Plan) plus the other supported vendors. API keys are supplied separately via
/// `focus login` or `--api-key`.
///
/// 默认的 `zai-coding-cn` 指向 **GLM Coding Plan** 档位,该档位的 key
/// (`xxxxxxxx.yyyyyyyy` 格式)必须使用 `/api/coding/paas/v4/chat/completions`——
/// 标准 `/api/paas/v4/...` 路径会对这类 key 返回 `429 余额不足`。
/// The default `zai-coding-cn` targets the **GLM Coding Plan** tier, whose keys
/// (`xxxxxxxx.yyyyyyyy` format) must use `/api/coding/paas/v4/chat/completions`
/// — the standard `/api/paas/v4/...` path reports `429 余额不足` for them.
pub fn builtin_defaults() -> Config {
let mk = |kind: &str, region: &str, model: &str, base_url: Option<&str>, path: Option<&str>| {
ProviderEntry {
kind: kind.to_string(),
region: region.to_string(),
model: model.to_string(),
api_key: String::new(),
base_url: base_url.map(String::from),
path: path.map(String::from),
}
};
Config {
default: "zai-coding-cn".to_string(),
providers: vec![
(
"zai-coding-cn".to_string(),
mk(
"zai",
"cn",
"glm-5.2",
None,
Some(focus_providers::ZAI_CODING_PATH),
),
),
(
"zai-coding-intl".to_string(),
mk(
"zai",
"intl",
"glm-5.2",
None,
Some(focus_providers::ZAI_CODING_PATH),
),
),
// 标准按量付费 PaaS 档位(与 coding plan 区分开)。
// Standard pay-as-you-go PaaS tier (separate from the coding plan).
("zai-cn".to_string(), mk("zai", "cn", "glm-5.2", None, None)),
(
"zai-intl".to_string(),
mk("zai", "intl", "glm-5.2", None, None),
),
(
"anthropic".to_string(),
mk("anthropic", "", "claude-sonnet-4-5", None, None),
),
("openai".to_string(), mk("openai", "", "gpt-4o", None, None)),
],
}
}
/// 从显式路径加载配置。便于测试。
/// Load config from an explicit path. Useful for tests.
pub fn load_from(path: &Path) -> Result<Config, ConfigError> {
// 配置文件缺失不是错误——返回空配置,让调用者决定如何引导用户。
// A missing config file is not an error — return an empty config so the
// caller can decide how to guide the user.
if !path.exists() {
return Ok(Config::empty());
}
let text = std::fs::read_to_string(path)
.map_err(|e| ConfigError::Io(format!("read {}: {}", path.display(), e)))?;
let value = parse(&text).map_err(|e| ConfigError::Parse(e.to_string()))?;
parse_config(&value)
}
/// 将解析后的 JSON 值转换为 [`Config`]。
/// Convert a parsed JSON value into a [`Config`].
fn parse_config(value: &JsonValue) -> Result<Config, ConfigError> {
let default = value
.get_str("default")
.ok_or_else(|| ConfigError::Missing("missing 'default' provider name".into()))?
.to_string();
let providers_obj = value
.get("providers")
.ok_or_else(|| ConfigError::Missing("missing 'providers' object".into()))?;
let entries = match providers_obj {
JsonValue::Obj(e) => e,
_ => return Err(ConfigError::Parse("'providers' must be an object".into())),
};
let mut providers = Vec::new();
for (name, entry) in entries {
let kind = entry
.get_str("kind")
.ok_or_else(|| ConfigError::Missing(format!("provider '{}' missing 'kind'", name)))?
.to_string();
let region = entry.get_str("region").unwrap_or("cn").to_string();
let model = entry
.get_str("model")
.ok_or_else(|| ConfigError::Missing(format!("provider '{}' missing 'model'", name)))?
.to_string();
// apiKey 在 config.json 中是可选的——key 通常通过 `focus login`(auth.json)
// 或 --api-key 提供。若缺失则默认为空,后续解析会失败并给出友好提示。
// apiKey is optional in config.json — the key is usually supplied via
// `focus login` (auth.json) or --api-key. If absent, it defaults to
// empty and resolution fails later with a helpful message.
let raw_key = entry.get_str("apiKey").unwrap_or("");
let api_key = if raw_key.is_empty() {
String::new()
} else {
focus_providers::resolve_config_value(raw_key).map_err(ConfigError::Io)?
};
let base_url = entry.get_str("baseUrl").map(String::from);
let path = entry.get_str("path").map(String::from);
providers.push((
name.clone(),
ProviderEntry {
kind,
region,
model,
api_key,
base_url,
path,
},
));
}
Ok(Config { default, providers })
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn parses_minimal_config() {
let text = r#"{
"default": "zai-coding-cn",
"providers": {
"zai-coding-cn": {
"kind": "zai",
"region": "cn",
"model": "glm-5.2",
"apiKey": "literal-key"
}
}
}"#;
let v = parse(text).unwrap();
let cfg = parse_config(&v).unwrap();
assert_eq!(cfg.default, "zai-coding-cn");
let (_, entry) = cfg.default_provider().unwrap();
assert_eq!(entry.kind, "zai");
assert_eq!(entry.region, "cn");
assert_eq!(entry.model, "glm-5.2");
assert_eq!(entry.api_key, "literal-key");
}
#[test]
fn resolves_env_var_key() {
env::set_var("FOCUS_CFG_TEST_KEY", "env-secret");
let text = r#"{
"default": "zai",
"providers": {
"zai": { "kind": "zai", "model": "m", "apiKey": "$FOCUS_CFG_TEST_KEY" }
}
}"#;
let v = parse(text).unwrap();
let cfg = parse_config(&v).unwrap();
assert_eq!(cfg.provider("zai").unwrap().api_key, "env-secret");
}
#[test]
fn provider_without_apikey_is_accepted() {
// apiKey 是可选的——key 通过 `focus login` 或 --api-key 提供。
// apiKey is optional — the key is supplied via `focus login` or --api-key.
let text = r#"{
"default": "zai-coding-cn",
"providers": {
"zai-coding-cn": { "kind": "zai", "region": "cn", "model": "glm-5.2" }
}
}"#;
let v = parse(text).unwrap();
let cfg = parse_config(&v).unwrap();
let entry = cfg.provider("zai-coding-cn").unwrap();
assert_eq!(entry.model, "glm-5.2");
assert_eq!(entry.api_key, "");
}
#[test]
fn rejects_missing_default() {
let v = parse(r#"{"providers":{}}"#).unwrap();
assert!(parse_config(&v).is_err());
}
#[test]
fn rejects_unknown_default() {
let text = r#"{
"default": "nope",
"providers": {
"zai": { "kind": "zai", "model": "m", "apiKey": "k" }
}
}"#;
let v = parse(text).unwrap();
let cfg = parse_config(&v).unwrap();
assert!(cfg.default_provider().is_none());
}
#[test]
fn missing_file_returns_empty_config() {
// 不存在的路径不是错误——返回空配置,以便 CLI 打印安装引导而不是崩溃。
// A nonexistent path is NOT an error — returns an empty config so the
// CLI can print setup guidance instead of crashing.
let path = Path::new("/nonexistent/focus/config-test-does-not-exist.json");
assert!(!path.exists());
let cfg = load_from(path).expect("missing file should not error");
assert!(cfg.is_empty());
assert_eq!(cfg.default, "");
}
#[test]
fn malformed_file_is_an_error() {
// 文件存在但 JSON 无效才是真正的错误。
// A file that exists but has invalid JSON is a real error.
let dir = std::env::temp_dir();
let path = dir.join("focus-malformed-test.json");
std::fs::write(&path, "{ this is not valid json").unwrap();
let result = load_from(&path);
assert!(result.is_err());
let _ = std::fs::remove_file(&path);
}
}