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
11pub const CONFIG_TOML: &str = ".edict.toml";
13pub const CONFIG_TOML_LEGACY: &str = ".botbox.toml";
15pub const CONFIG_JSON: &str = ".botbox.json";
16
17pub fn find_config(dir: &Path) -> Option<PathBuf> {
20 let toml_path = dir.join(CONFIG_TOML);
22 if toml_path.exists() {
23 return Some(toml_path);
24 }
25 let legacy_toml_path = dir.join(CONFIG_TOML_LEGACY);
27 if legacy_toml_path.exists() {
28 return Some(legacy_toml_path);
29 }
30 let json_path = dir.join(CONFIG_JSON);
32 if json_path.exists() {
33 return Some(json_path);
34 }
35 None
36}
37
38pub fn find_config_in_project(root: &Path) -> anyhow::Result<(PathBuf, PathBuf)> {
54 let ws_default = root.join("ws/default");
55
56 let root_toml = root.join(CONFIG_TOML);
58 if root_toml.exists() {
59 return Ok((root_toml, root.to_path_buf()));
60 }
61
62 let ws_toml = ws_default.join(CONFIG_TOML);
64 if ws_toml.exists() {
65 return Ok((ws_toml, ws_default));
66 }
67
68 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 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 let root_json = root.join(CONFIG_JSON);
82 if root_json.exists() {
83 return Ok((root_json, root.to_path_buf()));
84 }
85
86 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#[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 #[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 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#[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 #[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 #[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 #[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 #[serde(default)]
342 pub memory_limit: Option<String>,
343}
344
345fn 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 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 Self::parse_toml(&contents).or_else(|_| Self::parse_json(&contents))
434 }
435 }
436 }
437
438 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 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 pub fn to_toml(&self) -> anyhow::Result<String> {
452 let raw = toml::to_string_pretty(self).context("serializing config to TOML")?;
453
454 let mut doc: toml_edit::DocumentMut = raw
456 .parse()
457 .context("parsing generated TOML for comment injection")?;
458
459 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 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 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 pub fn channel(&self) -> String {
506 self.project
507 .channel
508 .clone()
509 .unwrap_or_else(|| self.project.name.clone())
510 }
511
512 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 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 pub fn resolve_model_pool(&self, model: &str) -> Vec<String> {
537 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 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 pub fn resolve_model(&self, model: &str) -> String {
565 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 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
592fn 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 if chars.peek() == Some(&'{') {
602 chars.next(); 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 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
637pub 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); assert_eq!(dev.pause, 2); assert_eq!(dev.timeout, 3600); }
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 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 #[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 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(); std::fs::write(ws_default.join(".edict.toml"), "").unwrap(); 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 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 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 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 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 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}