Skip to main content

robit_ai/
config.rs

1//! Configuration loading for config.toml.
2//!
3//! Loads a single unified config file from:
4//!   1. `cwd/.robit/config.toml` (project-local, highest priority)
5//!   2. `~/.robit/config.toml`   (global fallback)
6//!
7//! Configuration format uses a providers + models structure:
8//! ```toml
9//! default_model = "deepseek/deepseek-chat"
10//!
11//! [providers.deepseek]
12//! name = "DeepSeek"
13//! base_url = "https://api.deepseek.com/v1"
14//! api_key = "${DEEPSEEK_API_KEY}"
15//!
16//! [[providers.deepseek.models]]
17//! id = "deepseek-chat"
18//! context_window = 65536
19//! ```
20//!
21//! Environment variable substitution is supported in `api_key` fields via `${ENV_VAR}` syntax.
22
23use serde::Deserialize;
24use std::collections::HashMap;
25use std::path::PathBuf;
26
27use crate::error::LlmError;
28
29// ============================================================================
30// config.toml structures
31// ============================================================================
32
33/// Top-level config.toml configuration.
34#[derive(Debug, Deserialize)]
35pub struct RobitConfig {
36    /// Default model in "provider/model" format (e.g. "deepseek/deepseek-chat").
37    pub default_model: Option<String>,
38    /// Provider definitions keyed by provider name.
39    pub providers: HashMap<String, ProviderConfig>,
40    /// Application settings.
41    pub app: Option<AppConfig>,
42    /// Communication channel configurations (QQ Bot, Feishu, etc.).
43    #[serde(default)]
44    pub channels: Option<ChannelsConfig>,
45}
46
47/// A single LLM provider (one API endpoint with multiple models).
48#[derive(Debug, Deserialize)]
49pub struct ProviderConfig {
50    /// Display name for the provider (optional).
51    pub name: Option<String>,
52    /// API base URL (must be OpenAI-compatible).
53    pub base_url: String,
54    /// API key (supports `${ENV_VAR}` substitution).
55    pub api_key: String,
56    /// Available models under this provider.
57    pub models: Vec<ModelConfig>,
58}
59
60/// A single model definition within a provider.
61#[derive(Debug, Deserialize)]
62pub struct ModelConfig {
63    /// Model ID used in API calls (e.g. "deepseek-chat").
64    pub id: String,
65    /// Display name (optional).
66    pub name: Option<String>,
67    /// Context window size in tokens (optional).
68    pub context_window: Option<u64>,
69    /// Maximum output tokens (optional).
70    pub max_output_tokens: Option<u64>,
71    /// Sampling temperature (optional, runtime parameter).
72    pub temperature: Option<f32>,
73    /// Maximum completion tokens (optional, runtime parameter).
74    pub max_tokens: Option<u32>,
75    /// Whether this model supports image inputs (optional, default false).
76    pub supports_images: Option<bool>,
77    /// Whether this model supports tool calling (optional, default false).
78    pub supports_tools: Option<bool>,
79}
80
81// ============================================================================
82// Application config (unchanged from previous version)
83// ============================================================================
84
85#[derive(Debug, Deserialize, Default)]
86pub struct AppConfig {
87    pub log_level: Option<String>,
88    /// Whether to log to file (default: false).
89    pub log_file: Option<bool>,
90    pub max_steps: Option<usize>,
91    pub enabled_tools: Option<Vec<String>>,
92    pub enabled_skills: Option<Vec<String>>,
93    pub context: Option<ContextConfig>,
94    pub retry: Option<RetryConfig>,
95    pub auto_approve: Option<bool>,
96    pub global_storage: Option<bool>,
97    /// Bot platform settings (shared across Bot frontends).
98    pub bot: Option<BotConfig>,
99}
100
101#[derive(Debug, Deserialize)]
102pub struct ContextConfig {
103    pub max_output_lines: Option<usize>,
104    pub max_output_bytes: Option<usize>,
105    pub reserve_ratio: Option<f32>,
106    /// Token threshold for triggering compression (default 5000).
107    /// Only compress when removed messages exceed this token count.
108    pub compression_token_threshold: Option<usize>,
109    /// Enable/disable context compression (default true).
110    pub compression_enabled: Option<bool>,
111}
112
113#[derive(Debug, Deserialize)]
114pub struct RetryConfig {
115    pub max_retries: Option<u32>,
116    pub initial_backoff_ms: Option<u64>,
117    pub max_backoff_ms: Option<u64>,
118}
119
120// ============================================================================
121// Communication channels config (QQ Bot, Feishu, etc.)
122// ============================================================================
123
124/// Communication channel configurations, separate from LLM `providers`.
125#[derive(Debug, Deserialize, Default)]
126pub struct ChannelsConfig {
127    /// QQ Official Bot channel.
128    pub qq_bot: Option<QqBotConfig>,
129}
130
131/// QQ Official Bot credentials (from `[channels.qq_bot]`).
132#[derive(Debug, Deserialize, Clone)]
133pub struct QqBotConfig {
134    pub app_id: String,
135    pub app_secret: String,
136}
137
138// ============================================================================
139// Bot platform app config
140// ============================================================================
141
142/// Shared Bot platform settings under `[app.bot]`.
143#[derive(Debug, Deserialize, Default)]
144pub struct BotConfig {
145    /// Timeout (seconds) for waiting on a tool confirmation reply.
146    pub confirm_timeout_secs: Option<u64>,
147    /// Idle session expiry (minutes) before cleanup.
148    pub session_timeout_minutes: Option<u64>,
149    /// Custom confirm/reject keywords.
150    pub confirm_keywords: Option<ConfirmKeywordsConfig>,
151}
152
153/// Confirm/reject keyword lists for inline tool confirmation.
154#[derive(Debug, Deserialize, Clone, Default)]
155pub struct ConfirmKeywordsConfig {
156    pub approve: Option<Vec<String>>,
157    pub reject: Option<Vec<String>>,
158}
159
160// ============================================================================
161// Resolved model reference
162// ============================================================================
163
164/// A fully resolved model ready for client construction.
165///
166/// Merges provider-level settings (base_url, api_key) with model-level
167/// settings (context_window, temperature, etc).
168#[derive(Debug, Clone)]
169pub struct ResolvedModel {
170    pub profile_name: String,
171    pub model_id: String,
172    pub base_url: String,
173    pub api_key: String,
174    pub max_tokens: Option<u32>,
175    pub temperature: Option<f32>,
176    pub context_window: Option<u64>,
177    /// Whether this model supports image inputs.
178    pub supports_images: bool,
179    /// Whether this model supports tool calling.
180    pub supports_tools: bool,
181}
182
183// ============================================================================
184// Loader
185// ============================================================================
186
187/// Returns the ~/.robit/ directory path.
188fn robit_home() -> Result<PathBuf, LlmError> {
189    let home = dirs::home_dir()
190        .ok_or_else(|| LlmError::ConfigError("Cannot determine home directory".to_string()))?;
191    Ok(home.join(".robit"))
192}
193
194/// Replace `${ENV_VAR}` patterns with actual environment variable values.
195fn resolve_env_var(value: &str) -> String {
196    if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
197        std::env::var(var_name).unwrap_or_else(|_| value.to_string())
198    } else {
199        value.to_string()
200    }
201}
202
203/// Load and parse the config.toml config file.
204///
205/// Automatically loads .env files before resolving `${ENV_VAR}` patterns:
206///   1. `~/.robit/.env` (global, lower priority)
207///   2. `workdir/.robit/.env` (project-local, higher priority)
208///
209/// Search order for config.toml:
210///   1. `workdir/.robit/config.toml` (project-local, if workdir provided)
211///   2. `cwd/.robit/config.toml` (project-local, if workdir not provided)
212///   3. `~/.robit/config.toml`   (global fallback)
213pub fn load_config(workdir: Option<&std::path::Path>) -> Result<RobitConfig, LlmError> {
214    // Load .env first so ${ENV_VAR} substitutions work
215    load_env_from(workdir);
216
217    let path = find_config_path(workdir)?;
218
219    let content = std::fs::read_to_string(&path)
220        .map_err(|e| LlmError::ConfigError(format!("Failed to read {}: {}", path.display(), e)))?;
221
222    let mut config: RobitConfig = toml::from_str(&content)
223        .map_err(|e| LlmError::ConfigError(format!("Failed to parse config.toml: {}", e)))?;
224
225    // Resolve environment variables in api_key fields
226    for provider in config.providers.values_mut() {
227        provider.api_key = resolve_env_var(&provider.api_key);
228    }
229
230    // Also resolve env vars in channel configs
231    if let Some(ref mut channels) = config.channels {
232        if let Some(ref mut qq_bot) = channels.qq_bot {
233            qq_bot.app_id = resolve_env_var(&qq_bot.app_id);
234            qq_bot.app_secret = resolve_env_var(&qq_bot.app_secret);
235        }
236    }
237
238    Ok(config)
239}
240
241/// Load .env files in order: workdir first (higher priority), then global (lower priority).
242/// Workdir vars will override global vars.
243pub fn load_env_from(workdir: Option<&std::path::Path>) {
244    // Collect all env paths, workdir first (higher priority)
245    let mut env_paths = Vec::new();
246
247    // Workdir-specific .env (highest priority)
248    if let Some(workdir) = workdir {
249        let local_env = workdir.join(".robit").join(".env");
250        if local_env.exists() {
251            env_paths.push(local_env);
252        }
253    } else if let Ok(cwd) = std::env::current_dir() {
254        let local_env = cwd.join(".robit").join(".env");
255        if local_env.exists() {
256            env_paths.push(local_env);
257        }
258    }
259
260    // Global .env (lowest priority)
261    if let Ok(robit_dir) = robit_home() {
262        let env_path = robit_dir.join(".env");
263        if env_path.exists() {
264            env_paths.push(env_path);
265        }
266    }
267
268    // Load in reverse order (global first, then workdir) so workdir overrides global
269    // Use dotenvy::from_path_iter to load and manually set vars to enable overriding
270    for path in env_paths.iter().rev() {
271        if let Ok(iter) = dotenvy::from_path_iter(path) {
272            for item in iter {
273                if let Ok((key, value)) = item {
274                    std::env::set_var(key, value);
275                }
276            }
277        }
278    }
279}
280
281/// Load .env from ~/.robit/.env if it exists (deprecated, use load_env_from).
282pub fn load_env() {
283    if let Ok(robit_dir) = robit_home() {
284        let env_path = robit_dir.join(".env");
285        if env_path.exists() {
286            let _ = dotenvy::from_path(&env_path);
287        }
288    }
289}
290
291/// Find the config file path following the search order.
292fn find_config_path(workdir: Option<&std::path::Path>) -> Result<PathBuf, LlmError> {
293    // 1. Project-local: workdir/.robit/config.toml (if workdir provided)
294    if let Some(workdir) = workdir {
295        let local_path = workdir.join(".robit").join("config.toml");
296        if local_path.exists() {
297            return Ok(local_path);
298        }
299    }
300
301    // 2. Project-local: cwd/.robit/config.toml (if workdir not provided or no config there)
302    if let Ok(cwd) = std::env::current_dir() {
303        let local_path = cwd.join(".robit").join("config.toml");
304        if local_path.exists() {
305            return Ok(local_path);
306        }
307    }
308
309    // 3. Global: ~/.robit/config.toml
310    let global_path = robit_home()?.join("config.toml");
311    if global_path.exists() {
312        return Ok(global_path);
313    }
314
315    Err(LlmError::ConfigError(format!(
316        "Configuration file config.toml not found.\n\
317         Please create one of the following:\n\
318         - Project-local: .robit/config.toml\n\
319         - Global: {}",
320        global_path.display()
321    )))
322}
323
324/// Resolve which model to use.
325///
326/// `default_model` uses "provider/model" format.
327/// Priority: explicit `provider_name` argument > `default_model` field > first available.
328///
329/// When `provider_name` is None, parses `default_model` (e.g. "deepseek/deepseek-chat")
330/// into provider key and model ID.
331pub fn resolve_profile(
332    config: &RobitConfig,
333    provider_name: Option<&str>,
334) -> Result<ResolvedModel, LlmError> {
335    let (provider_key, model_id) = if let Some(name) = provider_name {
336        // Explicit provider override — use its first model
337        let provider = config.providers.get(name).ok_or_else(|| {
338            LlmError::ConfigError(format!(
339                "Provider '{}' is not defined in config.toml. Available providers: {:?}",
340                name,
341                config.providers.keys().collect::<Vec<_>>()
342            ))
343        })?;
344        let first_model = provider.models.first().ok_or_else(|| {
345            LlmError::ConfigError(format!("Provider '{}' has no models defined", name))
346        })?;
347        (name.to_string(), first_model.id.clone())
348    } else if let Some(ref default_model) = config.default_model {
349        parse_default_model(default_model)?
350    } else {
351        // Fall back to first available provider + first model
352        let (key, provider) = config.providers.iter().next().ok_or_else(|| {
353            LlmError::ConfigError("No providers defined in config.toml".to_string())
354        })?;
355        let first_model = provider.models.first().ok_or_else(|| {
356            LlmError::ConfigError(format!("Provider '{}' has no models defined", key))
357        })?;
358        (key.clone(), first_model.id.clone())
359    };
360
361    let provider = config.providers.get(&provider_key).ok_or_else(|| {
362        LlmError::ConfigError(format!(
363            "Provider '{}' is not defined in config.toml. Available providers: {:?}",
364            provider_key,
365            config.providers.keys().collect::<Vec<_>>()
366        ))
367    })?;
368
369    // Find the matching model
370    let model = provider
371        .models
372        .iter()
373        .find(|m| m.id == model_id)
374        .ok_or_else(|| {
375            let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
376            LlmError::ConfigError(format!(
377                "Model '{}' not found in provider '{}'. Available models: {:?}",
378                model_id, provider_key, available
379            ))
380        })?;
381
382    // Validate API key
383    if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
384        return Err(LlmError::ConfigError(format!(
385            "Provider '{}' API key is not configured or the environment variable is not set",
386            provider_key
387        )));
388    }
389
390    Ok(ResolvedModel {
391        profile_name: provider_key,
392        model_id: model.id.clone(),
393        base_url: provider.base_url.clone(),
394        api_key: provider.api_key.clone(),
395        max_tokens: model.max_tokens,
396        temperature: model.temperature,
397        context_window: model.context_window,
398        supports_images: model.supports_images.unwrap_or(false),
399        supports_tools: model.supports_tools.unwrap_or(false),
400    })
401}
402
403/// Parse "provider/model" format from default_model.
404///
405/// Returns (provider_key, model_id).
406fn parse_default_model(default_model: &str) -> Result<(String, String), LlmError> {
407    let parts: Vec<&str> = default_model.splitn(2, '/').collect();
408    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
409        return Err(LlmError::ConfigError(format!(
410            "Invalid default_model '{}' format, expected 'provider/model' (e.g. 'deepseek/deepseek-chat')",
411            default_model
412        )));
413    }
414    Ok((parts[0].to_string(), parts[1].to_string()))
415}
416
417// ============================================================================
418// Tests
419// ============================================================================
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_resolve_env_var_with_env_set() {
427        std::env::set_var("ROBIT_TEST_KEY", "test-value-123");
428        assert_eq!(resolve_env_var("${ROBIT_TEST_KEY}"), "test-value-123");
429        std::env::remove_var("ROBIT_TEST_KEY");
430    }
431
432    #[test]
433    fn test_resolve_env_var_without_env() {
434        assert_eq!(
435            resolve_env_var("${ROBIT_NONEXISTENT_KEY}"),
436            "${ROBIT_NONEXISTENT_KEY}"
437        );
438    }
439
440    #[test]
441    fn test_resolve_env_var_plain_string() {
442        assert_eq!(resolve_env_var("plain-key"), "plain-key");
443    }
444
445    #[test]
446    fn test_parse_robit_config() {
447        let toml_str = r#"
448            default_model = "deepseek/deepseek-chat"
449
450            [providers.deepseek]
451            name = "DeepSeek"
452            base_url = "https://api.deepseek.com"
453            api_key = "sk-test-key"
454
455            [[providers.deepseek.models]]
456            id = "deepseek-chat"
457            name = "DeepSeek Chat"
458            context_window = 65536
459            max_output_tokens = 8192
460            temperature = 0.0
461            max_tokens = 4096
462
463            [[providers.deepseek.models]]
464            id = "deepseek-reasoner"
465            name = "DeepSeek Reasoner"
466            context_window = 65536
467            temperature = 0.6
468
469            [providers.qwen]
470            name = "通义千问"
471            base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
472            api_key = "sk-qwen-key"
473
474            [[providers.qwen.models]]
475            id = "qwen-max"
476            name = "Qwen Max"
477            context_window = 32768
478
479            [app]
480            log_level = "DEBUG"
481            max_steps = 10
482            global_storage = true
483
484            [app.context]
485            max_output_lines = 500
486            reserve_ratio = 0.2
487
488            [app.retry]
489            max_retries = 3
490        "#;
491
492        let config: RobitConfig = toml::from_str(toml_str).unwrap();
493
494        // Default model
495        assert_eq!(
496            config.default_model.as_deref(),
497            Some("deepseek/deepseek-chat")
498        );
499
500        // Providers
501        assert_eq!(config.providers.len(), 2);
502
503        // DeepSeek provider
504        let ds = &config.providers["deepseek"];
505        assert_eq!(ds.name.as_deref(), Some("DeepSeek"));
506        assert_eq!(ds.base_url, "https://api.deepseek.com");
507        assert_eq!(ds.api_key, "sk-test-key");
508        assert_eq!(ds.models.len(), 2);
509        assert_eq!(ds.models[0].id, "deepseek-chat");
510        assert_eq!(ds.models[0].context_window, Some(65536));
511        assert_eq!(ds.models[0].temperature, Some(0.0));
512        assert_eq!(ds.models[0].max_tokens, Some(4096));
513        assert_eq!(ds.models[1].id, "deepseek-reasoner");
514        assert_eq!(ds.models[1].temperature, Some(0.6));
515
516        // Qwen provider
517        let qw = &config.providers["qwen"];
518        assert_eq!(qw.name.as_deref(), Some("通义千问"));
519        assert_eq!(qw.models.len(), 1);
520        assert_eq!(qw.models[0].id, "qwen-max");
521
522        // App section
523        let app = config.app.as_ref().unwrap();
524        assert_eq!(app.log_level.as_deref(), Some("DEBUG"));
525        assert_eq!(app.max_steps, Some(10));
526        assert_eq!(app.global_storage, Some(true));
527        assert!(app.context.is_some());
528        assert_eq!(app.context.as_ref().unwrap().max_output_lines, Some(500));
529        assert!(app.retry.is_some());
530        assert_eq!(app.retry.as_ref().unwrap().max_retries, Some(3));
531    }
532
533    #[test]
534    fn test_parse_config_minimal() {
535        let toml_str = r#"
536            [providers.default]
537            base_url = "https://api.deepseek.com"
538            api_key = "sk-test"
539
540            [[providers.default.models]]
541            id = "deepseek-chat"
542        "#;
543
544        let config: RobitConfig = toml::from_str(toml_str).unwrap();
545        assert!(config.default_model.is_none());
546        assert!(config.app.is_none());
547        assert_eq!(config.providers.len(), 1);
548    }
549
550    #[test]
551    fn test_resolve_profile_from_default_model() {
552        let config = make_test_config();
553        let resolved = resolve_profile(&config, None).unwrap();
554        assert_eq!(resolved.profile_name, "deepseek");
555        assert_eq!(resolved.model_id, "deepseek-chat");
556        assert_eq!(resolved.base_url, "https://api.deepseek.com");
557        assert_eq!(resolved.api_key, "sk-test");
558        assert_eq!(resolved.context_window, Some(65536));
559        assert_eq!(resolved.temperature, Some(0.0));
560        assert_eq!(resolved.max_tokens, Some(4096));
561    }
562
563    #[test]
564    fn test_resolve_profile_explicit_provider() {
565        let config = make_test_config();
566        // Explicit provider — uses first model of that provider
567        let resolved = resolve_profile(&config, Some("qwen")).unwrap();
568        assert_eq!(resolved.profile_name, "qwen");
569        assert_eq!(resolved.model_id, "qwen-max");
570        assert_eq!(
571            resolved.base_url,
572            "https://dashscope.aliyuncs.com/compatible-mode/v1"
573        );
574    }
575
576    #[test]
577    fn test_resolve_profile_first_available() {
578        // No default_model and no explicit provider — use first available
579        let toml_str = r#"
580            [providers.deepseek]
581            base_url = "https://api.deepseek.com"
582            api_key = "sk-test"
583
584            [[providers.deepseek.models]]
585            id = "deepseek-chat"
586        "#;
587        let config: RobitConfig = toml::from_str(toml_str).unwrap();
588        let resolved = resolve_profile(&config, None).unwrap();
589        assert_eq!(resolved.profile_name, "deepseek");
590        assert_eq!(resolved.model_id, "deepseek-chat");
591    }
592
593    #[test]
594    fn test_resolve_profile_not_found() {
595        let config = make_test_config();
596        let result = resolve_profile(&config, Some("nonexistent"));
597        assert!(result.is_err());
598    }
599
600    #[test]
601    fn test_resolve_profile_model_not_found() {
602        let toml_str = r#"
603            default_model = "deepseek/nonexistent-model"
604
605            [providers.deepseek]
606            base_url = "https://api.deepseek.com"
607            api_key = "sk-test"
608
609            [[providers.deepseek.models]]
610            id = "deepseek-chat"
611        "#;
612        let config: RobitConfig = toml::from_str(toml_str).unwrap();
613        let result = resolve_profile(&config, None);
614        assert!(result.is_err());
615    }
616
617    #[test]
618    fn test_resolve_profile_invalid_default_model_format() {
619        let toml_str = r#"
620            default_model = "invalid-no-slash"
621
622            [providers.deepseek]
623            base_url = "https://api.deepseek.com"
624            api_key = "sk-test"
625
626            [[providers.deepseek.models]]
627            id = "deepseek-chat"
628        "#;
629        let config: RobitConfig = toml::from_str(toml_str).unwrap();
630        let result = resolve_profile(&config, None);
631        assert!(result.is_err());
632        assert!(result
633            .unwrap_err()
634            .to_string()
635            .contains("Invalid default_model"));
636    }
637
638    #[test]
639    fn test_resolve_profile_empty_api_key() {
640        let toml_str = r#"
641            [providers.deepseek]
642            base_url = "https://api.deepseek.com"
643            api_key = ""
644
645            [[providers.deepseek.models]]
646            id = "deepseek-chat"
647        "#;
648        let config: RobitConfig = toml::from_str(toml_str).unwrap();
649        let result = resolve_profile(&config, None);
650        assert!(result.is_err());
651    }
652
653    #[test]
654    fn test_parse_enabled_skills() {
655        let toml_str = r#"
656            default_model = "deepseek/deepseek-chat"
657
658            [providers.deepseek]
659            base_url = "https://api.deepseek.com"
660            api_key = "sk-test"
661
662            [[providers.deepseek.models]]
663            id = "deepseek-chat"
664
665            [app]
666            enabled_skills = ["code-review", "refactor"]
667        "#;
668
669        let config: RobitConfig = toml::from_str(toml_str).unwrap();
670        let app = config.app.as_ref().unwrap();
671        assert!(app.enabled_skills.is_some());
672        let skills = app.enabled_skills.as_ref().unwrap();
673        assert_eq!(skills.len(), 2);
674        assert_eq!(skills[0], "code-review");
675        assert_eq!(skills[1], "refactor");
676    }
677
678    #[test]
679    fn test_parse_enabled_tools() {
680        let toml_str = r#"
681            default_model = "deepseek/deepseek-chat"
682
683            [providers.deepseek]
684            base_url = "https://api.deepseek.com"
685            api_key = "sk-test"
686
687            [[providers.deepseek.models]]
688            id = "deepseek-chat"
689
690            [app]
691            enabled_tools = ["read", "bash", "edit", "write", "grep", "find", "ls"]
692        "#;
693
694        let config: RobitConfig = toml::from_str(toml_str).unwrap();
695        let app = config.app.as_ref().unwrap();
696        assert!(app.enabled_tools.is_some());
697        let tools = app.enabled_tools.as_ref().unwrap();
698        assert_eq!(tools.len(), 7);
699        assert_eq!(tools[0], "read");
700        assert_eq!(tools[1], "bash");
701        assert_eq!(tools[2], "edit");
702        assert_eq!(tools[3], "write");
703        assert_eq!(tools[4], "grep");
704        assert_eq!(tools[5], "find");
705        assert_eq!(tools[6], "ls");
706    }
707
708    #[test]
709    fn test_parse_auto_approve() {
710        let toml_str = r#"
711            default_model = "deepseek/deepseek-chat"
712
713            [providers.deepseek]
714            base_url = "https://api.deepseek.com"
715            api_key = "sk-test"
716
717            [[providers.deepseek.models]]
718            id = "deepseek-chat"
719
720            [app]
721            auto_approve = true
722        "#;
723
724        let config: RobitConfig = toml::from_str(toml_str).unwrap();
725        let app = config.app.as_ref().unwrap();
726        assert_eq!(app.auto_approve, Some(true));
727    }
728
729    #[test]
730    fn test_parse_auto_approve_default_none() {
731        let toml_str = r#"
732            default_model = "deepseek/deepseek-chat"
733
734            [providers.deepseek]
735            base_url = "https://api.deepseek.com"
736            api_key = "sk-test"
737
738            [[providers.deepseek.models]]
739            id = "deepseek-chat"
740
741            [app]
742        "#;
743
744        let config: RobitConfig = toml::from_str(toml_str).unwrap();
745        let app = config.app.as_ref().unwrap();
746        assert_eq!(app.auto_approve, None);
747    }
748
749    fn make_test_config() -> RobitConfig {
750        let toml_str = r#"
751            default_model = "deepseek/deepseek-chat"
752
753            [providers.deepseek]
754            base_url = "https://api.deepseek.com"
755            api_key = "sk-test"
756
757            [[providers.deepseek.models]]
758            id = "deepseek-chat"
759            context_window = 65536
760            temperature = 0.0
761            max_tokens = 4096
762
763            [providers.qwen]
764            base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
765            api_key = "sk-qwen-test"
766
767            [[providers.qwen.models]]
768            id = "qwen-max"
769            context_window = 32768
770        "#;
771
772        toml::from_str(toml_str).unwrap()
773    }
774
775    #[test]
776    fn test_parse_channels_and_bot_sections() {
777        let toml_str = r#"
778            default_model = "deepseek/deepseek-chat"
779
780            [providers.deepseek]
781            base_url = "https://api.deepseek.com"
782            api_key = "sk-test"
783
784            [[providers.deepseek.models]]
785            id = "deepseek-chat"
786
787            [channels.qq_bot]
788            app_id = "123456789"
789            app_secret = "secret-value"
790
791            [app.bot]
792            confirm_timeout_secs = 60
793            session_timeout_minutes = 30
794
795            [app.bot.confirm_keywords]
796            approve = ["确认", "yes"]
797            reject = ["取消", "no"]
798        "#;
799
800        let config: RobitConfig = toml::from_str(toml_str).unwrap();
801
802        // channels.qq_bot
803        let qq = config
804            .channels
805            .as_ref()
806            .and_then(|c| c.qq_bot.as_ref())
807            .expect("qq_bot config missing");
808        assert_eq!(qq.app_id, "123456789");
809        assert_eq!(qq.app_secret, "secret-value");
810
811        // app.bot
812        let bot = config.app.as_ref().unwrap().bot.as_ref().unwrap();
813        assert_eq!(bot.confirm_timeout_secs, Some(60));
814        assert_eq!(bot.session_timeout_minutes, Some(30));
815        let kw = bot.confirm_keywords.as_ref().unwrap();
816        assert_eq!(kw.approve.as_ref().unwrap(), &vec!["确认".to_string(), "yes".to_string()]);
817        assert_eq!(kw.reject.as_ref().unwrap(), &vec!["取消".to_string(), "no".to_string()]);
818    }
819
820    #[test]
821    fn test_config_without_channels_still_parses() {
822        let toml_str = r#"
823            [providers.deepseek]
824            base_url = "https://api.deepseek.com"
825            api_key = "sk-test"
826
827            [[providers.deepseek.models]]
828            id = "deepseek-chat"
829        "#;
830
831        let config: RobitConfig = toml::from_str(toml_str).unwrap();
832        assert!(config.channels.is_none());
833        assert!(config.app.is_none() || config.app.as_ref().unwrap().bot.is_none());
834    }
835}