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}
21
22impl ProjectConfig {
23 pub fn default_for_project(project_id: impl Into<String>) -> Self {
24 Self {
25 kimetsu: KimetsuSection {
26 project_id: project_id.into(),
27 schema_version: KIMETSU_SCHEMA_VERSION,
28 },
29 model: ModelSection::default(),
30 broker: BrokerSection::default(),
31 shell: ShellSection::default(),
32 ingestion: IngestionSection::default(),
33 run: RunSection::default(),
34 embedder: EmbedderSection::default(),
35 }
36 }
37
38 pub fn from_toml(value: &str) -> KimetsuResult<Self> {
39 Ok(toml::from_str(value)?)
40 }
41
42 pub fn to_toml(&self) -> KimetsuResult<String> {
43 Ok(toml::to_string_pretty(self)?)
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct KimetsuSection {
49 pub project_id: String,
50 pub schema_version: i64,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct EmbedderSection {
60 #[serde(default = "default_embedder_id")]
61 pub model: String,
62}
63
64fn default_embedder_id() -> String {
65 "bge-small-en-v1.5".to_string()
66}
67
68impl Default for EmbedderSection {
69 fn default() -> Self {
70 Self {
71 model: default_embedder_id(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ModelSection {
78 pub provider: String,
79 pub model: String,
80 pub api_key_env: String,
81 pub max_output_tokens: u32,
82 pub temperature: f32,
83 pub request_timeout_secs: u64,
84}
85
86impl Default for ModelSection {
87 fn default() -> Self {
88 Self {
89 provider: "anthropic".to_string(),
90 model: "claude-opus-4-7".to_string(),
91 api_key_env: "ANTHROPIC_API_KEY".to_string(),
92 max_output_tokens: 8192,
93 temperature: 0.2,
94 request_timeout_secs: 120,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct BrokerSection {
101 pub default_budget_tokens: u32,
102 pub weights: BrokerWeights,
103}
104
105impl Default for BrokerSection {
106 fn default() -> Self {
107 Self {
108 default_budget_tokens: 6000,
109 weights: BrokerWeights::default(),
110 }
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct BrokerWeights {
116 pub relevance: f32,
117 pub confidence: f32,
118 pub freshness: f32,
119 pub scope: f32,
120 pub localization: Option<StageWeights>,
121 pub patch_plan: Option<StageWeights>,
122 pub verification: Option<StageWeights>,
123 pub review: Option<StageWeights>,
124 #[serde(default = "default_decay_half_life_days")]
135 pub decay_half_life_days: f32,
136}
137
138fn default_decay_half_life_days() -> f32 {
139 30.0
140}
141
142impl Default for BrokerWeights {
143 fn default() -> Self {
144 Self {
145 relevance: 0.50,
146 confidence: 0.20,
147 freshness: 0.20,
148 scope: 0.10,
149 localization: Some(StageWeights {
150 relevance: 0.70,
151 confidence: 0.10,
152 freshness: 0.10,
153 scope: 0.10,
154 }),
155 patch_plan: Some(StageWeights {
156 relevance: 0.40,
157 confidence: 0.30,
158 freshness: 0.10,
159 scope: 0.20,
160 }),
161 verification: Some(StageWeights {
162 relevance: 0.40,
163 confidence: 0.10,
164 freshness: 0.40,
165 scope: 0.10,
166 }),
167 review: Some(StageWeights {
168 relevance: 0.50,
169 confidence: 0.20,
170 freshness: 0.20,
171 scope: 0.10,
172 }),
173 decay_half_life_days: default_decay_half_life_days(),
174 }
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct StageWeights {
180 pub relevance: f32,
181 pub confidence: f32,
182 pub freshness: f32,
183 pub scope: f32,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ShellSection {
188 pub default_timeout_secs: u64,
189 pub max_timeout_secs: u64,
190 pub env_allowlist_extra: Vec<String>,
191 pub redact_secrets: bool,
192}
193
194impl Default for ShellSection {
195 fn default() -> Self {
196 Self {
197 default_timeout_secs: 60,
198 max_timeout_secs: 600,
199 env_allowlist_extra: vec!["RUSTFLAGS".to_string(), "CARGO_HOME".to_string()],
200 redact_secrets: true,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct IngestionSection {
207 pub max_file_bytes: u64,
208 pub extra_skip_dirs: Vec<String>,
209 pub max_total_files: u64,
210}
211
212impl Default for IngestionSection {
213 fn default() -> Self {
214 Self {
215 max_file_bytes: 524_288,
216 extra_skip_dirs: Vec::new(),
217 max_total_files: 50_000,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct RunSection {
224 pub max_total_tool_calls: u32,
225 pub max_total_model_turns: u32,
226 pub max_total_cost_usd: f32,
227}
228
229impl Default for RunSection {
230 fn default() -> Self {
231 Self {
238 max_total_tool_calls: 60,
239 max_total_model_turns: 30,
240 max_total_cost_usd: 250.0,
241 }
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
253 fn pre_v0_8_config_without_embedder_loads_with_default() {
254 let toml = r#"
255[kimetsu]
256project_id = "demo"
257schema_version = 7
258
259[model]
260provider = "anthropic"
261model = "claude-opus-4-7"
262api_key_env = "ANTHROPIC_API_KEY"
263max_output_tokens = 8192
264temperature = 0.2
265request_timeout_secs = 120
266
267[broker]
268default_budget_tokens = 6000
269
270[broker.weights]
271relevance = 0.5
272confidence = 0.2
273freshness = 0.2
274scope = 0.1
275
276[shell]
277default_timeout_secs = 60
278max_timeout_secs = 600
279env_allowlist_extra = []
280redact_secrets = true
281
282[ingestion]
283max_file_bytes = 524288
284extra_skip_dirs = []
285max_total_files = 50000
286
287[run]
288max_total_tool_calls = 60
289max_total_model_turns = 30
290max_total_cost_usd = 250.0
291"#;
292 let config = ProjectConfig::from_toml(toml).expect("pre-v0.8 toml must load");
293 assert_eq!(config.embedder.model, "bge-small-en-v1.5");
294 }
295
296 #[test]
299 fn embedder_survives_toml_round_trip() {
300 let mut config = ProjectConfig::default_for_project("demo");
301 config.embedder.model = "bge-m3".to_string();
302 let serialized = config.to_toml().expect("serialize");
303 let reloaded = ProjectConfig::from_toml(&serialized).expect("reload");
304 assert_eq!(reloaded.embedder.model, "bge-m3");
305 assert_eq!(reloaded.broker.default_budget_tokens, 6000);
306 assert_eq!(reloaded.kimetsu.project_id, "demo");
307 }
308}