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