1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::error::{Result, SqzError};
5
6pub struct PresetParser;
45
46impl PresetParser {
47 pub fn parse(toml_str: &str) -> Result<Preset> {
49 let preset: Preset = toml::from_str(toml_str)?;
50 Self::validate(&preset)?;
51 Ok(preset)
52 }
53
54 pub fn to_toml(preset: &Preset) -> Result<String> {
56 Ok(toml::to_string_pretty(preset)?)
57 }
58
59 pub fn validate(preset: &Preset) -> Result<()> {
61 if preset.preset.name.is_empty() {
62 return Err(SqzError::PresetValidation {
63 field: "preset.name".to_string(),
64 message: "must not be empty".to_string(),
65 });
66 }
67
68 if preset.preset.version.is_empty() {
69 return Err(SqzError::PresetValidation {
70 field: "preset.version".to_string(),
71 message: "must not be empty".to_string(),
72 });
73 }
74
75 let wt = preset.budget.warning_threshold;
76 if !(wt > 0.0 && wt < 1.0) {
77 return Err(SqzError::PresetValidation {
78 field: "budget.warning_threshold".to_string(),
79 message: "must be between 0.0 and 1.0".to_string(),
80 });
81 }
82
83 let ct = preset.budget.ceiling_threshold;
84 if !(ct > 0.0 && ct < 1.0) || ct <= wt {
85 return Err(SqzError::PresetValidation {
86 field: "budget.ceiling_threshold".to_string(),
87 message: "must be between 0.0 and 1.0 and greater than warning_threshold"
88 .to_string(),
89 });
90 }
91
92 let max_tools = preset.tool_selection.max_tools;
93 if !(1..=50).contains(&max_tools) {
94 return Err(SqzError::PresetValidation {
95 field: "tool_selection.max_tools".to_string(),
96 message: "must be between 1 and 50".to_string(),
97 });
98 }
99
100 let st = preset.tool_selection.similarity_threshold;
101 if !(st > 0.0 && st < 1.0) {
102 return Err(SqzError::PresetValidation {
103 field: "tool_selection.similarity_threshold".to_string(),
104 message: "must be between 0.0 and 1.0".to_string(),
105 });
106 }
107
108 let cxt = preset.model.complexity_threshold;
109 if !(cxt > 0.0 && cxt < 1.0) {
110 return Err(SqzError::PresetValidation {
111 field: "model.complexity_threshold".to_string(),
112 message: "must be between 0.0 and 1.0".to_string(),
113 });
114 }
115
116 Ok(())
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Preset {
127 pub preset: PresetHeader,
128 pub compression: CompressionConfig,
129 pub tool_selection: ToolSelectionConfig,
130 pub budget: BudgetConfig,
131 pub terse_mode: TerseModeConfig,
132 pub model: ModelConfig,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PresetHeader {
138 pub name: String,
140 pub version: String,
142 #[serde(default)]
144 pub description: String,
145}
146
147pub type PresetMeta = PresetHeader;
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct CompressionConfig {
154 #[serde(default)]
155 pub stages: Vec<String>,
156 pub keep_fields: Option<KeepFieldsConfig>,
157 pub strip_fields: Option<StripFieldsConfig>,
158 pub condense: Option<CondenseConfig>,
159 pub git_diff_fold: Option<GitDiffFoldConfig>,
160 pub strip_nulls: Option<StripNullsConfig>,
161 pub flatten: Option<FlattenConfig>,
162 pub truncate_strings: Option<TruncateStringsConfig>,
163 pub collapse_arrays: Option<CollapseArraysConfig>,
164 pub custom_transforms: Option<CustomTransformsConfig>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct GitDiffFoldConfig {
169 pub enabled: bool,
170 #[serde(default = "default_max_context_lines")]
171 pub max_context_lines: u32,
172}
173
174fn default_max_context_lines() -> u32 {
175 2
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct KeepFieldsConfig {
180 pub enabled: bool,
181 #[serde(default)]
182 pub fields: Vec<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct StripFieldsConfig {
187 pub enabled: bool,
188 #[serde(default)]
189 pub fields: Vec<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct CondenseConfig {
194 pub enabled: bool,
195 #[serde(default = "default_max_repeated_lines")]
196 pub max_repeated_lines: u32,
197}
198
199fn default_max_repeated_lines() -> u32 {
200 3
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct StripNullsConfig {
205 pub enabled: bool,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct FlattenConfig {
210 pub enabled: bool,
211 #[serde(default = "default_max_depth")]
212 pub max_depth: u32,
213}
214
215fn default_max_depth() -> u32 {
216 3
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct TruncateStringsConfig {
221 pub enabled: bool,
222 #[serde(default = "default_max_length")]
223 pub max_length: u32,
224}
225
226fn default_max_length() -> u32 {
227 500
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct CollapseArraysConfig {
232 pub enabled: bool,
233 #[serde(default = "default_max_items")]
234 pub max_items: u32,
235 #[serde(default = "default_collapse_max_chars")]
238 pub max_chars: u32,
239 #[serde(default)]
240 pub summary_template: String,
241}
242
243fn default_max_items() -> u32 {
244 5
245}
246
247fn default_collapse_max_chars() -> u32 {
248 crate::stages::DEFAULT_COLLAPSE_MAX_CHARS as u32
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct CustomTransformsConfig {
253 pub enabled: bool,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct ToolSelectionConfig {
260 #[serde(default = "default_max_tools")]
261 pub max_tools: usize,
262 #[serde(default = "default_similarity_threshold")]
263 pub similarity_threshold: f64,
264 #[serde(default)]
265 pub default_tools: Vec<String>,
266}
267
268fn default_max_tools() -> usize {
269 5
270}
271
272fn default_similarity_threshold() -> f64 {
273 0.7
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct BudgetConfig {
280 #[serde(default = "default_warning_threshold")]
281 pub warning_threshold: f64,
282 #[serde(default = "default_ceiling_threshold")]
283 pub ceiling_threshold: f64,
284 #[serde(default = "default_window_size")]
285 pub default_window_size: u32,
286 #[serde(default)]
287 pub agents: HashMap<String, f64>,
288}
289
290fn default_warning_threshold() -> f64 {
291 0.70
292}
293
294fn default_ceiling_threshold() -> f64 {
295 0.85
296}
297
298fn default_window_size() -> u32 {
299 200_000
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct TerseModeConfig {
306 pub enabled: bool,
307 #[serde(default = "default_terse_level")]
308 pub level: TerseLevel,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "lowercase")]
313pub enum TerseLevel {
314 Minimal,
315 Moderate,
316 Verbose,
317}
318
319fn default_terse_level() -> TerseLevel {
320 TerseLevel::Moderate
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ModelConfig {
327 pub family: String,
328 #[serde(default)]
329 pub primary: String,
330 #[serde(default)]
331 pub local: String,
332 #[serde(default = "default_complexity_threshold")]
333 pub complexity_threshold: f64,
334 pub pricing: Option<ModelPricingConfig>,
335}
336
337fn default_complexity_threshold() -> f64 {
338 0.4
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct ModelPricingConfig {
343 pub input_per_1k: f64,
344 pub output_per_1k: f64,
345 #[serde(default)]
346 pub cache_read_discount: f64,
347}
348
349impl Default for Preset {
350 fn default() -> Self {
351 Preset {
352 preset: PresetMeta {
353 name: "default".to_string(),
354 version: "1.0".to_string(),
355 description: "Default compression preset for general development".to_string(),
356 },
357 compression: CompressionConfig {
358 stages: vec![
359 "keep_fields".to_string(),
360 "strip_fields".to_string(),
361 "condense".to_string(),
362 "strip_nulls".to_string(),
363 "flatten".to_string(),
364 "truncate_strings".to_string(),
365 "collapse_arrays".to_string(),
366 "custom_transforms".to_string(),
367 ],
368 keep_fields: Some(KeepFieldsConfig {
369 enabled: false,
370 fields: vec![
371 "id".to_string(),
372 "name".to_string(),
373 "type".to_string(),
374 "status".to_string(),
375 "error".to_string(),
376 "message".to_string(),
377 ],
378 }),
379 strip_fields: Some(StripFieldsConfig {
380 enabled: true,
381 fields: vec![
382 "metadata.internal_id".to_string(),
383 "debug_info".to_string(),
384 "trace_id".to_string(),
385 ],
386 }),
387 condense: Some(CondenseConfig {
388 enabled: true,
389 max_repeated_lines: 3,
390 }),
391 git_diff_fold: Some(GitDiffFoldConfig {
392 enabled: true,
393 max_context_lines: 2,
394 }),
395 strip_nulls: Some(StripNullsConfig { enabled: true }),
396 flatten: Some(FlattenConfig {
397 enabled: true,
398 max_depth: 3,
399 }),
400 truncate_strings: Some(TruncateStringsConfig {
401 enabled: true,
402 max_length: 500,
403 }),
404 collapse_arrays: Some(CollapseArraysConfig {
405 enabled: true,
406 max_items: 5,
407 max_chars: default_collapse_max_chars(),
408 summary_template: "... and {remaining} more items".to_string(),
409 }),
410 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
411 },
412 tool_selection: ToolSelectionConfig {
413 max_tools: 5,
414 similarity_threshold: 0.7,
415 default_tools: vec![
416 "read_file".to_string(),
417 "write_file".to_string(),
418 "search".to_string(),
419 ],
420 },
421 budget: BudgetConfig {
422 warning_threshold: 0.70,
423 ceiling_threshold: 0.85,
424 default_window_size: 200_000,
425 agents: {
426 let mut m = HashMap::new();
427 m.insert("parent".to_string(), 0.60);
428 m.insert("child".to_string(), 0.20);
429 m
430 },
431 },
432 terse_mode: TerseModeConfig {
433 enabled: true,
434 level: TerseLevel::Moderate,
435 },
436 model: ModelConfig {
437 family: "anthropic".to_string(),
438 primary: "claude-sonnet-4-20250514".to_string(),
439 local: "llama-3.1-8b".to_string(),
440 complexity_threshold: 0.4,
441 pricing: Some(ModelPricingConfig {
442 input_per_1k: 0.003,
443 output_per_1k: 0.015,
444 cache_read_discount: 0.9,
445 }),
446 },
447 }
448 }
449}
450
451#[cfg(test)]
456mod tests {
457 use super::*;
458 use proptest::prelude::*;
459
460 fn arb_nonempty_string() -> impl Strategy<Value = String> {
466 "[a-zA-Z0-9_\\-\\.]{1,32}".prop_map(|s| s)
467 }
468
469 fn arb_open_unit() -> impl Strategy<Value = f64> {
471 (1u32..=9999u32).prop_map(|n| n as f64 / 10_000.0)
472 }
473
474 fn arb_budget_config() -> impl Strategy<Value = BudgetConfig> {
476 (1u32..=8999u32).prop_flat_map(|w_raw| {
478 let warning = w_raw as f64 / 10_000.0; let c_min = (w_raw + 1) as f64 / 10_000.0;
481 let c_max = 9999.0_f64 / 10_000.0;
482 let c_min_int = w_raw + 1;
484 (c_min_int..=9999u32).prop_map(move |c_raw| {
485 let ceiling = c_raw as f64 / 10_000.0;
486 let _ = (c_min, c_max); BudgetConfig {
488 warning_threshold: warning,
489 ceiling_threshold: ceiling,
490 default_window_size: 200_000,
491 agents: Default::default(),
492 }
493 })
494 })
495 }
496
497 fn arb_tool_selection_config() -> impl Strategy<Value = ToolSelectionConfig> {
499 (1usize..=50usize, arb_open_unit()).prop_map(|(max_tools, similarity_threshold)| {
500 ToolSelectionConfig {
501 max_tools,
502 similarity_threshold,
503 default_tools: vec![],
504 }
505 })
506 }
507
508 fn arb_model_config() -> impl Strategy<Value = ModelConfig> {
510 (arb_nonempty_string(), arb_open_unit()).prop_map(|(family, complexity_threshold)| {
511 ModelConfig {
512 family,
513 primary: String::new(),
514 local: String::new(),
515 complexity_threshold,
516 pricing: None,
517 }
518 })
519 }
520
521 fn arb_preset() -> impl Strategy<Value = Preset> {
523 (
524 arb_nonempty_string(), arb_nonempty_string(), arb_budget_config(),
527 arb_tool_selection_config(),
528 arb_model_config(),
529 )
530 .prop_map(|(name, version, budget, tool_selection, model)| Preset {
531 preset: PresetMeta {
532 name,
533 version,
534 description: String::new(),
535 },
536 compression: CompressionConfig {
537 stages: vec![],
538 keep_fields: None,
539 strip_fields: None,
540 condense: None,
541 git_diff_fold: None,
542 strip_nulls: None,
543 flatten: None,
544 truncate_strings: None,
545 collapse_arrays: None,
546 custom_transforms: None,
547 },
548 tool_selection,
549 budget,
550 terse_mode: TerseModeConfig {
551 enabled: false,
552 level: TerseLevel::Moderate,
553 },
554 model,
555 })
556 }
557
558 proptest! {
564 #[test]
576 fn prop_preset_toml_round_trip(preset in arb_preset()) {
577 let toml1 = PresetParser::to_toml(&preset)
579 .expect("to_toml should not fail on a valid preset");
580
581 let parsed = PresetParser::parse(&toml1)
583 .expect("parse should not fail on a valid TOML string");
584
585 let toml2 = PresetParser::to_toml(&parsed)
587 .expect("to_toml should not fail on re-parsed preset");
588
589 prop_assert_eq!(
591 &toml1,
592 &toml2,
593 "TOML round-trip mismatch:\nfirst: {}\nsecond: {}",
594 toml1,
595 toml2
596 );
597 }
598 }
599
600 fn arb_invalid_warning_threshold() -> impl Strategy<Value = f64> {
607 prop_oneof![
608 Just(0.0_f64),
609 Just(1.0_f64),
610 (1u32..=10000u32).prop_map(|n| -(n as f64 / 10_000.0)),
612 (10001u32..=20000u32).prop_map(|n| n as f64 / 10_000.0),
614 ]
615 }
616
617 fn arb_invalid_ceiling_threshold() -> impl Strategy<Value = f64> {
619 prop_oneof![
620 Just(0.0_f64),
621 Just(1.0_f64),
622 (1u32..=10000u32).prop_map(|n| -(n as f64 / 10_000.0)),
623 (10001u32..=20000u32).prop_map(|n| n as f64 / 10_000.0),
624 ]
625 }
626
627 fn arb_invalid_max_tools() -> impl Strategy<Value = usize> {
629 prop_oneof![
630 Just(0usize),
631 (51usize..=200usize),
632 ]
633 }
634
635 fn arb_invalid_complexity_threshold() -> impl Strategy<Value = f64> {
637 prop_oneof![
638 Just(0.0_f64),
639 Just(1.0_f64),
640 (1u32..=10000u32).prop_map(|n| -(n as f64 / 10_000.0)),
641 (10001u32..=20000u32).prop_map(|n| n as f64 / 10_000.0),
642 ]
643 }
644
645 proptest! {
646 #[test]
651 fn prop_invalid_warning_threshold_error_mentions_field(
652 invalid_wt in arb_invalid_warning_threshold()
653 ) {
654 let mut preset = Preset::default();
655 preset.budget.warning_threshold = invalid_wt;
656 preset.budget.ceiling_threshold = 0.85;
662
663 let result = PresetParser::validate(&preset);
664 prop_assert!(result.is_err(), "expected validation error for warning_threshold={}", invalid_wt);
665 let err_msg = result.unwrap_err().to_string();
666 prop_assert!(
667 err_msg.contains("budget.warning_threshold"),
668 "error message '{}' does not mention 'budget.warning_threshold'",
669 err_msg
670 );
671 }
672
673 #[test]
678 fn prop_invalid_ceiling_threshold_error_mentions_field(
679 invalid_ct in arb_invalid_ceiling_threshold()
680 ) {
681 let mut preset = Preset::default();
682 preset.budget.warning_threshold = 0.70;
684 preset.budget.ceiling_threshold = invalid_ct;
685
686 let result = PresetParser::validate(&preset);
687 prop_assert!(result.is_err(), "expected validation error for ceiling_threshold={}", invalid_ct);
688 let err_msg = result.unwrap_err().to_string();
689 prop_assert!(
690 err_msg.contains("budget.ceiling_threshold"),
691 "error message '{}' does not mention 'budget.ceiling_threshold'",
692 err_msg
693 );
694 }
695
696 #[test]
701 fn prop_empty_preset_name_error_mentions_field(_dummy in 0u32..1u32) {
702 let mut preset = Preset::default();
703 preset.preset.name = String::new();
704
705 let result = PresetParser::validate(&preset);
706 prop_assert!(result.is_err(), "expected validation error for empty preset.name");
707 let err_msg = result.unwrap_err().to_string();
708 prop_assert!(
709 err_msg.contains("preset.name"),
710 "error message '{}' does not mention 'preset.name'",
711 err_msg
712 );
713 }
714
715 #[test]
720 fn prop_invalid_max_tools_error_mentions_field(
721 invalid_mt in arb_invalid_max_tools()
722 ) {
723 let mut preset = Preset::default();
724 preset.tool_selection.max_tools = invalid_mt;
725
726 let result = PresetParser::validate(&preset);
727 prop_assert!(result.is_err(), "expected validation error for max_tools={}", invalid_mt);
728 let err_msg = result.unwrap_err().to_string();
729 prop_assert!(
730 err_msg.contains("tool_selection.max_tools"),
731 "error message '{}' does not mention 'tool_selection.max_tools'",
732 err_msg
733 );
734 }
735
736 #[test]
741 fn prop_invalid_complexity_threshold_error_mentions_field(
742 invalid_cxt in arb_invalid_complexity_threshold()
743 ) {
744 let mut preset = Preset::default();
745 preset.model.complexity_threshold = invalid_cxt;
746
747 let result = PresetParser::validate(&preset);
748 prop_assert!(result.is_err(), "expected validation error for complexity_threshold={}", invalid_cxt);
749 let err_msg = result.unwrap_err().to_string();
750 prop_assert!(
751 err_msg.contains("model.complexity_threshold"),
752 "error message '{}' does not mention 'model.complexity_threshold'",
753 err_msg
754 );
755 }
756 }
757}