1use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
13#[serde(rename_all = "kebab-case")]
14pub struct CodeBlockToolsConfig {
15 #[serde(default)]
17 pub enabled: bool,
18
19 #[serde(default)]
21 pub normalize_language: NormalizeLanguage,
22
23 #[serde(default)]
25 pub on_error: OnError,
26
27 #[serde(default)]
29 pub on_missing_language_definition: OnMissing,
30
31 #[serde(default = "default_on_missing_tool_binary")]
35 pub on_missing_tool_binary: OnMissing,
36
37 #[serde(default, alias = "binary_preferences")]
39 pub binary_preferences: BTreeMap<String, BinaryPreference>,
40
41 #[serde(default, alias = "on_missing_language_tag")]
43 pub on_missing_language_tag: OnMissing,
44 #[serde(default, skip_serializing_if = "Option::is_none", alias = "on_unknown_language_tag")]
46 pub on_unknown_language_tag: Option<OnMissing>,
47 #[serde(default, alias = "on_missing_mode_definition")]
49 pub on_missing_mode_definition: OnMissing,
50 #[serde(default, alias = "on_no_tools_run")]
52 pub on_no_tools_run: OnMissing,
53 #[serde(skip)]
55 #[schemars(skip)]
56 pub run_state: Option<std::sync::Arc<super::run_state::RunState>>,
57
58 #[serde(default = "default_timeout")]
60 #[schemars(schema_with = "schema_timeout")]
61 pub timeout: u64,
62
63 #[serde(default)]
65 pub languages: BTreeMap<String, LanguageToolConfig>,
66
67 #[serde(default)]
70 pub language_aliases: BTreeMap<String, String>,
71
72 #[serde(default)]
74 pub tools: BTreeMap<String, ToolDefinition>,
75
76 #[serde(skip)]
85 #[schemars(skip)]
86 pub values_withheld: bool,
87}
88
89fn default_timeout() -> u64 {
90 30_000
91}
92
93fn default_on_missing_tool_binary() -> OnMissing {
94 OnMissing::Warn
95}
96
97fn schema_timeout(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
99 schemars::json_schema!({
100 "type": "integer",
101 "minimum": 0
102 })
103}
104
105impl CodeBlockToolsConfig {
106 pub fn unknown_language_policy(&self) -> OnMissing {
108 self.on_unknown_language_tag
109 .unwrap_or(self.on_missing_language_definition)
110 }
111
112 pub fn requires_fail_fast(&self) -> bool {
113 self.enabled
114 && [
115 self.on_missing_language_tag,
116 self.unknown_language_policy(),
117 self.on_missing_mode_definition,
118 self.on_missing_language_definition,
119 self.on_missing_tool_binary,
120 self.on_no_tools_run,
121 ]
122 .contains(&OnMissing::FailFast)
123 }
124}
125
126impl Default for CodeBlockToolsConfig {
127 fn default() -> Self {
128 Self {
129 enabled: false,
130 normalize_language: NormalizeLanguage::default(),
131 on_error: OnError::default(),
132 on_missing_language_definition: OnMissing::default(),
133 on_missing_tool_binary: default_on_missing_tool_binary(),
134 binary_preferences: BTreeMap::new(),
135 on_missing_language_tag: OnMissing::Ignore,
136 on_unknown_language_tag: None,
137 on_missing_mode_definition: OnMissing::Ignore,
138 on_no_tools_run: OnMissing::Ignore,
139 run_state: None,
140 timeout: default_timeout(),
141 languages: BTreeMap::new(),
142 language_aliases: BTreeMap::new(),
143 tools: BTreeMap::new(),
144 values_withheld: false,
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
151#[serde(rename_all = "kebab-case")]
152pub enum BinaryPreference {
153 Project,
155 System,
157 OnlyProject,
159 #[default]
161 OnlySystem,
162}
163
164#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
166#[serde(rename_all = "kebab-case")]
167pub enum FormatMode {
168 #[default]
171 Fallback,
172 Pipeline,
175}
176
177#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
179#[serde(rename_all = "kebab-case")]
180pub enum NormalizeLanguage {
181 #[default]
183 Linguist,
184 Exact,
186}
187
188#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
190#[serde(rename_all = "kebab-case")]
191pub enum OnError {
192 #[default]
194 Fail,
195 Skip,
197 Warn,
199}
200
201#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
203#[serde(rename_all = "kebab-case")]
204pub enum OnMissing {
205 #[default]
207 Ignore,
208 Warn,
211 Fail,
213 FailFast,
215}
216
217impl OnMissing {
218 pub fn skips_the_block(self) -> bool {
225 matches!(self, OnMissing::Ignore | OnMissing::Warn)
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
231#[serde(rename_all = "kebab-case")]
232pub struct LanguageToolConfig {
233 #[serde(default = "default_true")]
237 pub enabled: bool,
238
239 #[serde(default)]
241 pub lint: Vec<String>,
242
243 #[serde(default)]
245 pub format: Vec<String>,
246
247 #[serde(default)]
249 pub format_mode: FormatMode,
250
251 #[serde(default)]
253 pub on_error: Option<OnError>,
254}
255
256impl Default for LanguageToolConfig {
257 fn default() -> Self {
258 Self {
259 enabled: true,
260 lint: Vec::new(),
261 format: Vec::new(),
262 format_mode: FormatMode::default(),
263 on_error: None,
264 }
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
272#[serde(rename_all = "kebab-case")]
273pub struct ToolDefinition {
274 pub command: Vec<String>,
276
277 #[serde(default = "default_true")]
279 pub stdin: bool,
280
281 #[serde(default = "default_true")]
283 pub stdout: bool,
284
285 #[serde(default)]
287 pub lint_args: Vec<String>,
288
289 #[serde(default)]
291 pub format_args: Vec<String>,
292}
293
294fn default_true() -> bool {
295 true
296}
297
298impl Default for ToolDefinition {
299 fn default() -> Self {
300 Self {
301 command: Vec::new(),
302 stdin: true,
303 stdout: true,
304 lint_args: Vec::new(),
305 format_args: Vec::new(),
306 }
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn test_default_config() {
316 let config = CodeBlockToolsConfig::default();
317 assert!(!config.enabled);
318 assert_eq!(config.normalize_language, NormalizeLanguage::Linguist);
319 assert_eq!(config.on_error, OnError::Fail);
320 assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
321 assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
322 assert_eq!(config.timeout, 30_000);
323 assert!(config.languages.is_empty());
324 assert!(config.language_aliases.is_empty());
325 assert!(config.tools.is_empty());
326 }
327
328 #[test]
329 fn test_deserialize_config() {
330 let toml = r#"
331enabled = true
332normalize-language = "exact"
333on-error = "skip"
334timeout = 60000
335
336[languages.python]
337lint = ["ruff:check"]
338format = ["ruff:format"]
339
340[languages.json]
341format = ["prettier"]
342on-error = "warn"
343
344[language-aliases]
345py = "python"
346bash = "shell"
347
348[tools.custom-tool]
349command = ["my-tool", "--format"]
350stdin = true
351stdout = true
352"#;
353
354 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
355
356 assert!(config.enabled);
357 assert_eq!(config.normalize_language, NormalizeLanguage::Exact);
358 assert_eq!(config.on_error, OnError::Skip);
359 assert_eq!(config.timeout, 60_000);
360
361 let python = config.languages.get("python").expect("Missing python config");
362 assert_eq!(python.lint, vec!["ruff:check"]);
363 assert_eq!(python.format, vec!["ruff:format"]);
364 assert_eq!(python.on_error, None);
365
366 let json = config.languages.get("json").expect("Missing json config");
367 assert!(json.lint.is_empty());
368 assert_eq!(json.format, vec!["prettier"]);
369 assert_eq!(json.on_error, Some(OnError::Warn));
370
371 assert_eq!(config.language_aliases.get("py").map(String::as_str), Some("python"));
372 assert_eq!(config.language_aliases.get("bash").map(String::as_str), Some("shell"));
373
374 let tool = config.tools.get("custom-tool").expect("Missing custom tool");
375 assert_eq!(tool.command, vec!["my-tool", "--format"]);
376 assert!(tool.stdin);
377 assert!(tool.stdout);
378 }
379
380 #[test]
381 fn test_serialize_config() {
382 let mut config = CodeBlockToolsConfig {
383 enabled: true,
384 ..Default::default()
385 };
386 config.languages.insert(
387 "rust".to_string(),
388 LanguageToolConfig {
389 format: vec!["rustfmt".to_string()],
390 ..Default::default()
391 },
392 );
393
394 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
395 assert!(toml.contains("enabled = true"));
396 assert!(toml.contains("[languages.rust]"));
397 assert!(toml.contains("rustfmt"));
398 }
399
400 #[test]
401 fn test_on_missing_options() {
402 let toml = r#"
403enabled = true
404on-missing-language-definition = "fail"
405on-missing-tool-binary = "fail-fast"
406"#;
407
408 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
409
410 assert_eq!(config.on_missing_language_definition, OnMissing::Fail);
411 assert_eq!(config.on_missing_tool_binary, OnMissing::FailFast);
412 }
413
414 #[test]
415 fn test_on_missing_defaults() {
416 let toml = r#"
417enabled = true
418"#;
419
420 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
421
422 assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
425 assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
428 }
429
430 #[test]
431 fn test_on_missing_all_variants() {
432 for (input, expected) in [
434 ("ignore", OnMissing::Ignore),
435 ("warn", OnMissing::Warn),
436 ("fail", OnMissing::Fail),
437 ("fail-fast", OnMissing::FailFast),
438 ] {
439 let toml = format!(
440 r#"
441enabled = true
442on-missing-language-definition = "{input}"
443"#
444 );
445 let config: CodeBlockToolsConfig = toml::from_str(&toml).expect("Failed to parse TOML");
446 assert_eq!(
447 config.on_missing_language_definition, expected,
448 "Failed for variant: {input}"
449 );
450 }
451 }
452
453 #[test]
454 fn test_language_config_enabled_defaults_to_true() {
455 let toml = r#"
457lint = ["ruff:check"]
458"#;
459 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
460 assert!(config.enabled);
461 assert_eq!(config.lint, vec!["ruff:check"]);
462 assert!(config.format.is_empty());
463 }
464
465 #[test]
466 fn test_language_config_enabled_false() {
467 let toml = r#"
469enabled = false
470"#;
471 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
472 assert!(!config.enabled);
473 assert!(config.lint.is_empty());
474 assert!(config.format.is_empty());
475 }
476
477 #[test]
478 fn test_language_config_enabled_false_with_tools() {
479 let toml = r#"
481enabled = false
482lint = ["ruff:check"]
483format = ["ruff:format"]
484"#;
485 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
486 assert!(!config.enabled);
487 assert_eq!(config.lint, vec!["ruff:check"]);
488 assert_eq!(config.format, vec!["ruff:format"]);
489 }
490
491 #[test]
492 fn test_language_config_enabled_in_full_config() {
493 let toml = r#"
495enabled = true
496on-missing-language-definition = "fail"
497
498[languages.python]
499lint = ["ruff:check"]
500
501[languages.plaintext]
502enabled = false
503"#;
504 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
505
506 let python = config.languages.get("python").expect("Missing python config");
507 assert!(python.enabled);
508 assert_eq!(python.lint, vec!["ruff:check"]);
509
510 let plaintext = config.languages.get("plaintext").expect("Missing plaintext config");
511 assert!(!plaintext.enabled);
512 assert!(plaintext.lint.is_empty());
513 }
514
515 #[test]
516 fn test_language_config_default_trait() {
517 let config = LanguageToolConfig::default();
518 assert!(config.enabled);
519 assert!(config.lint.is_empty());
520 assert!(config.format.is_empty());
521 assert!(config.on_error.is_none());
522 }
523
524 #[test]
525 fn test_format_mode_parses_and_defaults_to_fallback() {
526 let toml = r#"
527[languages]
528json = { format = ["jq"] }
529shell = { format = ["shuck:lint-fix", "shuck:format"], format-mode = "pipeline" }
530python = { format = ["ruff:format", "black"], format-mode = "fallback" }
531"#;
532 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
533 assert_eq!(config.languages["json"].format_mode, FormatMode::Fallback);
534 assert_eq!(config.languages["shell"].format_mode, FormatMode::Pipeline);
535 assert_eq!(config.languages["python"].format_mode, FormatMode::Fallback);
536
537 let invalid = "[languages]\njson = { format = [\"jq\"], format-mode = \"chain\" }\n";
538 let error = toml::from_str::<CodeBlockToolsConfig>(invalid).unwrap_err().to_string();
539 assert!(
540 error.contains("format-mode") || (error.contains("fallback") && error.contains("pipeline")),
541 "{error}"
542 );
543 }
544
545 #[test]
546 fn test_format_mode_round_trips() {
547 let config = LanguageToolConfig {
548 format: vec!["jq".to_string()],
549 format_mode: FormatMode::Pipeline,
550 ..Default::default()
551 };
552 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
553 assert!(toml.contains("format-mode = \"pipeline\""), "{toml}");
554 assert_eq!(toml::from_str::<LanguageToolConfig>(&toml).unwrap(), config);
555 }
556
557 #[test]
558 fn test_language_config_serialize_enabled_false() {
559 let config = LanguageToolConfig {
560 enabled: false,
561 ..Default::default()
562 };
563 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
564 assert!(toml.contains("enabled = false"));
565 }
566}