1use serde::{Deserialize, Serialize};
2
3use crate::{KIMETSU_SCHEMA_VERSION, KimetsuResult};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ProjectConfig {
7 pub kimetsu: KimetsuSection,
8 pub model: ModelSection,
9 pub broker: BrokerSection,
10 pub shell: ShellSection,
11 pub ingestion: IngestionSection,
12 pub run: RunSection,
13 #[serde(default)]
19 pub embedder: EmbedderSection,
20 #[serde(default)]
24 pub learning: LearningSection,
25}
26
27impl ProjectConfig {
28 pub fn default_for_project(project_id: impl Into<String>) -> Self {
29 Self {
30 kimetsu: KimetsuSection {
31 project_id: project_id.into(),
32 schema_version: KIMETSU_SCHEMA_VERSION,
33 },
34 model: ModelSection::default(),
35 broker: BrokerSection::default(),
36 shell: ShellSection::default(),
37 ingestion: IngestionSection::default(),
38 run: RunSection::default(),
39 embedder: EmbedderSection::default(),
40 learning: LearningSection::default(),
41 }
42 }
43
44 pub fn from_toml(value: &str) -> KimetsuResult<Self> {
45 Ok(toml::from_str(value)?)
46 }
47
48 pub fn to_toml(&self) -> KimetsuResult<String> {
49 Ok(toml::to_string_pretty(self)?)
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct KimetsuSection {
55 pub project_id: String,
56 pub schema_version: i64,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct EmbedderSection {
66 #[serde(default = "default_embedder_id")]
67 pub model: String,
68}
69
70fn default_embedder_id() -> String {
71 "bge-small-en-v1.5".to_string()
72}
73
74impl Default for EmbedderSection {
75 fn default() -> Self {
76 Self {
77 model: default_embedder_id(),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LearningSection {
90 #[serde(default = "default_auto_harvest")]
91 pub auto_harvest: bool,
92 #[serde(default)]
96 pub distiller: DistillerSection,
97}
98
99fn default_auto_harvest() -> bool {
100 true
101}
102
103impl Default for LearningSection {
104 fn default() -> Self {
105 Self {
106 auto_harvest: default_auto_harvest(),
107 distiller: DistillerSection::default(),
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct DistillerSection {
117 #[serde(default)]
118 pub enabled: bool,
119 #[serde(default = "default_distiller_provider")]
120 pub provider: String,
121 #[serde(default = "default_distiller_model")]
122 pub model: String,
123 #[serde(default = "default_distiller_api_key_env")]
124 pub api_key_env: String,
125 #[serde(default = "default_distiller_base_url_env")]
126 pub base_url_env: String,
127}
128
129fn default_distiller_provider() -> String {
130 "anthropic".to_string()
131}
132fn default_distiller_model() -> String {
133 "claude-haiku-4-5".to_string()
134}
135fn default_distiller_api_key_env() -> String {
136 "ANTHROPIC_API_KEY".to_string()
137}
138fn default_distiller_base_url_env() -> String {
139 "ANTHROPIC_BASE_URL".to_string()
140}
141
142impl Default for DistillerSection {
143 fn default() -> Self {
144 Self {
145 enabled: false,
146 provider: default_distiller_provider(),
147 model: default_distiller_model(),
148 api_key_env: default_distiller_api_key_env(),
149 base_url_env: default_distiller_base_url_env(),
150 }
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct ModelSection {
156 pub provider: String,
157 pub model: String,
158 pub api_key_env: String,
159 pub max_output_tokens: u32,
160 pub temperature: f32,
161 pub request_timeout_secs: u64,
162}
163
164impl Default for ModelSection {
165 fn default() -> Self {
166 Self {
167 provider: "anthropic".to_string(),
168 model: "claude-opus-4-7".to_string(),
169 api_key_env: "ANTHROPIC_API_KEY".to_string(),
170 max_output_tokens: 8192,
171 temperature: 0.2,
172 request_timeout_secs: 120,
173 }
174 }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct BrokerSection {
179 pub default_budget_tokens: u32,
180 pub weights: BrokerWeights,
181}
182
183impl Default for BrokerSection {
184 fn default() -> Self {
185 Self {
186 default_budget_tokens: 6000,
187 weights: BrokerWeights::default(),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct BrokerWeights {
194 pub relevance: f32,
195 pub confidence: f32,
196 pub freshness: f32,
197 pub scope: f32,
198 pub localization: Option<StageWeights>,
199 pub patch_plan: Option<StageWeights>,
200 pub verification: Option<StageWeights>,
201 pub review: Option<StageWeights>,
202 #[serde(default = "default_decay_half_life_days")]
213 pub decay_half_life_days: f32,
214}
215
216fn default_decay_half_life_days() -> f32 {
217 30.0
218}
219
220impl Default for BrokerWeights {
221 fn default() -> Self {
222 Self {
223 relevance: 0.50,
224 confidence: 0.20,
225 freshness: 0.20,
226 scope: 0.10,
227 localization: Some(StageWeights {
228 relevance: 0.70,
229 confidence: 0.10,
230 freshness: 0.10,
231 scope: 0.10,
232 }),
233 patch_plan: Some(StageWeights {
234 relevance: 0.40,
235 confidence: 0.30,
236 freshness: 0.10,
237 scope: 0.20,
238 }),
239 verification: Some(StageWeights {
240 relevance: 0.40,
241 confidence: 0.10,
242 freshness: 0.40,
243 scope: 0.10,
244 }),
245 review: Some(StageWeights {
246 relevance: 0.50,
247 confidence: 0.20,
248 freshness: 0.20,
249 scope: 0.10,
250 }),
251 decay_half_life_days: default_decay_half_life_days(),
252 }
253 }
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct StageWeights {
258 pub relevance: f32,
259 pub confidence: f32,
260 pub freshness: f32,
261 pub scope: f32,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ShellSection {
266 pub default_timeout_secs: u64,
267 pub max_timeout_secs: u64,
268 pub env_allowlist_extra: Vec<String>,
269 pub redact_secrets: bool,
270}
271
272impl Default for ShellSection {
273 fn default() -> Self {
274 Self {
275 default_timeout_secs: 60,
276 max_timeout_secs: 600,
277 env_allowlist_extra: vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()],
278 redact_secrets: true,
279 }
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct IngestionSection {
285 pub max_file_bytes: u64,
286 pub extra_skip_dirs: Vec<String>,
287 pub max_total_files: u64,
288}
289
290impl Default for IngestionSection {
291 fn default() -> Self {
292 Self {
293 max_file_bytes: 524_288,
294 extra_skip_dirs: Vec::new(),
295 max_total_files: 50_000,
296 }
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct RunSection {
302 pub max_total_tool_calls: u32,
303 pub max_total_model_turns: u32,
304 pub max_total_cost_usd: f32,
305}
306
307impl Default for RunSection {
308 fn default() -> Self {
309 Self {
316 max_total_tool_calls: 60,
317 max_total_model_turns: 30,
318 max_total_cost_usd: 250.0,
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
331 fn pre_v0_8_config_without_embedder_loads_with_default() {
332 let toml = r#"
333[kimetsu]
334project_id = "demo"
335schema_version = 7
336
337[model]
338provider = "anthropic"
339model = "claude-opus-4-7"
340api_key_env = "ANTHROPIC_API_KEY"
341max_output_tokens = 8192
342temperature = 0.2
343request_timeout_secs = 120
344
345[broker]
346default_budget_tokens = 6000
347
348[broker.weights]
349relevance = 0.5
350confidence = 0.2
351freshness = 0.2
352scope = 0.1
353
354[shell]
355default_timeout_secs = 60
356max_timeout_secs = 600
357env_allowlist_extra = []
358redact_secrets = true
359
360[ingestion]
361max_file_bytes = 524288
362extra_skip_dirs = []
363max_total_files = 50000
364
365[run]
366max_total_tool_calls = 60
367max_total_model_turns = 30
368max_total_cost_usd = 250.0
369"#;
370 let config = ProjectConfig::from_toml(toml).expect("pre-v0.8 toml must load");
371 assert_eq!(config.embedder.model, "bge-small-en-v1.5");
372 assert!(config.learning.auto_harvest);
375 assert!(!config.learning.distiller.enabled);
378 assert_eq!(config.learning.distiller.provider, "anthropic");
379 assert_eq!(config.learning.distiller.model, "claude-haiku-4-5");
380 assert_eq!(config.learning.distiller.api_key_env, "ANTHROPIC_API_KEY");
381 assert_eq!(config.learning.distiller.base_url_env, "ANTHROPIC_BASE_URL");
382 }
383
384 #[test]
387 fn embedder_survives_toml_round_trip() {
388 let mut config = ProjectConfig::default_for_project("demo");
389 config.embedder.model = "bge-m3".to_string();
390 let serialized = config.to_toml().expect("serialize");
391 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
392 assert_eq!(reloaded.embedder.model, "bge-m3");
393 assert_eq!(reloaded.broker.default_budget_tokens, 6000);
394 assert_eq!(reloaded.kimetsu.project_id, "demo");
395 }
396}