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