pub mod agents;
pub mod commands;
pub mod defaults;
pub mod loader;
pub mod paths;
pub mod secrets;
pub mod types;
pub mod wizard;
pub use types::{
AgentDef, BridgeRagConfig, ChannelMappingEntry, Config, ConfigFile, DebugTargets,
DiscordSection, ModelOverride, ProviderEntry, RagConfig, SlackSection, TelegramSection,
WebConfig,
};
pub use paths::{
bench_log_path,
collet_home,
config_dir,
config_file_path,
dated_log_path,
ensure_config_dir,
logs_dir,
project_data_dir,
project_data_dir_with,
};
pub use secrets::{decrypt_key, encrypt_key};
pub use loader::{load_config_file, resolve_default_provider, resolve_provider, save_ui_theme};
#[cfg(test)]
pub use defaults::write_default_config;
pub use defaults::{
patch_config_toml, save_config_secrets, write_config_commented, write_config_public,
};
pub use agents::extract_yaml_list;
pub use commands::{cmd_clis, cmd_provider, cmd_secure, cmd_status, cmd_unsecure};
pub use wizard::{cmd_setup, run_setup_wizard_if_needed};
pub mod wizard_style {
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const CYAN: &str = "\x1b[36m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const WHITE: &str = "\x1b[97m";
pub const BG_CYAN: &str = "\x1b[46m";
pub const BLACK: &str = "\x1b[30m";
}
macro_rules! resolve {
($env1:expr, $file:expr, $default:expr) => {
std::env::var($env1)
.ok()
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.or_else(|| $file.map(|v| v.to_string()))
.unwrap_or_else(|| $default.to_string())
};
($env1:expr, $env2:expr, $file:expr, $default:expr) => {
std::env::var($env1)
.ok()
.or_else(|| std::env::var($env2).ok())
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.or_else(|| $file.map(|v| v.to_string()))
.unwrap_or_else(|| $default.to_string())
};
}
macro_rules! resolve_parse {
($env1:expr, $file:expr, $default:expr) => {
std::env::var($env1)
.ok()
.and_then(|v| v.parse().ok())
.or($file)
.unwrap_or($default)
};
($env1:expr, $env2:expr, $file:expr, $default:expr) => {
std::env::var($env1)
.ok()
.or_else(|| std::env::var($env2).ok())
.and_then(|v| v.parse().ok())
.or($file)
.unwrap_or($default)
};
}
use crate::agent::swarm::config::CollaborationConfig;
use crate::common::AgentError;
use agents::discover_agents as _discover_agents;
use loader::migrate_legacy_sections;
use paths::collet_home as _collet_home;
use secrets::decrypt_key as _decrypt_key;
pub fn default_deny_paths() -> Vec<String> {
vec![
"~/.ssh".into(),
"~/.gnupg".into(),
"~/.aws".into(),
"~/.config/gcloud".into(),
"**/.env".into(),
"**/*.pem".into(),
"**/*.key".into(),
"**/id_rsa".into(),
"**/id_ed25519".into(),
]
}
impl Config {
pub fn load() -> crate::common::Result<Self> {
let mut file = match load_config_file() {
Ok(f) => f,
Err(e) => {
let msg = e.to_string();
if !msg.contains("os error 2")
&& !msg.contains("doesn't exist")
&& !msg.contains("No such file")
{
eprintln!("warning: config file could not be loaded, using defaults: {e}");
}
ConfigFile::default()
}
};
if file.api.provider.is_none()
&& let Some(ref chain) = file.api.providers.clone()
&& let Some(first) = chain
.split(',')
.next()
.map(str::trim)
.filter(|s| !s.is_empty())
&& let Some(slash) = first.find('/')
{
file.api.provider = Some(first[..slash].to_string());
if file.api.model.is_none() {
file.api.model = Some(first[slash + 1..].to_string());
}
}
if let Some(ref provider_name) = file.api.provider
&& let Some(entry) = file
.providers
.iter()
.find(|p| p.name.eq_ignore_ascii_case(provider_name))
{
if !entry.base_url.is_empty() {
file.api.base_url = Some(entry.base_url.clone());
}
if file.api.model.is_none() {
file.api.model = entry.models.first().cloned();
}
if entry.api_key_enc.is_some() {
file.api.api_key_enc = entry.api_key_enc.clone();
}
}
let supports_tools_override: Option<bool> = None;
let api_key = Self::resolve_api_key(&file.api)?;
let base_url = resolve!("COLLET_BASE_URL", file.api.base_url.as_deref(), "");
let model = std::env::var("COLLET_MODEL")
.ok()
.and_then(|v| if v.is_empty() { None } else { Some(v) })
.or_else(|| file.api.model.clone())
.or_else(|| {
file.providers
.first()
.and_then(|p| p.models.first().cloned())
})
.unwrap_or_default();
let max_tokens: u32 = resolve_parse!("COLLET_MAX_TOKENS", file.api.max_tokens, 8192);
let tool_timeout_secs: u64 =
resolve_parse!("COLLET_TOOL_TIMEOUT", file.agent.tool_timeout_secs, 300);
let task_timeout_secs: u64 =
resolve_parse!("COLLET_TASK_TIMEOUT", file.agent.task_timeout_secs, 7200);
let max_iterations: u32 = std::env::var("COLLET_MAX_ITERATIONS")
.ok()
.and_then(|v| v.parse().ok())
.or(file.agent.max_iterations)
.unwrap_or(50);
let max_continuations: u32 =
resolve_parse!("COLLET_MAX_CONTINUATIONS", file.agent.max_continuations, 3);
let circuit_breaker_threshold: u32 = resolve_parse!(
"COLLET_CIRCUIT_BREAKER",
file.agent.circuit_breaker_threshold,
3
);
let stream_idle_timeout_secs: u64 = resolve_parse!(
"COLLET_STREAM_IDLE_TIMEOUT",
file.agent.stream_idle_timeout_secs,
120
);
let stream_max_retries: u32 = resolve_parse!(
"COLLET_STREAM_MAX_RETRIES",
file.agent.stream_max_retries,
5
);
let iteration_delay_ms: u64 = std::env::var("COLLET_ITERATION_DELAY_MS")
.ok()
.and_then(|v| v.parse().ok())
.or(file.agent.iteration_delay_ms)
.unwrap_or(50);
let temperature: Option<f32> = std::env::var("COLLET_TEMPERATURE")
.ok()
.and_then(|v| v.parse().ok());
let thinking_budget_tokens: Option<u32> = std::env::var("COLLET_THINKING_BUDGET_TOKENS")
.ok()
.and_then(|v| v.parse().ok());
let reasoning_effort: Option<String> = std::env::var("COLLET_REASONING_EFFORT")
.ok()
.filter(|v| matches!(v.as_str(), "low" | "medium" | "high"));
let working_dir = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
let discovered = _discover_agents(&working_dir);
let mut agents: Vec<AgentDef> = discovered;
for agent in &mut agents {
if agent.model == "default" {
agent.model = model.clone();
}
if agent.provider.is_none() {
let m = agent.model.as_str();
if let Some(p) = file.providers.iter().find(|p| p.all_models().contains(&m)) {
agent.provider = Some(p.name.clone());
}
}
}
let context_max_tokens: usize = std::env::var("COLLET_CONTEXT_TOKENS")
.ok()
.and_then(|v| v.parse().ok())
.or(file.context.max_tokens)
.unwrap_or_else(|| {
let profile = crate::api::model_profile::profile_for(&model);
profile
.context_window
.saturating_sub(profile.max_output_tokens as usize)
});
let compaction_threshold: f32 = resolve_parse!(
"COLLET_COMPACTION_THRESHOLD",
file.context.compaction_threshold,
0.8
);
let adaptive_compaction = std::env::var("COLLET_ADAPTIVE_COMPACTION")
.map(|v| v == "1" || v == "true")
.ok()
.or(file.context.adaptive_compaction)
.unwrap_or(true);
let auto_commit = std::env::var("COLLET_AUTO_COMMIT")
.map(|v| v == "1" || v == "true")
.ok()
.or(file.hooks.auto_commit)
.unwrap_or(true);
let auto_route = std::env::var("COLLET_AUTO_ROUTE")
.map(|v| v == "1" || v == "true")
.ok()
.or(file.agent.auto_route)
.unwrap_or(true);
let lint_cmd = std::env::var("COLLET_LINT_CMD")
.ok()
.filter(|s| !s.is_empty())
.or(file.hooks.lint_cmd);
let test_cmd = std::env::var("COLLET_TEST_CMD")
.ok()
.filter(|s| !s.is_empty())
.or(file.hooks.test_cmd);
let theme = resolve!("COLLET_THEME", file.ui.theme.as_deref(), "default");
let debug_mode = std::env::var("COLLET_DEBUG_MODE")
.ok()
.and_then(|v| match v.as_str() {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
_ => None,
})
.or(file.ui.debug_mode)
.unwrap_or(false);
let bench_retain_days: u32 =
resolve_parse!("COLLET_BENCH_RETAIN_DAYS", file.bench.retain_days, 90);
let mut collab_section = file.collaboration;
migrate_legacy_sections(&file.fork, &file.hive, &mut collab_section);
let active_provider = file.api.provider.as_ref().and_then(|name| {
file.providers
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
});
let collaboration = CollaborationConfig::from_section(&collab_section, active_provider);
let debug_targets = types::DebugTargets {
tool_latency_avg_ms: file.debug.tool_latency_avg_ms.unwrap_or(2000),
api_latency_avg_ms: file.debug.api_latency_avg_ms.unwrap_or(5000),
tool_success_rate: file.debug.tool_success_rate.unwrap_or(95.0),
tokens_per_iteration: file.debug.tokens_per_iteration.unwrap_or(8000),
tools_per_iteration: file.debug.tools_per_iteration.unwrap_or(5),
};
let telemetry_force_off = std::env::var("COLLET_TELEMETRY")
.map(|v| v == "0" || v == "false")
.unwrap_or(false);
let telemetry_enabled = if telemetry_force_off {
false
} else {
file.telemetry.enabled.unwrap_or(true)
};
let telemetry_error_reporting =
telemetry_enabled && file.telemetry.error_reporting.unwrap_or(true);
let telemetry_analytics = telemetry_enabled && file.telemetry.analytics.unwrap_or(true);
let (
active_cli,
active_cli_args,
active_cli_yolo_args,
active_cli_model_env,
active_cli_skip_model,
active_cli_yolo_env,
active_cli_max_turns_flag,
) = file
.api
.provider
.as_ref()
.and_then(|name| {
file.providers
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
})
.filter(|p| p.is_cli())
.map(|p| {
(
p.cli.clone(),
p.cli_args.clone(),
p.cli_yolo_args.clone(),
p.cli_model_env.clone(),
p.cli_skip_model,
p.cli_yolo_env.clone(),
p.cli_max_turns_flag.clone(),
)
})
.unwrap_or((None, Vec::new(), Vec::new(), None, false, Vec::new(), None));
Ok(Self {
api_key,
base_url,
model,
max_tokens,
supports_tools: supports_tools_override,
tool_timeout_secs,
task_timeout_secs,
max_iterations,
max_continuations,
circuit_breaker_threshold,
stream_idle_timeout_secs,
stream_max_retries,
iteration_delay_ms,
temperature,
thinking_budget_tokens,
reasoning_effort,
model_overrides: file.models,
agents,
context_max_tokens,
compaction_threshold,
adaptive_compaction,
auto_commit,
lint_cmd,
test_cmd,
theme,
debug_mode,
bench_retain_days,
collaboration,
collet_home: _collet_home(file.paths.home.as_deref()),
debug_targets,
web: types::WebConfig::from_section(&file.web),
rag: types::RagConfig::from_section(&file.rag),
soul_enabled: file.soul.enabled.unwrap_or(true),
evolution_enabled: file.evolution.enabled.unwrap_or(false),
evolution_model: file.evolution.model.clone(),
evolution_cycles: file.evolution.cycles.unwrap_or(1),
auto_optimize: file.agent.auto_optimize.unwrap_or(true),
auto_route,
pii_filter: file.security.pii_filter.unwrap_or(true),
deny_paths: if file.security.deny_paths.is_empty() {
default_deny_paths()
} else {
file.security.deny_paths.clone()
},
follow_symlinks: file.security.follow_symlinks.unwrap_or(false),
telemetry_enabled,
telemetry_error_reporting,
telemetry_analytics,
cli: active_cli,
cli_args: active_cli_args,
cli_yolo_args: active_cli_yolo_args,
cli_model_env: active_cli_model_env,
cli_skip_model: active_cli_skip_model,
cli_yolo_env: active_cli_yolo_env,
cli_max_turns_flag: active_cli_max_turns_flag,
providers: file.providers.clone(),
proxy_headers: file.proxy_headers.clone(),
yolo: false,
iteration_budget: None,
})
}
pub fn model_override(&self, model_name: &str) -> Option<&ModelOverride> {
self.model_overrides.iter().find(|m| m.name == model_name)
}
pub fn resolve_temperature(&self, model_name: &str, agent: Option<&AgentDef>) -> Option<f32> {
agent
.and_then(|a| a.temperature)
.or_else(|| self.model_override(model_name).and_then(|m| m.temperature))
.or(self.temperature)
}
pub fn resolve_thinking_budget(
&self,
model_name: &str,
agent: Option<&AgentDef>,
) -> Option<u32> {
agent
.and_then(|a| a.thinking_budget_tokens)
.or_else(|| {
self.model_override(model_name)
.and_then(|m| m.thinking_budget_tokens)
})
.or(self.thinking_budget_tokens)
}
pub fn resolve_reasoning_effort(
&self,
model_name: &str,
agent: Option<&AgentDef>,
) -> Option<String> {
agent
.and_then(|a| a.reasoning_effort.clone())
.or_else(|| {
self.model_override(model_name)
.and_then(|m| m.reasoning_effort.clone())
})
.or_else(|| self.reasoning_effort.clone())
}
pub fn resolve_max_output_tokens(&self, model_name: &str, agent: Option<&AgentDef>) -> u32 {
let profile_default = crate::api::model_profile::profile_for(model_name).max_output_tokens;
agent
.and_then(|a| a.max_output_tokens)
.or_else(|| {
self.model_override(model_name)
.and_then(|m| m.max_output_tokens)
})
.unwrap_or(profile_default)
}
pub fn resolve_supports_tools(&self, model_name: &str, agent: Option<&AgentDef>) -> bool {
let profile_default = crate::api::model_profile::profile_for(model_name).supports_tool_use;
agent
.and_then(|a| a.supports_tools)
.or_else(|| {
self.model_override(model_name)
.and_then(|m| m.supports_tools)
})
.or(self.supports_tools)
.unwrap_or(profile_default)
}
pub fn resolve_supports_reasoning(&self, model_name: &str, agent: Option<&AgentDef>) -> bool {
let profile_default = crate::api::model_profile::profile_for(model_name).supports_reasoning;
agent
.and_then(|a| a.supports_reasoning)
.or_else(|| {
self.model_override(model_name)
.and_then(|m| m.supports_reasoning)
})
.unwrap_or(profile_default)
}
pub fn resolve_context_window(&self, model_name: &str, agent: Option<&AgentDef>) -> usize {
let profile_default = crate::api::model_profile::profile_for(model_name).context_window;
agent
.and_then(|a| a.context_window)
.or_else(|| {
self.model_override(model_name)
.and_then(|m| m.context_window)
})
.unwrap_or(profile_default)
}
pub fn resolve_max_iterations(&self, model_name: &str, agent: Option<&AgentDef>) -> u32 {
agent
.and_then(|a| a.max_iterations)
.or_else(|| {
self.model_override(model_name)
.and_then(|m| m.max_iterations)
})
.unwrap_or(self.max_iterations)
}
pub fn resolve_iteration_delay_ms(&self, model_name: &str, agent: Option<&AgentDef>) -> u64 {
agent
.and_then(|a| a.iteration_delay_ms)
.or_else(|| {
self.model_override(model_name)
.and_then(|m| m.iteration_delay_ms)
})
.unwrap_or(self.iteration_delay_ms)
}
#[cfg(test)]
pub fn default_for_test() -> Self {
Self {
api_key: String::new(),
base_url: String::new(),
model: "test-model".to_string(),
max_tokens: 8192,
supports_tools: None,
tool_timeout_secs: 120,
task_timeout_secs: 600,
max_iterations: 50,
max_continuations: 3,
circuit_breaker_threshold: 3,
stream_idle_timeout_secs: 120,
stream_max_retries: 5,
iteration_delay_ms: 50,
temperature: None,
thinking_budget_tokens: None,
reasoning_effort: None,
model_overrides: Vec::new(),
agents: Vec::new(),
context_max_tokens: 200_000,
compaction_threshold: 0.80,
adaptive_compaction: true,
auto_commit: false,
lint_cmd: None,
test_cmd: None,
theme: "default".to_string(),
debug_mode: false,
bench_retain_days: 90,
collaboration: CollaborationConfig::default(),
collet_home: std::path::PathBuf::from("/tmp/collet-test"),
debug_targets: types::DebugTargets::default(),
auto_optimize: true,
web: types::WebConfig::from_section(&types::WebSection::default()),
pii_filter: true,
deny_paths: default_deny_paths(),
follow_symlinks: false,
telemetry_enabled: false,
telemetry_error_reporting: false,
telemetry_analytics: false,
rag: None,
soul_enabled: false,
evolution_enabled: false,
evolution_model: None,
evolution_cycles: 1,
cli: None,
cli_args: Vec::new(),
cli_yolo_args: Vec::new(),
cli_model_env: None,
cli_skip_model: false,
cli_yolo_env: Vec::new(),
cli_max_turns_flag: None,
providers: Vec::new(),
proxy_headers: std::collections::HashMap::new(),
yolo: false,
iteration_budget: None,
auto_route: false,
}
}
fn resolve_api_key(api: &types::ApiSection) -> crate::common::Result<String> {
if let Ok(key) = std::env::var("COLLET_API_KEY")
&& !key.is_empty()
{
return Ok(key);
}
if let Some(ref enc) = api.api_key_enc
&& !enc.is_empty()
{
match _decrypt_key(enc) {
Ok(key) => return Ok(key),
Err(e) => {
tracing::warn!("could not decrypt stored key ({e}); trying other sources");
}
}
}
if let Some(ref key) = api.api_key
&& !key.is_empty()
{
tracing::warn!(
"api_key is stored in plain text in config.toml; run `collet secure` to encrypt it"
);
return Ok(key.clone());
}
if let Some(ref url) = api.base_url
&& (url.contains("localhost") || url.contains("127.0.0.1"))
{
return Ok(String::new());
}
Err(AgentError::Config(
"API key not found. Run `collet setup` to configure a provider, \
or `collet secure` to re-encrypt existing credentials."
.to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::wizard::ui::{
prompt_api_key_inner, prompt_with_default, write_minimal_wizard_config,
};
use super::*;
#[test]
fn test_config_dir() {
let dir = config_dir();
assert!(dir.to_str().unwrap().contains("collet"));
}
#[test]
fn test_config_file_path() {
let path = config_file_path();
assert!(path.to_str().unwrap().ends_with("config.toml"));
}
#[test]
fn test_default_config_file_parse() {
let cf = ConfigFile::default();
assert!(cf.api.api_key.is_none());
assert!(cf.api.base_url.is_none());
assert!(cf.agent.max_iterations.is_none());
assert!(cf.context.max_tokens.is_none());
}
#[test]
fn test_parse_config_toml() {
let toml_str = r#"
[api]
base_url = "https://example.com"
model = "glm-5"
max_tokens = 4096
[agent]
tool_timeout_secs = 60
max_iterations = 10
circuit_breaker_threshold = 5
[context]
max_tokens = 200000
compaction_threshold = 0.80
[hooks]
auto_commit = true
lint_cmd = "cargo clippy"
test_cmd = "cargo test"
[ui]
theme = "dark"
"#;
let cf: ConfigFile = toml::from_str(toml_str).unwrap();
assert_eq!(cf.api.base_url.as_deref(), Some("https://example.com"));
assert_eq!(cf.api.model.as_deref(), Some("glm-5"));
assert_eq!(cf.api.max_tokens, Some(4096));
assert_eq!(cf.agent.tool_timeout_secs, Some(60));
assert_eq!(cf.agent.max_iterations, Some(10));
assert_eq!(cf.context.max_tokens, Some(200_000));
assert_eq!(cf.context.compaction_threshold, Some(0.80));
assert_eq!(cf.hooks.auto_commit, Some(true));
assert_eq!(cf.hooks.lint_cmd.as_deref(), Some("cargo clippy"));
assert_eq!(cf.ui.theme.as_deref(), Some("dark"));
}
#[test]
fn test_encrypt_decrypt_round_trip() {
let original = "sk-test-key-12345";
let encrypted = encrypt_key(original).unwrap();
assert_ne!(encrypted, original);
assert!(encrypted.len() > 20);
let decrypted = decrypt_key(&encrypted).unwrap();
assert_eq!(decrypted, original);
}
#[test]
fn test_decrypt_invalid_base64() {
let result = decrypt_key("not-valid-base64!!!");
assert!(result.is_err());
}
#[test]
fn test_decrypt_too_short() {
use base64::Engine;
let short = base64::engine::general_purpose::STANDARD.encode([1u8, 2, 3]);
let result = decrypt_key(&short);
assert!(result.is_err());
}
#[test]
fn test_write_default_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
write_default_config(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("[api]"));
assert!(content.contains("[agent]"));
assert!(content.contains("[context]"));
assert!(content.contains("[hooks]"));
assert!(content.contains("[ui]"));
assert!(content.contains("ZAI_API_KEY"));
}
#[test]
fn test_partial_config_file_uses_defaults() {
let toml_str = r#"
[context]
max_tokens = 256000
"#;
let cf: ConfigFile = toml::from_str(toml_str).unwrap();
assert_eq!(cf.context.max_tokens, Some(256_000));
assert!(cf.api.model.is_none());
assert!(cf.agent.max_iterations.is_none());
assert!(cf.hooks.auto_commit.is_none());
}
#[test]
fn test_prompt_with_default_empty() {
use std::io::Cursor;
let mut input = Cursor::new(b"\n");
let result = prompt_with_default("Label", "mydefault", &mut input).unwrap();
assert_eq!(result, "mydefault");
}
#[test]
fn test_prompt_with_default_value() {
use std::io::Cursor;
let mut input = Cursor::new(b"custom-value\n");
let result = prompt_with_default("Label", "mydefault", &mut input).unwrap();
assert_eq!(result, "custom-value");
}
#[test]
fn test_prompt_api_key_inner_success() {
use std::io::Cursor;
let mut input = Cursor::new(b"\n\nsk-valid-key\n");
let result = prompt_api_key_inner(&mut input).unwrap();
assert_eq!(result, "sk-valid-key");
}
#[test]
fn test_prompt_api_key_inner_three_failures() {
use std::io::Cursor;
let mut input = Cursor::new(b"\n\n\n");
let result = prompt_api_key_inner(&mut input);
assert!(result.is_err());
}
#[test]
fn test_save_encrypted_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let initial = r#"[api]
api_key = "old-plain-key"
"#;
std::fs::write(&path, initial).unwrap();
let encrypted = encrypt_key("sk-test-12345").unwrap();
assert!(!encrypted.is_empty());
let decrypted = decrypt_key(&encrypted).unwrap();
assert_eq!(decrypted, "sk-test-12345");
}
#[test]
fn test_wizard_skipped_when_has_prompt() {
let result = run_setup_wizard_if_needed(true, false);
assert!(!result.unwrap());
}
#[test]
fn test_wizard_skipped_when_has_session_flag() {
let result = run_setup_wizard_if_needed(false, true);
assert!(!result.unwrap());
}
#[test]
fn test_wizard_full_creates_minimal_toml() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
write_minimal_wizard_config(
&config_path,
"sk-encrypted-test",
"https://api.z.ai/api/coding/paas/v4",
"glm-4.7",
)
.unwrap();
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(content.contains("[api]"));
assert!(content.contains("api_key_enc"));
assert!(content.contains("base_url"));
assert!(content.contains("model"));
}
}