Skip to main content

edict/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::Context;
5use rand::seq::{IndexedRandom, SliceRandom};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::error::ExitError;
10
11/// Config file name constants.
12pub const CONFIG_TOML: &str = ".edict.toml";
13/// Legacy config name from the botbox era — accepted on read, migrated to CONFIG_TOML on sync.
14pub const CONFIG_TOML_LEGACY: &str = ".botbox.toml";
15pub const CONFIG_JSON: &str = ".botbox.json";
16
17/// Find the config file path, preferring the current name over legacy names.
18/// Returns None if none exist.
19pub fn find_config(dir: &Path) -> Option<PathBuf> {
20    // Current name
21    let toml_path = dir.join(CONFIG_TOML);
22    if toml_path.exists() {
23        return Some(toml_path);
24    }
25    // Legacy TOML name (botbox era, migrated to .edict.toml on sync)
26    let legacy_toml_path = dir.join(CONFIG_TOML_LEGACY);
27    if legacy_toml_path.exists() {
28        return Some(legacy_toml_path);
29    }
30    // Oldest legacy JSON name
31    let json_path = dir.join(CONFIG_JSON);
32    if json_path.exists() {
33        return Some(json_path);
34    }
35    None
36}
37
38/// Find config in the standard locations: direct path, then ws/default/.
39/// Returns (config_path, config_dir) or an error.
40///
41/// Priority order (highest first):
42/// 1. Root `.edict.toml` — current canonical name
43/// 2. `ws/default/.edict.toml` — maw v2 bare repo, current name
44/// 3. Root `.botbox.toml` — legacy name, migrated to .edict.toml on sync
45/// 4. `ws/default/.botbox.toml` — maw v2 bare repo, legacy name
46/// 5. Root `.botbox.json` — oldest legacy format
47/// 6. `ws/default/.botbox.json` — maw v2 oldest legacy
48///
49/// In maw v2 bare repos, the TOML migration runs inside ws/default. This can leave a
50/// stale `.botbox.json` at the bare root (with wrong project name / agent identity) that
51/// would previously shadow the correct ws/default TOML. By checking ws/default TOML before
52/// root JSON we ensure agents always load the current, migrated config.
53pub fn find_config_in_project(root: &Path) -> anyhow::Result<(PathBuf, PathBuf)> {
54    let ws_default = root.join("ws/default");
55
56    // 1. Root .edict.toml — current canonical name
57    let root_toml = root.join(CONFIG_TOML);
58    if root_toml.exists() {
59        return Ok((root_toml, root.to_path_buf()));
60    }
61
62    // 2. ws/default .edict.toml — maw v2 bare repo, current name
63    let ws_toml = ws_default.join(CONFIG_TOML);
64    if ws_toml.exists() {
65        return Ok((ws_toml, ws_default));
66    }
67
68    // 3. Root .botbox.toml — legacy name, will be migrated on sync
69    let root_legacy_toml = root.join(CONFIG_TOML_LEGACY);
70    if root_legacy_toml.exists() {
71        return Ok((root_legacy_toml, root.to_path_buf()));
72    }
73
74    // 4. ws/default .botbox.toml — maw v2 legacy
75    let ws_legacy_toml = ws_default.join(CONFIG_TOML_LEGACY);
76    if ws_legacy_toml.exists() {
77        return Ok((ws_legacy_toml, ws_default));
78    }
79
80    // 5. Root JSON — oldest legacy format
81    let root_json = root.join(CONFIG_JSON);
82    if root_json.exists() {
83        return Ok((root_json, root.to_path_buf()));
84    }
85
86    // 6. ws/default JSON — maw v2 not yet migrated
87    let ws_json = ws_default.join(CONFIG_JSON);
88    if ws_json.exists() {
89        return Ok((ws_json, ws_default));
90    }
91
92    anyhow::bail!(
93        "no .edict.toml or .botbox.toml found in {} or ws/default/",
94        root.display()
95    )
96}
97
98/// Top-level .botbox.toml config.
99///
100/// All structs use snake_case (TOML native) with `alias` attributes for
101/// backwards compatibility when loading legacy camelCase JSON configs.
102#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
103pub struct Config {
104    pub version: String,
105    pub project: ProjectConfig,
106    #[serde(default)]
107    pub tools: ToolsConfig,
108    #[serde(default)]
109    pub review: ReviewConfig,
110    #[serde(default, alias = "pushMain")]
111    pub push_main: bool,
112    #[serde(default)]
113    pub agents: AgentsConfig,
114    #[serde(default)]
115    pub models: ModelsConfig,
116    /// Environment variables to pass to all spawned agents.
117    /// Values support shell variable expansion (e.g. `$HOME`, `${HOME}`).
118    #[serde(default)]
119    pub env: HashMap<String, String>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123pub struct ProjectConfig {
124    pub name: String,
125    #[serde(default, rename = "type")]
126    pub project_type: Vec<String>,
127    #[serde(default)]
128    pub languages: Vec<String>,
129    #[serde(default, alias = "defaultAgent")]
130    pub default_agent: Option<String>,
131    #[serde(default)]
132    pub channel: Option<String>,
133    #[serde(default, alias = "installCommand")]
134    pub install_command: Option<String>,
135    #[serde(default, alias = "checkCommand")]
136    pub check_command: Option<String>,
137    #[serde(default, alias = "criticalApprovers")]
138    pub critical_approvers: Option<Vec<String>>,
139}
140
141#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
142pub struct ToolsConfig {
143    #[serde(default, alias = "beads")]
144    pub bones: bool,
145    #[serde(default)]
146    pub maw: bool,
147    #[serde(default)]
148    pub crit: bool,
149    #[serde(default)]
150    pub botbus: bool,
151    #[serde(default, alias = "botty")]
152    pub vessel: bool,
153}
154
155impl ToolsConfig {
156    /// Returns a list of enabled tool names
157    pub fn enabled_tools(&self) -> Vec<String> {
158        let mut tools = Vec::new();
159        if self.bones {
160            tools.push("bones".to_string());
161        }
162        if self.maw {
163            tools.push("maw".to_string());
164        }
165        if self.crit {
166            tools.push("crit".to_string());
167        }
168        if self.botbus {
169            tools.push("botbus".to_string());
170        }
171        if self.vessel {
172            tools.push("vessel".to_string());
173        }
174        tools
175    }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
179pub struct ReviewConfig {
180    #[serde(default)]
181    pub enabled: bool,
182    #[serde(default)]
183    pub reviewers: Vec<String>,
184}
185
186/// Model tier configuration for cross-provider load balancing.
187///
188/// Each tier maps to a list of `provider/model:thinking` strings.
189/// When an agent config specifies a tier name (e.g. "fast"), `resolve_model()`
190/// randomly picks one model from that tier's pool.
191#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
192pub struct ModelsConfig {
193    #[serde(default = "default_tier_fast")]
194    pub fast: Vec<String>,
195    #[serde(default = "default_tier_balanced")]
196    pub balanced: Vec<String>,
197    #[serde(default = "default_tier_strong")]
198    pub strong: Vec<String>,
199}
200
201impl Default for ModelsConfig {
202    fn default() -> Self {
203        Self {
204            fast: default_tier_fast(),
205            balanced: default_tier_balanced(),
206            strong: default_tier_strong(),
207        }
208    }
209}
210
211fn default_tier_fast() -> Vec<String> {
212    vec![
213        "anthropic/claude-haiku-4-5:low".into(),
214        "google-gemini-cli/gemini-3-flash-preview:low".into(),
215        "openai-codex/gpt-5.3-codex-spark:low".into(),
216    ]
217}
218
219fn default_tier_balanced() -> Vec<String> {
220    vec![
221        "anthropic/claude-sonnet-4-6:medium".into(),
222        "google-gemini-cli/gemini-3-pro-preview:medium".into(),
223        "openai-codex/gpt-5.3-codex:medium".into(),
224    ]
225}
226
227fn default_tier_strong() -> Vec<String> {
228    vec![
229        "anthropic/claude-opus-4-6:high".into(),
230        "openai-codex/gpt-5.3-codex:xhigh".into(),
231    ]
232}
233
234#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
235pub struct AgentsConfig {
236    #[serde(default)]
237    pub dev: Option<DevAgentConfig>,
238    #[serde(default)]
239    pub worker: Option<WorkerAgentConfig>,
240    #[serde(default)]
241    pub reviewer: Option<ReviewerAgentConfig>,
242    #[serde(default)]
243    pub responder: Option<ResponderAgentConfig>,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
247pub struct DevAgentConfig {
248    #[serde(default = "default_model_dev")]
249    pub model: String,
250    #[serde(default = "default_max_loops", alias = "maxLoops")]
251    pub max_loops: u32,
252    #[serde(default = "default_pause")]
253    pub pause: u32,
254    #[serde(default = "default_timeout_3600")]
255    pub timeout: u64,
256    #[serde(default = "default_missions")]
257    pub missions: Option<MissionsConfig>,
258    #[serde(default = "default_multi_lead", alias = "multiLead")]
259    pub multi_lead: Option<MultiLeadConfig>,
260    /// Memory limit for dev-loop agents (e.g. "4G", "2G"). Passed as --memory-limit to vessel spawn.
261    #[serde(default)]
262    pub memory_limit: Option<String>,
263}
264
265impl Default for DevAgentConfig {
266    fn default() -> Self {
267        Self {
268            model: default_model_dev(),
269            max_loops: default_max_loops(),
270            pause: default_pause(),
271            timeout: default_timeout_3600(),
272            missions: default_missions(),
273            multi_lead: default_multi_lead(),
274            memory_limit: None,
275        }
276    }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
280pub struct MissionsConfig {
281    #[serde(default = "default_true")]
282    pub enabled: bool,
283    #[serde(default = "default_max_workers", alias = "maxWorkers")]
284    pub max_workers: u32,
285    #[serde(default = "default_max_children", alias = "maxChildren")]
286    pub max_children: u32,
287    #[serde(
288        default = "default_checkpoint_interval",
289        alias = "checkpointIntervalSec"
290    )]
291    pub checkpoint_interval_sec: u64,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
295pub struct MultiLeadConfig {
296    #[serde(default = "default_true")]
297    pub enabled: bool,
298    #[serde(default = "default_max_leads", alias = "maxLeads")]
299    pub max_leads: u32,
300    #[serde(default = "default_merge_timeout", alias = "mergeTimeoutSec")]
301    pub merge_timeout_sec: u64,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
305pub struct WorkerAgentConfig {
306    #[serde(default = "default_model_worker")]
307    pub model: String,
308    #[serde(default = "default_timeout_900")]
309    pub timeout: u64,
310    /// Memory limit for worker agents (e.g. "4G", "2G"). Passed as --memory-limit to vessel spawn.
311    #[serde(default)]
312    pub memory_limit: Option<String>,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
316pub struct ReviewerAgentConfig {
317    #[serde(default = "default_model_reviewer")]
318    pub model: String,
319    #[serde(default = "default_max_loops", alias = "maxLoops")]
320    pub max_loops: u32,
321    #[serde(default = "default_pause")]
322    pub pause: u32,
323    #[serde(default = "default_timeout_900")]
324    pub timeout: u64,
325    /// Memory limit for reviewer agents (e.g. "4G", "2G"). Passed as --memory-limit to vessel spawn.
326    #[serde(default)]
327    pub memory_limit: Option<String>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
331pub struct ResponderAgentConfig {
332    #[serde(default = "default_model_responder")]
333    pub model: String,
334    #[serde(default = "default_timeout_300")]
335    pub timeout: u64,
336    #[serde(default = "default_timeout_300")]
337    pub wait_timeout: u64,
338    #[serde(default = "default_max_conversations", alias = "maxConversations")]
339    pub max_conversations: u32,
340    /// Memory limit for responder agents (e.g. "4G", "2G"). Passed as --memory-limit to vessel spawn.
341    #[serde(default)]
342    pub memory_limit: Option<String>,
343}
344
345// Default value functions for serde
346fn default_model_dev() -> String {
347    "opus".into()
348}
349fn default_model_worker() -> String {
350    "balanced".into()
351}
352fn default_model_reviewer() -> String {
353    "strong".into()
354}
355fn default_model_responder() -> String {
356    "balanced".into()
357}
358fn default_max_loops() -> u32 {
359    100
360}
361fn default_pause() -> u32 {
362    2
363}
364fn default_timeout_300() -> u64 {
365    300
366}
367fn default_timeout_900() -> u64 {
368    900
369}
370fn default_timeout_3600() -> u64 {
371    3600
372}
373fn default_true() -> bool {
374    true
375}
376fn default_max_workers() -> u32 {
377    4
378}
379fn default_max_children() -> u32 {
380    12
381}
382fn default_checkpoint_interval() -> u64 {
383    30
384}
385fn default_max_leads() -> u32 {
386    3
387}
388fn default_merge_timeout() -> u64 {
389    120
390}
391fn default_max_conversations() -> u32 {
392    10
393}
394fn default_missions() -> Option<MissionsConfig> {
395    Some(MissionsConfig::default())
396}
397fn default_multi_lead() -> Option<MultiLeadConfig> {
398    Some(MultiLeadConfig::default())
399}
400
401impl Default for MissionsConfig {
402    fn default() -> Self {
403        Self {
404            enabled: true,
405            max_workers: default_max_workers(),
406            max_children: default_max_children(),
407            checkpoint_interval_sec: default_checkpoint_interval(),
408        }
409    }
410}
411
412impl Default for MultiLeadConfig {
413    fn default() -> Self {
414        Self {
415            enabled: true,
416            max_leads: default_max_leads(),
417            merge_timeout_sec: default_merge_timeout(),
418        }
419    }
420}
421
422impl Config {
423    /// Load config from a file (TOML or JSON, auto-detected by extension).
424    pub fn load(path: &Path) -> anyhow::Result<Self> {
425        let contents =
426            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
427        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
428        match ext {
429            "toml" => Self::parse_toml(&contents),
430            "json" => Self::parse_json(&contents),
431            _ => {
432                // Try TOML first, then JSON
433                Self::parse_toml(&contents).or_else(|_| Self::parse_json(&contents))
434            }
435        }
436    }
437
438    /// Parse config from a TOML string.
439    pub fn parse_toml(toml_str: &str) -> anyhow::Result<Self> {
440        toml::from_str(toml_str)
441            .map_err(|e| ExitError::Config(format!("invalid .edict.toml: {e}")).into())
442    }
443
444    /// Parse config from a JSON string (for backwards compatibility).
445    pub fn parse_json(json: &str) -> anyhow::Result<Self> {
446        serde_json::from_str(json)
447            .map_err(|e| ExitError::Config(format!("invalid .botbox.json: {e}")).into())
448    }
449
450    /// Serialize config to a TOML string with helpful comments.
451    pub fn to_toml(&self) -> anyhow::Result<String> {
452        let raw = toml::to_string_pretty(self).context("serializing config to TOML")?;
453
454        // Use toml_edit to add comments for default values
455        let mut doc: toml_edit::DocumentMut = raw
456            .parse()
457            .context("parsing generated TOML for comment injection")?;
458
459        // Add header comment with taplo schema reference for editor autocomplete
460        doc.decor_mut().set_prefix(
461            "#:schema https://raw.githubusercontent.com/bobisme/edict/main/schemas/edict.schema.json\n\
462             # Edict project configuration\n\
463             # Schema: https://github.com/bobisme/edict/blob/main/schemas/edict.schema.json\n\n",
464        );
465
466        // Add comments before section headers using item decor
467        fn set_table_comment(doc: &mut toml_edit::DocumentMut, key: &str, comment: &str) {
468            if let Some(item) = doc.get_mut(key) {
469                if let Some(tbl) = item.as_table_mut() {
470                    tbl.decor_mut().set_prefix(comment);
471                }
472            }
473        }
474
475        set_table_comment(&mut doc, "tools", "\n# Companion tools to enable\n");
476        set_table_comment(&mut doc, "review", "\n# Code review configuration\n");
477        set_table_comment(
478            &mut doc,
479            "agents",
480            "\n# Agent configuration (omit sections to use defaults)\n",
481        );
482        set_table_comment(
483            &mut doc,
484            "models",
485            "\n# Model tier pools for load balancing\n# Each tier maps to a list of \"provider/model:thinking\" strings\n",
486        );
487        set_table_comment(
488            &mut doc,
489            "env",
490            "\n# Environment variables passed to all spawned agents\n# Values support shell variable expansion ($HOME, ${HOME})\n# Set OTEL_EXPORTER_OTLP_ENDPOINT to enable telemetry: \"stderr\" for JSON to stderr, \"http://host:port\" for OTLP HTTP\n",
491        );
492
493        Ok(doc.to_string())
494    }
495
496    /// Returns the effective agent name (project.default_agent or "{name}-dev").
497    pub fn default_agent(&self) -> String {
498        self.project
499            .default_agent
500            .clone()
501            .unwrap_or_else(|| format!("{}-dev", self.project.name))
502    }
503
504    /// Returns the effective channel name (project.channel or project.name).
505    pub fn channel(&self) -> String {
506        self.project
507            .channel
508            .clone()
509            .unwrap_or_else(|| self.project.name.clone())
510    }
511
512    /// Returns env vars with shell variables expanded (e.g. `$HOME` → `/home/user`).
513    ///
514    /// Also propagates `OTEL_EXPORTER_OTLP_ENDPOINT` from the process environment if set and
515    /// not already defined in the config, so telemetry flows through to spawned agents.
516    pub fn resolved_env(&self) -> HashMap<String, String> {
517        let mut env: HashMap<String, String> = self
518            .env
519            .iter()
520            .map(|(k, v)| (k.clone(), expand_env_value(v)))
521            .collect();
522
523        // Auto-propagate telemetry endpoint to child agents
524        if !env.contains_key("OTEL_EXPORTER_OTLP_ENDPOINT") {
525            if let Ok(val) = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT") {
526                env.insert("OTEL_EXPORTER_OTLP_ENDPOINT".into(), val);
527            }
528        }
529
530        env
531    }
532
533    /// Resolve a model string to the full pool of models for that tier.
534    /// Tier names (fast/balanced/strong) return a shuffled Vec of all models in the pool.
535    /// Legacy short names (opus/sonnet/haiku) and explicit model strings return a single-element Vec.
536    pub fn resolve_model_pool(&self, model: &str) -> Vec<String> {
537        // Legacy short names -> specific Anthropic models (no fallback pool)
538        match model {
539            "opus" => return vec!["anthropic/claude-opus-4-6:high".to_string()],
540            "sonnet" => return vec!["anthropic/claude-sonnet-4-6:medium".to_string()],
541            "haiku" => return vec!["anthropic/claude-haiku-4-5:low".to_string()],
542            _ => {}
543        }
544
545        // Tier names -> shuffled pool
546        let pool = match model {
547            "fast" => &self.models.fast,
548            "balanced" => &self.models.balanced,
549            "strong" => &self.models.strong,
550            _ => return vec![model.to_string()],
551        };
552
553        if pool.is_empty() {
554            return vec![model.to_string()];
555        }
556
557        let mut pool = pool.clone();
558        pool.shuffle(&mut rand::rng());
559        pool
560    }
561
562    /// Resolve a model string: if it matches a tier name (fast/balanced/strong),
563    /// randomly pick from that tier's pool. Otherwise pass through as-is.
564    pub fn resolve_model(&self, model: &str) -> String {
565        // Legacy short names -> specific Anthropic models (deterministic)
566        match model {
567            "opus" => return "anthropic/claude-opus-4-6:high".to_string(),
568            "sonnet" => return "anthropic/claude-sonnet-4-6:medium".to_string(),
569            "haiku" => return "anthropic/claude-haiku-4-5:low".to_string(),
570            _ => {}
571        }
572
573        // Tier names -> random pool selection
574        let pool = match model {
575            "fast" => &self.models.fast,
576            "balanced" => &self.models.balanced,
577            "strong" => &self.models.strong,
578            _ => return model.to_string(),
579        };
580
581        if pool.is_empty() {
582            return model.to_string();
583        }
584
585        let mut rng = rand::rng();
586        pool.choose(&mut rng)
587            .cloned()
588            .unwrap_or_else(|| model.to_string())
589    }
590}
591
592/// Expand shell-style variable references in a string.
593/// Supports `$VAR` and `${VAR}` syntax. Unknown variables are left as-is.
594fn expand_env_value(value: &str) -> String {
595    let mut result = String::with_capacity(value.len());
596    let mut chars = value.chars().peekable();
597
598    while let Some(c) = chars.next() {
599        if c == '$' {
600            // ${VAR} syntax
601            if chars.peek() == Some(&'{') {
602                chars.next(); // consume '{'
603                let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
604                if let Ok(val) = std::env::var(&var_name) {
605                    result.push_str(&val);
606                } else {
607                    result.push_str(&format!("${{{var_name}}}"));
608                }
609            } else {
610                // $VAR syntax — peek-collect alphanumeric + underscore
611                let mut var_name = String::new();
612                while let Some(&ch) = chars.peek() {
613                    if ch.is_alphanumeric() || ch == '_' {
614                        var_name.push(ch);
615                        chars.next();
616                    } else {
617                        break;
618                    }
619                }
620                if var_name.is_empty() {
621                    result.push('$');
622                } else if let Ok(val) = std::env::var(&var_name) {
623                    result.push_str(&val);
624                } else {
625                    result.push('$');
626                    result.push_str(&var_name);
627                }
628            }
629        } else {
630            result.push(c);
631        }
632    }
633
634    result
635}
636
637/// Convert a JSON config string to TOML format.
638/// Used during migration from .botbox.json to .botbox.toml.
639pub fn json_to_toml(json: &str) -> anyhow::Result<String> {
640    let config = Config::parse_json(json)?;
641    config.to_toml()
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    #[test]
649    fn parse_full_toml_config() {
650        let toml_str = r#"
651version = "1.0.16"
652push_main = false
653
654[project]
655name = "myapp"
656type = ["cli"]
657channel = "myapp"
658install_command = "just install"
659check_command = "cargo clippy && cargo test"
660default_agent = "myapp-dev"
661
662[tools]
663bones = true
664maw = true
665crit = true
666botbus = true
667vessel = true
668
669[review]
670enabled = true
671reviewers = ["security"]
672
673[agents.dev]
674model = "opus"
675max_loops = 20
676pause = 2
677timeout = 900
678
679[agents.worker]
680model = "haiku"
681timeout = 600
682
683[agents.reviewer]
684model = "opus"
685max_loops = 20
686pause = 2
687timeout = 600
688"#;
689
690        let config = Config::parse_toml(toml_str).unwrap();
691        assert_eq!(config.project.name, "myapp");
692        assert_eq!(config.default_agent(), "myapp-dev");
693        assert_eq!(config.channel(), "myapp");
694        assert!(config.tools.bones);
695        assert!(config.tools.maw);
696        assert!(config.review.enabled);
697        assert_eq!(config.review.reviewers, vec!["security"]);
698        assert!(!config.push_main);
699        assert_eq!(
700            config.project.check_command,
701            Some("cargo clippy && cargo test".to_string())
702        );
703
704        let dev = config.agents.dev.unwrap();
705        assert_eq!(dev.model, "opus");
706        assert_eq!(dev.max_loops, 20);
707        assert_eq!(dev.timeout, 900);
708
709        let worker = config.agents.worker.unwrap();
710        assert_eq!(worker.model, "haiku");
711        assert_eq!(worker.timeout, 600);
712    }
713
714    #[test]
715    fn parse_full_json_config_with_camel_case() {
716        let json = r#"{
717            "version": "1.0.16",
718            "project": {
719                "name": "myapp",
720                "type": ["cli"],
721                "channel": "myapp",
722                "installCommand": "just install",
723                "checkCommand": "cargo clippy && cargo test",
724                "defaultAgent": "myapp-dev"
725            },
726            "tools": { "bones": true, "maw": true, "crit": true, "botbus": true, "vessel": true },
727            "review": { "enabled": true, "reviewers": ["security"] },
728            "pushMain": false,
729            "agents": {
730                "dev": { "model": "opus", "maxLoops": 20, "pause": 2, "timeout": 900 },
731                "worker": { "model": "haiku", "timeout": 600 },
732                "reviewer": { "model": "opus", "maxLoops": 20, "pause": 2, "timeout": 600 }
733            }
734        }"#;
735
736        let config = Config::parse_json(json).unwrap();
737        assert_eq!(config.project.name, "myapp");
738        assert_eq!(config.default_agent(), "myapp-dev");
739        assert_eq!(config.channel(), "myapp");
740        assert!(config.tools.bones);
741        assert!(config.review.enabled);
742        assert!(!config.push_main);
743
744        let dev = config.agents.dev.unwrap();
745        assert_eq!(dev.model, "opus");
746        assert_eq!(dev.max_loops, 20);
747    }
748
749    #[test]
750    fn parse_minimal_toml_config() {
751        let toml_str = r#"
752version = "1.0.0"
753
754[project]
755name = "test"
756"#;
757
758        let config = Config::parse_toml(toml_str).unwrap();
759        assert_eq!(config.project.name, "test");
760        assert_eq!(config.default_agent(), "test-dev");
761        assert_eq!(config.channel(), "test");
762        assert!(!config.tools.bones);
763        assert!(!config.review.enabled);
764        assert!(!config.push_main);
765        assert!(config.agents.dev.is_none());
766    }
767
768    #[test]
769    fn parse_missing_optional_fields() {
770        let toml_str = r#"
771version = "1.0.0"
772
773[project]
774name = "bare"
775
776[agents.dev]
777model = "sonnet"
778"#;
779
780        let config = Config::parse_toml(toml_str).unwrap();
781        let dev = config.agents.dev.unwrap();
782        assert_eq!(dev.model, "sonnet");
783        assert_eq!(dev.max_loops, 100); // default
784        assert_eq!(dev.pause, 2); // default
785        assert_eq!(dev.timeout, 3600); // default
786    }
787
788    #[test]
789    fn resolve_model_tier_names() {
790        let config = Config::parse_toml(
791            r#"
792version = "1.0.0"
793[project]
794name = "test"
795"#,
796        )
797        .unwrap();
798
799        let fast = config.resolve_model("fast");
800        assert!(
801            fast.contains('/'),
802            "fast tier should resolve to provider/model, got: {fast}"
803        );
804
805        let balanced = config.resolve_model("balanced");
806        assert!(
807            balanced.contains('/'),
808            "balanced tier should resolve to provider/model, got: {balanced}"
809        );
810
811        let strong = config.resolve_model("strong");
812        assert!(
813            strong.contains('/'),
814            "strong tier should resolve to provider/model, got: {strong}"
815        );
816    }
817
818    #[test]
819    fn resolve_model_passthrough() {
820        let config = Config::parse_toml(
821            r#"
822version = "1.0.0"
823[project]
824name = "test"
825"#,
826        )
827        .unwrap();
828
829        assert_eq!(
830            config.resolve_model("anthropic/claude-sonnet-4-6:medium"),
831            "anthropic/claude-sonnet-4-6:medium"
832        );
833        assert_eq!(
834            config.resolve_model("some-unknown-model"),
835            "some-unknown-model"
836        );
837        assert_eq!(
838            config.resolve_model("opus"),
839            "anthropic/claude-opus-4-6:high"
840        );
841        assert_eq!(
842            config.resolve_model("sonnet"),
843            "anthropic/claude-sonnet-4-6:medium"
844        );
845        assert_eq!(
846            config.resolve_model("haiku"),
847            "anthropic/claude-haiku-4-5:low"
848        );
849    }
850
851    #[test]
852    fn resolve_model_custom_tiers() {
853        let config = Config::parse_toml(
854            r#"
855version = "1.0.0"
856[project]
857name = "test"
858[models]
859fast = ["custom/model-a"]
860balanced = ["custom/model-b"]
861strong = ["custom/model-c"]
862"#,
863        )
864        .unwrap();
865
866        assert_eq!(config.resolve_model("fast"), "custom/model-a");
867        assert_eq!(config.resolve_model("balanced"), "custom/model-b");
868        assert_eq!(config.resolve_model("strong"), "custom/model-c");
869    }
870
871    #[test]
872    fn default_model_tiers() {
873        let config = Config::parse_toml(
874            r#"
875version = "1.0.0"
876[project]
877name = "test"
878"#,
879        )
880        .unwrap();
881
882        assert!(!config.models.fast.is_empty());
883        assert!(!config.models.balanced.is_empty());
884        assert!(!config.models.strong.is_empty());
885    }
886
887    #[test]
888    fn resolve_model_pool_tiers() {
889        let config = Config::parse_toml(
890            r#"
891version = "1.0.0"
892[project]
893name = "test"
894"#,
895        )
896        .unwrap();
897
898        let pool = config.resolve_model_pool("balanced");
899        assert_eq!(pool.len(), 3, "balanced tier should have 3 models");
900        assert!(
901            pool.iter().all(|m| m.contains('/')),
902            "all models should be provider/model format"
903        );
904    }
905
906    #[test]
907    fn resolve_model_pool_legacy_names() {
908        let config = Config::parse_toml(
909            r#"
910version = "1.0.0"
911[project]
912name = "test"
913"#,
914        )
915        .unwrap();
916
917        assert_eq!(
918            config.resolve_model_pool("opus"),
919            vec!["anthropic/claude-opus-4-6:high"]
920        );
921        assert_eq!(
922            config.resolve_model_pool("sonnet"),
923            vec!["anthropic/claude-sonnet-4-6:medium"]
924        );
925        assert_eq!(
926            config.resolve_model_pool("haiku"),
927            vec!["anthropic/claude-haiku-4-5:low"]
928        );
929    }
930
931    #[test]
932    fn resolve_model_pool_explicit_model() {
933        let config = Config::parse_toml(
934            r#"
935version = "1.0.0"
936[project]
937name = "test"
938"#,
939        )
940        .unwrap();
941
942        assert_eq!(
943            config.resolve_model_pool("anthropic/claude-sonnet-4-6:medium"),
944            vec!["anthropic/claude-sonnet-4-6:medium"]
945        );
946    }
947
948    #[test]
949    fn parse_malformed_toml() {
950        let result = Config::parse_toml("not valid toml [[[");
951        assert!(result.is_err());
952        let err = result.unwrap_err();
953        assert!(err.to_string().contains("invalid .edict.toml"));
954    }
955
956    #[test]
957    fn parse_malformed_json() {
958        let result = Config::parse_json("not json");
959        assert!(result.is_err());
960        let err = result.unwrap_err();
961        assert!(err.to_string().contains("invalid .botbox.json"));
962    }
963
964    #[test]
965    fn parse_missing_required_fields() {
966        let toml_str = r#"version = "1.0.0""#;
967        let result = Config::parse_toml(toml_str);
968        assert!(result.is_err());
969    }
970
971    #[test]
972    fn roundtrip_toml() {
973        let toml_str = r#"
974version = "1.0.16"
975
976[project]
977name = "myapp"
978type = ["cli"]
979default_agent = "myapp-dev"
980channel = "myapp"
981install_command = "just install"
982
983[tools]
984bones = true
985maw = true
986crit = true
987botbus = true
988vessel = true
989"#;
990
991        let config = Config::parse_toml(toml_str).unwrap();
992        let output = config.to_toml().unwrap();
993        let config2 = Config::parse_toml(&output).unwrap();
994        assert_eq!(config.project.name, config2.project.name);
995        assert_eq!(config.project.default_agent, config2.project.default_agent);
996        assert_eq!(config.tools.bones, config2.tools.bones);
997    }
998
999    #[test]
1000    fn json_to_toml_conversion() {
1001        let json = r#"{
1002            "version": "1.0.16",
1003            "project": {
1004                "name": "test",
1005                "type": ["cli"],
1006                "defaultAgent": "test-dev",
1007                "channel": "test"
1008            },
1009            "tools": { "bones": true, "maw": true },
1010            "pushMain": false
1011        }"#;
1012
1013        let toml_str = json_to_toml(json).unwrap();
1014        let config = Config::parse_toml(&toml_str).unwrap();
1015        assert_eq!(config.project.name, "test");
1016        assert_eq!(config.project.default_agent, Some("test-dev".to_string()));
1017        assert!(config.tools.bones);
1018        assert!(config.tools.maw);
1019        assert!(!config.push_main);
1020    }
1021
1022    #[test]
1023    fn find_config_prefers_edict_toml() {
1024        let dir = tempfile::tempdir().unwrap();
1025        // All three exist — .edict.toml wins
1026        std::fs::write(dir.path().join(".edict.toml"), "").unwrap();
1027        std::fs::write(dir.path().join(".botbox.toml"), "").unwrap();
1028        std::fs::write(dir.path().join(".botbox.json"), "").unwrap();
1029
1030        let found = find_config(dir.path()).unwrap();
1031        assert!(found.to_string_lossy().ends_with(".edict.toml"));
1032    }
1033
1034    #[test]
1035    fn find_config_falls_back_to_legacy_toml() {
1036        let dir = tempfile::tempdir().unwrap();
1037        std::fs::write(dir.path().join(".botbox.toml"), "").unwrap();
1038        std::fs::write(dir.path().join(".botbox.json"), "").unwrap();
1039
1040        let found = find_config(dir.path()).unwrap();
1041        assert!(found.to_string_lossy().ends_with(".botbox.toml"));
1042    }
1043
1044    #[test]
1045    fn find_config_falls_back_to_json() {
1046        let dir = tempfile::tempdir().unwrap();
1047        std::fs::write(dir.path().join(".botbox.json"), "").unwrap();
1048
1049        let found = find_config(dir.path()).unwrap();
1050        assert!(found.to_string_lossy().ends_with(".botbox.json"));
1051    }
1052
1053    #[test]
1054    fn find_config_returns_none_when_missing() {
1055        let dir = tempfile::tempdir().unwrap();
1056        assert!(find_config(dir.path()).is_none());
1057    }
1058
1059    // --- find_config_in_project tests ---
1060
1061    #[test]
1062    fn find_config_in_project_root_toml_preferred() {
1063        let dir = tempfile::tempdir().unwrap();
1064        std::fs::write(dir.path().join(".edict.toml"), "").unwrap();
1065        std::fs::write(dir.path().join(".botbox.json"), "").unwrap();
1066
1067        let (path, config_dir) = find_config_in_project(dir.path()).unwrap();
1068        assert!(path.to_string_lossy().ends_with(".edict.toml"));
1069        assert_eq!(config_dir, dir.path());
1070    }
1071
1072    #[test]
1073    fn find_config_in_project_ws_toml_beats_root_json() {
1074        // maw v2 scenario: stale .botbox.json at bare root, migrated .edict.toml in ws/default
1075        let dir = tempfile::tempdir().unwrap();
1076        let ws_default = dir.path().join("ws/default");
1077        std::fs::create_dir_all(&ws_default).unwrap();
1078
1079        std::fs::write(dir.path().join(".botbox.json"), "").unwrap(); // stale root JSON
1080        std::fs::write(ws_default.join(".edict.toml"), "").unwrap(); // current ws/default TOML
1081
1082        let (path, config_dir) = find_config_in_project(dir.path()).unwrap();
1083        assert!(
1084            path.to_string_lossy().ends_with(".edict.toml"),
1085            "ws/default TOML should beat stale root JSON, got: {path:?}"
1086        );
1087        assert_eq!(config_dir, ws_default);
1088    }
1089
1090    #[test]
1091    fn find_config_in_project_legacy_toml_accepted() {
1092        // Pre-migration: .botbox.toml still present, no .edict.toml yet
1093        let dir = tempfile::tempdir().unwrap();
1094        std::fs::write(dir.path().join(".botbox.toml"), "").unwrap();
1095
1096        let (path, config_dir) = find_config_in_project(dir.path()).unwrap();
1097        assert!(path.to_string_lossy().ends_with(".botbox.toml"));
1098        assert_eq!(config_dir, dir.path());
1099    }
1100
1101    #[test]
1102    fn find_config_in_project_root_json_fallback() {
1103        // Legacy single-workspace: root JSON only
1104        let dir = tempfile::tempdir().unwrap();
1105        std::fs::write(dir.path().join(".botbox.json"), "").unwrap();
1106
1107        let (path, config_dir) = find_config_in_project(dir.path()).unwrap();
1108        assert!(path.to_string_lossy().ends_with(".botbox.json"));
1109        assert_eq!(config_dir, dir.path());
1110    }
1111
1112    #[test]
1113    fn find_config_in_project_ws_json_fallback() {
1114        // maw v2 with JSON not yet migrated
1115        let dir = tempfile::tempdir().unwrap();
1116        let ws_default = dir.path().join("ws/default");
1117        std::fs::create_dir_all(&ws_default).unwrap();
1118        std::fs::write(ws_default.join(".botbox.json"), "").unwrap();
1119
1120        let (path, config_dir) = find_config_in_project(dir.path()).unwrap();
1121        assert!(path.to_string_lossy().ends_with(".botbox.json"));
1122        assert_eq!(config_dir, ws_default);
1123    }
1124
1125    #[test]
1126    fn find_config_in_project_missing() {
1127        let dir = tempfile::tempdir().unwrap();
1128        let result = find_config_in_project(dir.path());
1129        assert!(result.is_err());
1130        assert!(
1131            result
1132                .unwrap_err()
1133                .to_string()
1134                .contains("no .edict.toml or .botbox.toml")
1135        );
1136    }
1137
1138    #[test]
1139    fn to_toml_includes_comments() {
1140        let config = Config::parse_toml(
1141            r#"
1142version = "1.0.0"
1143[project]
1144name = "test"
1145[tools]
1146bones = true
1147"#,
1148        )
1149        .unwrap();
1150        let output = config.to_toml().unwrap();
1151        assert!(output.contains("#:schema https://raw.githubusercontent.com/bobisme/edict"));
1152        assert!(output.contains("# Edict project configuration"));
1153        assert!(output.contains("# Companion tools to enable"));
1154    }
1155
1156    #[test]
1157    fn parse_toml_with_env_section() {
1158        let toml_str = r#"
1159version = "1.0.0"
1160
1161[project]
1162name = "test"
1163
1164[env]
1165CARGO_BUILD_JOBS = "2"
1166RUSTC_WRAPPER = "sccache"
1167"#;
1168
1169        let config = Config::parse_toml(toml_str).unwrap();
1170        assert_eq!(config.env.len(), 2);
1171        assert_eq!(config.env["CARGO_BUILD_JOBS"], "2");
1172        assert_eq!(config.env["RUSTC_WRAPPER"], "sccache");
1173    }
1174
1175    #[test]
1176    fn parse_toml_without_env_section() {
1177        let toml_str = r#"
1178version = "1.0.0"
1179[project]
1180name = "test"
1181"#;
1182        let config = Config::parse_toml(toml_str).unwrap();
1183        assert!(config.env.is_empty());
1184    }
1185
1186    #[test]
1187    fn expand_env_value_dollar_var() {
1188        // Set a test var then expand it
1189        unsafe { std::env::set_var("EDICT_TEST_VAR", "/test/path"); }
1190        assert_eq!(expand_env_value("$EDICT_TEST_VAR/sub"), "/test/path/sub");
1191        assert_eq!(expand_env_value("${EDICT_TEST_VAR}/sub"), "/test/path/sub");
1192        unsafe { std::env::remove_var("EDICT_TEST_VAR"); }
1193    }
1194
1195    #[test]
1196    fn expand_env_value_unset_var_preserved() {
1197        // Unset vars should be left as-is
1198        let result = expand_env_value("$EDICT_NONEXISTENT_VAR_12345");
1199        assert_eq!(result, "$EDICT_NONEXISTENT_VAR_12345");
1200        let result = expand_env_value("${EDICT_NONEXISTENT_VAR_12345}");
1201        assert_eq!(result, "${EDICT_NONEXISTENT_VAR_12345}");
1202    }
1203
1204    #[test]
1205    fn expand_env_value_no_vars() {
1206        assert_eq!(expand_env_value("plain string"), "plain string");
1207        assert_eq!(expand_env_value("/usr/bin/sccache"), "/usr/bin/sccache");
1208    }
1209
1210    #[test]
1211    fn resolved_env_expands_values() {
1212        unsafe { std::env::set_var("EDICT_TEST_HOME", "/home/test"); }
1213        let config = Config::parse_toml(r#"
1214version = "1.0.0"
1215[project]
1216name = "test"
1217[env]
1218SCCACHE_DIR = "$EDICT_TEST_HOME/.cache/sccache"
1219PLAIN = "no-vars"
1220"#).unwrap();
1221        let resolved = config.resolved_env();
1222        assert_eq!(resolved["SCCACHE_DIR"], "/home/test/.cache/sccache");
1223        assert_eq!(resolved["PLAIN"], "no-vars");
1224        unsafe { std::env::remove_var("EDICT_TEST_HOME"); }
1225    }
1226}