loopsmith_core/config/
mod.rs1use serde::{Deserialize, Serialize};
13
14pub mod constraints;
15pub mod context;
16pub mod default_skills;
17pub mod gates;
18pub mod goals;
19pub mod graph;
20pub mod guidelines;
21pub mod info;
22pub mod providers;
23pub mod skills;
24pub mod success;
25pub mod triggers;
26pub mod validation;
27pub mod work;
28
29pub use constraints::{ConstraintSet, Constraints};
30pub use context::ContextPolicy;
31pub use default_skills::{is_safe_repo_url, DefaultSkill, SkillOrigin};
32pub use gates::StopGates;
33pub use goals::Goal;
34pub use graph::{Concurrency, GraphSpec, NodeSpec, Role, Tier};
35pub use guidelines::{parse_chain, ExecutionGuidelines, Guideline, Phase};
36pub use info::InfoItem;
37pub use providers::{ProviderKind, ProviderRouting, ProviderSpec};
38pub use skills::{AcquisitionSource, SkillPolicy};
39pub use success::SuccessScenario;
40pub use triggers::Trigger;
41pub use validation::{CompareOp, Detector, Mode, Validation};
42pub use work::WorkItem;
43
44pub const OVERALL: &str = "overall";
46
47pub(crate) fn yes() -> bool {
50 true
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct LoopConfig {
56 pub name: String,
58 #[serde(default = "default_version")]
59 pub version: String,
60 #[serde(default)]
61 pub description: String,
62
63 #[serde(default)]
65 pub information: Vec<InfoItem>,
66 #[serde(default)]
68 pub pre_execution: Vec<WorkItem>,
69 pub goals: Vec<Goal>,
71 pub validations: Vec<Validation>,
73 #[serde(default)]
75 pub success: Vec<SuccessScenario>,
76 #[serde(default)]
78 pub stop_gates: StopGates,
79 #[serde(default)]
81 pub schedules: Vec<Trigger>,
82 #[serde(default)]
84 pub constraints: Constraints,
85 #[serde(default)]
87 pub execution_guidelines: ExecutionGuidelines,
88 #[serde(default)]
90 pub default_skills: Vec<DefaultSkill>,
91
92 #[serde(default)]
94 pub graph: GraphSpec,
95 #[serde(default)]
98 pub providers: ProviderRouting,
99 #[serde(default)]
101 pub skills: SkillPolicy,
102 #[serde(default)]
104 pub context: ContextPolicy,
105}
106
107fn default_version() -> String {
108 "0.1.0".into()
109}
110
111impl LoopConfig {
112 pub fn goal_names(&self) -> Vec<&str> {
113 self.goals.iter().map(|g| g.name.as_str()).collect()
114 }
115
116 pub fn blocking_validations_for(&self, target: &str) -> Vec<&Validation> {
117 self.validations
118 .iter()
119 .filter(|v| v.target == target && v.blocking)
120 .collect()
121 }
122
123 pub fn provider(&self, id: &str) -> Option<&ProviderSpec> {
124 self.providers.providers.iter().find(|p| p.id == id)
125 }
126
127 pub fn cascade_for(&self, tier: Tier) -> Vec<&ProviderSpec> {
129 let key = match tier {
130 Tier::Cheap => "cheap",
131 Tier::Standard => "standard",
132 Tier::Strong => "strong",
133 };
134 if let Some(ids) = self.providers.cascade.get(key) {
135 return ids.iter().filter_map(|id| self.provider(id)).collect();
136 }
137 self.providers
138 .providers
139 .iter()
140 .filter(|p| p.tiers.is_empty() || p.tiers.contains(&tier))
141 .collect()
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 const MINIMAL: &str = r#"
150name: t
151goals:
152 - name: g1
153 description: a goal with a long enough description
154validations:
155 - target: g1
156 name: v1
157 mode: objective
158 statement: it works
159 detector: { type: file_exists, path: out.txt }
160"#;
161
162 fn parse(text: &str) -> Result<LoopConfig, serde_yaml::Error> {
163 serde_yaml::from_str::<LoopConfig>(text)
164 }
165
166 #[test]
167 fn the_minimal_config_parses() {
168 let cfg = parse(MINIMAL).expect("minimal config parses");
169 assert_eq!(cfg.name, "t");
170 assert_eq!(cfg.version, "0.1.0");
171 assert_eq!(cfg.stop_gates.max_iterations, 10);
172 }
173
174 #[test]
175 fn a_misspelled_top_level_section_is_refused_not_ignored() {
176 let typo = MINIMAL.to_string() + "stop_gate:\n max_iterations: 2\n";
180 let err = parse(&typo).expect_err("a misspelled section must be refused");
181 assert!(
182 err.to_string().contains("stop_gate"),
183 "the error must name the offending key, got: {err}"
184 );
185 }
186
187 #[test]
188 fn a_misspelled_nested_field_is_refused_not_ignored() {
189 let typo = MINIMAL.to_string() + "stop_gates:\n max_iteration: 2\n";
190 let err = parse(&typo).expect_err("a misspelled field must be refused");
191 assert!(err.to_string().contains("max_iteration"), "got: {err}");
192 }
193
194 #[test]
195 fn provider_kind_aliases_still_resolve() {
196 for (written, expected) in [
199 ("claude", ProviderKind::ClaudeCode),
200 ("claude-code", ProviderKind::ClaudeCode),
201 ("openai", ProviderKind::OpenAi),
202 ("open_ai", ProviderKind::OpenAi),
203 ("OpenAI", ProviderKind::OpenAi),
204 ("grok", ProviderKind::GrokCli),
205 ("custom", ProviderKind::Byok),
206 ("MCP", ProviderKind::Mcp),
207 ] {
208 let text = format!(
209 "{MINIMAL}providers:\n providers:\n - id: p\n kind: {written}\n command: echo\n"
210 );
211 let cfg = parse(&text).unwrap_or_else(|e| panic!("`{written}` should parse: {e}"));
212 assert_eq!(cfg.providers.providers[0].kind, expected, "for `{written}`");
213 }
214 }
215}