//! 配置加载。 //! 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, /// 可选的 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, } /// 已加载的配置。 /// 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 { 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 { 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 { // 配置文件缺失不是错误——返回空配置,让调用者决定如何引导用户。 // 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 { 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); } }