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 `~/.robit/.env` before resolving `${ENV_VAR}` patterns.
206///
207/// Search order:
208///   1. `workdir/.robit/config.toml` (project-local, if workdir provided)
209///   2. `cwd/.robit/config.toml` (project-local, if workdir not provided)
210///   3. `~/.robit/config.toml`   (global fallback)
211pub fn load_config(workdir: Option<&std::path::Path>) -> Result<RobitConfig, LlmError> {
212    // Load .env first so ${ENV_VAR} substitutions work
213    load_env();
214
215    let path = find_config_path(workdir)?;
216
217    let content = std::fs::read_to_string(&path)
218        .map_err(|e| LlmError::ConfigError(format!("Failed to read {}: {}", path.display(), e)))?;
219
220    let mut config: RobitConfig = toml::from_str(&content)
221        .map_err(|e| LlmError::ConfigError(format!("Failed to parse config.toml: {}", e)))?;
222
223    // Resolve environment variables in api_key fields
224    for provider in config.providers.values_mut() {
225        provider.api_key = resolve_env_var(&provider.api_key);
226    }
227
228    Ok(config)
229}
230
231/// Load .env from ~/.robit/.env if it exists.
232pub fn load_env() {
233    if let Ok(robit_dir) = robit_home() {
234        let env_path = robit_dir.join(".env");
235        if env_path.exists() {
236            let _ = dotenvy::from_path(&env_path);
237        }
238    }
239}
240
241/// Find the config file path following the search order.
242fn find_config_path(workdir: Option<&std::path::Path>) -> Result<PathBuf, LlmError> {
243    // 1. Project-local: workdir/.robit/config.toml (if workdir provided)
244    if let Some(workdir) = workdir {
245        let local_path = workdir.join(".robit").join("config.toml");
246        if local_path.exists() {
247            return Ok(local_path);
248        }
249    }
250
251    // 2. Project-local: cwd/.robit/config.toml (if workdir not provided or no config there)
252    if let Ok(cwd) = std::env::current_dir() {
253        let local_path = cwd.join(".robit").join("config.toml");
254        if local_path.exists() {
255            return Ok(local_path);
256        }
257    }
258
259    // 3. Global: ~/.robit/config.toml
260    let global_path = robit_home()?.join("config.toml");
261    if global_path.exists() {
262        return Ok(global_path);
263    }
264
265    Err(LlmError::ConfigError(format!(
266        "Configuration file config.toml not found.\n\
267         Please create one of the following:\n\
268         - Project-local: .robit/config.toml\n\
269         - Global: {}",
270        global_path.display()
271    )))
272}
273
274/// Resolve which model to use.
275///
276/// `default_model` uses "provider/model" format.
277/// Priority: explicit `provider_name` argument > `default_model` field > first available.
278///
279/// When `provider_name` is None, parses `default_model` (e.g. "deepseek/deepseek-chat")
280/// into provider key and model ID.
281pub fn resolve_profile(
282    config: &RobitConfig,
283    provider_name: Option<&str>,
284) -> Result<ResolvedModel, LlmError> {
285    let (provider_key, model_id) = if let Some(name) = provider_name {
286        // Explicit provider override — use its first model
287        let provider = config.providers.get(name).ok_or_else(|| {
288            LlmError::ConfigError(format!(
289                "Provider '{}' is not defined in config.toml. Available providers: {:?}",
290                name,
291                config.providers.keys().collect::<Vec<_>>()
292            ))
293        })?;
294        let first_model = provider.models.first().ok_or_else(|| {
295            LlmError::ConfigError(format!("Provider '{}' has no models defined", name))
296        })?;
297        (name.to_string(), first_model.id.clone())
298    } else if let Some(ref default_model) = config.default_model {
299        parse_default_model(default_model)?
300    } else {
301        // Fall back to first available provider + first model
302        let (key, provider) = config.providers.iter().next().ok_or_else(|| {
303            LlmError::ConfigError("No providers defined in config.toml".to_string())
304        })?;
305        let first_model = provider.models.first().ok_or_else(|| {
306            LlmError::ConfigError(format!("Provider '{}' has no models defined", key))
307        })?;
308        (key.clone(), first_model.id.clone())
309    };
310
311    let provider = config.providers.get(&provider_key).ok_or_else(|| {
312        LlmError::ConfigError(format!(
313            "Provider '{}' is not defined in config.toml. Available providers: {:?}",
314            provider_key,
315            config.providers.keys().collect::<Vec<_>>()
316        ))
317    })?;
318
319    // Find the matching model
320    let model = provider
321        .models
322        .iter()
323        .find(|m| m.id == model_id)
324        .ok_or_else(|| {
325            let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
326            LlmError::ConfigError(format!(
327                "Model '{}' not found in provider '{}'. Available models: {:?}",
328                model_id, provider_key, available
329            ))
330        })?;
331
332    // Validate API key
333    if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
334        return Err(LlmError::ConfigError(format!(
335            "Provider '{}' API key is not configured or the environment variable is not set",
336            provider_key
337        )));
338    }
339
340    Ok(ResolvedModel {
341        profile_name: provider_key,
342        model_id: model.id.clone(),
343        base_url: provider.base_url.clone(),
344        api_key: provider.api_key.clone(),
345        max_tokens: model.max_tokens,
346        temperature: model.temperature,
347        context_window: model.context_window,
348        supports_images: model.supports_images.unwrap_or(false),
349        supports_tools: model.supports_tools.unwrap_or(false),
350    })
351}
352
353/// Parse "provider/model" format from default_model.
354///
355/// Returns (provider_key, model_id).
356fn parse_default_model(default_model: &str) -> Result<(String, String), LlmError> {
357    let parts: Vec<&str> = default_model.splitn(2, '/').collect();
358    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
359        return Err(LlmError::ConfigError(format!(
360            "Invalid default_model '{}' format, expected 'provider/model' (e.g. 'deepseek/deepseek-chat')",
361            default_model
362        )));
363    }
364    Ok((parts[0].to_string(), parts[1].to_string()))
365}
366
367// ============================================================================
368// Tests
369// ============================================================================
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_resolve_env_var_with_env_set() {
377        std::env::set_var("ROBIT_TEST_KEY", "test-value-123");
378        assert_eq!(resolve_env_var("${ROBIT_TEST_KEY}"), "test-value-123");
379        std::env::remove_var("ROBIT_TEST_KEY");
380    }
381
382    #[test]
383    fn test_resolve_env_var_without_env() {
384        assert_eq!(
385            resolve_env_var("${ROBIT_NONEXISTENT_KEY}"),
386            "${ROBIT_NONEXISTENT_KEY}"
387        );
388    }
389
390    #[test]
391    fn test_resolve_env_var_plain_string() {
392        assert_eq!(resolve_env_var("plain-key"), "plain-key");
393    }
394
395    #[test]
396    fn test_parse_robit_config() {
397        let toml_str = r#"
398            default_model = "deepseek/deepseek-chat"
399
400            [providers.deepseek]
401            name = "DeepSeek"
402            base_url = "https://api.deepseek.com"
403            api_key = "sk-test-key"
404
405            [[providers.deepseek.models]]
406            id = "deepseek-chat"
407            name = "DeepSeek Chat"
408            context_window = 65536
409            max_output_tokens = 8192
410            temperature = 0.0
411            max_tokens = 4096
412
413            [[providers.deepseek.models]]
414            id = "deepseek-reasoner"
415            name = "DeepSeek Reasoner"
416            context_window = 65536
417            temperature = 0.6
418
419            [providers.qwen]
420            name = "通义千问"
421            base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
422            api_key = "sk-qwen-key"
423
424            [[providers.qwen.models]]
425            id = "qwen-max"
426            name = "Qwen Max"
427            context_window = 32768
428
429            [app]
430            log_level = "DEBUG"
431            max_steps = 10
432            global_storage = true
433
434            [app.context]
435            max_output_lines = 500
436            reserve_ratio = 0.2
437
438            [app.retry]
439            max_retries = 3
440        "#;
441
442        let config: RobitConfig = toml::from_str(toml_str).unwrap();
443
444        // Default model
445        assert_eq!(
446            config.default_model.as_deref(),
447            Some("deepseek/deepseek-chat")
448        );
449
450        // Providers
451        assert_eq!(config.providers.len(), 2);
452
453        // DeepSeek provider
454        let ds = &config.providers["deepseek"];
455        assert_eq!(ds.name.as_deref(), Some("DeepSeek"));
456        assert_eq!(ds.base_url, "https://api.deepseek.com");
457        assert_eq!(ds.api_key, "sk-test-key");
458        assert_eq!(ds.models.len(), 2);
459        assert_eq!(ds.models[0].id, "deepseek-chat");
460        assert_eq!(ds.models[0].context_window, Some(65536));
461        assert_eq!(ds.models[0].temperature, Some(0.0));
462        assert_eq!(ds.models[0].max_tokens, Some(4096));
463        assert_eq!(ds.models[1].id, "deepseek-reasoner");
464        assert_eq!(ds.models[1].temperature, Some(0.6));
465
466        // Qwen provider
467        let qw = &config.providers["qwen"];
468        assert_eq!(qw.name.as_deref(), Some("通义千问"));
469        assert_eq!(qw.models.len(), 1);
470        assert_eq!(qw.models[0].id, "qwen-max");
471
472        // App section
473        let app = config.app.as_ref().unwrap();
474        assert_eq!(app.log_level.as_deref(), Some("DEBUG"));
475        assert_eq!(app.max_steps, Some(10));
476        assert_eq!(app.global_storage, Some(true));
477        assert!(app.context.is_some());
478        assert_eq!(app.context.as_ref().unwrap().max_output_lines, Some(500));
479        assert!(app.retry.is_some());
480        assert_eq!(app.retry.as_ref().unwrap().max_retries, Some(3));
481    }
482
483    #[test]
484    fn test_parse_config_minimal() {
485        let toml_str = r#"
486            [providers.default]
487            base_url = "https://api.deepseek.com"
488            api_key = "sk-test"
489
490            [[providers.default.models]]
491            id = "deepseek-chat"
492        "#;
493
494        let config: RobitConfig = toml::from_str(toml_str).unwrap();
495        assert!(config.default_model.is_none());
496        assert!(config.app.is_none());
497        assert_eq!(config.providers.len(), 1);
498    }
499
500    #[test]
501    fn test_resolve_profile_from_default_model() {
502        let config = make_test_config();
503        let resolved = resolve_profile(&config, None).unwrap();
504        assert_eq!(resolved.profile_name, "deepseek");
505        assert_eq!(resolved.model_id, "deepseek-chat");
506        assert_eq!(resolved.base_url, "https://api.deepseek.com");
507        assert_eq!(resolved.api_key, "sk-test");
508        assert_eq!(resolved.context_window, Some(65536));
509        assert_eq!(resolved.temperature, Some(0.0));
510        assert_eq!(resolved.max_tokens, Some(4096));
511    }
512
513    #[test]
514    fn test_resolve_profile_explicit_provider() {
515        let config = make_test_config();
516        // Explicit provider — uses first model of that provider
517        let resolved = resolve_profile(&config, Some("qwen")).unwrap();
518        assert_eq!(resolved.profile_name, "qwen");
519        assert_eq!(resolved.model_id, "qwen-max");
520        assert_eq!(
521            resolved.base_url,
522            "https://dashscope.aliyuncs.com/compatible-mode/v1"
523        );
524    }
525
526    #[test]
527    fn test_resolve_profile_first_available() {
528        // No default_model and no explicit provider — use first available
529        let toml_str = r#"
530            [providers.deepseek]
531            base_url = "https://api.deepseek.com"
532            api_key = "sk-test"
533
534            [[providers.deepseek.models]]
535            id = "deepseek-chat"
536        "#;
537        let config: RobitConfig = toml::from_str(toml_str).unwrap();
538        let resolved = resolve_profile(&config, None).unwrap();
539        assert_eq!(resolved.profile_name, "deepseek");
540        assert_eq!(resolved.model_id, "deepseek-chat");
541    }
542
543    #[test]
544    fn test_resolve_profile_not_found() {
545        let config = make_test_config();
546        let result = resolve_profile(&config, Some("nonexistent"));
547        assert!(result.is_err());
548    }
549
550    #[test]
551    fn test_resolve_profile_model_not_found() {
552        let toml_str = r#"
553            default_model = "deepseek/nonexistent-model"
554
555            [providers.deepseek]
556            base_url = "https://api.deepseek.com"
557            api_key = "sk-test"
558
559            [[providers.deepseek.models]]
560            id = "deepseek-chat"
561        "#;
562        let config: RobitConfig = toml::from_str(toml_str).unwrap();
563        let result = resolve_profile(&config, None);
564        assert!(result.is_err());
565    }
566
567    #[test]
568    fn test_resolve_profile_invalid_default_model_format() {
569        let toml_str = r#"
570            default_model = "invalid-no-slash"
571
572            [providers.deepseek]
573            base_url = "https://api.deepseek.com"
574            api_key = "sk-test"
575
576            [[providers.deepseek.models]]
577            id = "deepseek-chat"
578        "#;
579        let config: RobitConfig = toml::from_str(toml_str).unwrap();
580        let result = resolve_profile(&config, None);
581        assert!(result.is_err());
582        assert!(result
583            .unwrap_err()
584            .to_string()
585            .contains("Invalid default_model"));
586    }
587
588    #[test]
589    fn test_resolve_profile_empty_api_key() {
590        let toml_str = r#"
591            [providers.deepseek]
592            base_url = "https://api.deepseek.com"
593            api_key = ""
594
595            [[providers.deepseek.models]]
596            id = "deepseek-chat"
597        "#;
598        let config: RobitConfig = toml::from_str(toml_str).unwrap();
599        let result = resolve_profile(&config, None);
600        assert!(result.is_err());
601    }
602
603    #[test]
604    fn test_parse_enabled_skills() {
605        let toml_str = r#"
606            default_model = "deepseek/deepseek-chat"
607
608            [providers.deepseek]
609            base_url = "https://api.deepseek.com"
610            api_key = "sk-test"
611
612            [[providers.deepseek.models]]
613            id = "deepseek-chat"
614
615            [app]
616            enabled_skills = ["code-review", "refactor"]
617        "#;
618
619        let config: RobitConfig = toml::from_str(toml_str).unwrap();
620        let app = config.app.as_ref().unwrap();
621        assert!(app.enabled_skills.is_some());
622        let skills = app.enabled_skills.as_ref().unwrap();
623        assert_eq!(skills.len(), 2);
624        assert_eq!(skills[0], "code-review");
625        assert_eq!(skills[1], "refactor");
626    }
627
628    #[test]
629    fn test_parse_enabled_tools() {
630        let toml_str = r#"
631            default_model = "deepseek/deepseek-chat"
632
633            [providers.deepseek]
634            base_url = "https://api.deepseek.com"
635            api_key = "sk-test"
636
637            [[providers.deepseek.models]]
638            id = "deepseek-chat"
639
640            [app]
641            enabled_tools = ["read", "bash", "edit", "write", "grep", "find", "ls"]
642        "#;
643
644        let config: RobitConfig = toml::from_str(toml_str).unwrap();
645        let app = config.app.as_ref().unwrap();
646        assert!(app.enabled_tools.is_some());
647        let tools = app.enabled_tools.as_ref().unwrap();
648        assert_eq!(tools.len(), 7);
649        assert_eq!(tools[0], "read");
650        assert_eq!(tools[1], "bash");
651        assert_eq!(tools[2], "edit");
652        assert_eq!(tools[3], "write");
653        assert_eq!(tools[4], "grep");
654        assert_eq!(tools[5], "find");
655        assert_eq!(tools[6], "ls");
656    }
657
658    #[test]
659    fn test_parse_auto_approve() {
660        let toml_str = r#"
661            default_model = "deepseek/deepseek-chat"
662
663            [providers.deepseek]
664            base_url = "https://api.deepseek.com"
665            api_key = "sk-test"
666
667            [[providers.deepseek.models]]
668            id = "deepseek-chat"
669
670            [app]
671            auto_approve = true
672        "#;
673
674        let config: RobitConfig = toml::from_str(toml_str).unwrap();
675        let app = config.app.as_ref().unwrap();
676        assert_eq!(app.auto_approve, Some(true));
677    }
678
679    #[test]
680    fn test_parse_auto_approve_default_none() {
681        let toml_str = r#"
682            default_model = "deepseek/deepseek-chat"
683
684            [providers.deepseek]
685            base_url = "https://api.deepseek.com"
686            api_key = "sk-test"
687
688            [[providers.deepseek.models]]
689            id = "deepseek-chat"
690
691            [app]
692        "#;
693
694        let config: RobitConfig = toml::from_str(toml_str).unwrap();
695        let app = config.app.as_ref().unwrap();
696        assert_eq!(app.auto_approve, None);
697    }
698
699    fn make_test_config() -> RobitConfig {
700        let toml_str = r#"
701            default_model = "deepseek/deepseek-chat"
702
703            [providers.deepseek]
704            base_url = "https://api.deepseek.com"
705            api_key = "sk-test"
706
707            [[providers.deepseek.models]]
708            id = "deepseek-chat"
709            context_window = 65536
710            temperature = 0.0
711            max_tokens = 4096
712
713            [providers.qwen]
714            base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
715            api_key = "sk-qwen-test"
716
717            [[providers.qwen.models]]
718            id = "qwen-max"
719            context_window = 32768
720        "#;
721
722        toml::from_str(toml_str).unwrap()
723    }
724
725    #[test]
726    fn test_parse_channels_and_bot_sections() {
727        let toml_str = r#"
728            default_model = "deepseek/deepseek-chat"
729
730            [providers.deepseek]
731            base_url = "https://api.deepseek.com"
732            api_key = "sk-test"
733
734            [[providers.deepseek.models]]
735            id = "deepseek-chat"
736
737            [channels.qq_bot]
738            app_id = "123456789"
739            app_secret = "secret-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
761        // app.bot
762        let bot = config.app.as_ref().unwrap().bot.as_ref().unwrap();
763        assert_eq!(bot.confirm_timeout_secs, Some(60));
764        assert_eq!(bot.session_timeout_minutes, Some(30));
765        let kw = bot.confirm_keywords.as_ref().unwrap();
766        assert_eq!(kw.approve.as_ref().unwrap(), &vec!["确认".to_string(), "yes".to_string()]);
767        assert_eq!(kw.reject.as_ref().unwrap(), &vec!["取消".to_string(), "no".to_string()]);
768    }
769
770    #[test]
771    fn test_config_without_channels_still_parses() {
772        let toml_str = r#"
773            [providers.deepseek]
774            base_url = "https://api.deepseek.com"
775            api_key = "sk-test"
776
777            [[providers.deepseek.models]]
778            id = "deepseek-chat"
779        "#;
780
781        let config: RobitConfig = toml::from_str(toml_str).unwrap();
782        assert!(config.channels.is_none());
783        assert!(config.app.is_none() || config.app.as_ref().unwrap().bot.is_none());
784    }
785}