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