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, skip_serializing_if = "Option::is_none", alias = "tool_version_definition")]
43 pub tool_version_definition: Option<ToolVersionDefinition>,
44
45 #[serde(default = "default_on_tool_version_mismatch", alias = "on_tool_version_mismatch")]
49 pub on_tool_version_mismatch: OnMissing,
50
51 #[serde(default, alias = "on_missing_language_tag")]
53 pub on_missing_language_tag: OnMissing,
54 #[serde(default, skip_serializing_if = "Option::is_none", alias = "on_unknown_language_tag")]
56 pub on_unknown_language_tag: Option<OnMissing>,
57 #[serde(default, alias = "on_missing_mode_definition")]
59 pub on_missing_mode_definition: OnMissing,
60 #[serde(default, alias = "on_no_tools_run")]
62 pub on_no_tools_run: OnMissing,
63 #[serde(skip)]
65 #[schemars(skip)]
66 pub run_state: Option<std::sync::Arc<super::run_state::RunState>>,
67
68 #[serde(default = "default_timeout")]
70 #[schemars(schema_with = "schema_timeout")]
71 pub timeout: u64,
72
73 #[serde(default)]
75 pub languages: BTreeMap<String, LanguageToolConfig>,
76
77 #[serde(default)]
80 pub language_aliases: BTreeMap<String, String>,
81
82 #[serde(default)]
84 pub tools: BTreeMap<String, ToolDefinition>,
85
86 #[serde(skip)]
95 #[schemars(skip)]
96 pub values_withheld: bool,
97}
98
99fn default_timeout() -> u64 {
100 30_000
101}
102
103fn default_on_tool_version_mismatch() -> OnMissing {
104 OnMissing::Warn
105}
106
107fn default_on_missing_tool_binary() -> OnMissing {
108 OnMissing::Warn
109}
110
111fn schema_timeout(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
113 schemars::json_schema!({
114 "type": "integer",
115 "minimum": 0
116 })
117}
118
119impl CodeBlockToolsConfig {
120 pub fn unknown_language_policy(&self) -> OnMissing {
122 self.on_unknown_language_tag
123 .unwrap_or(self.on_missing_language_definition)
124 }
125
126 pub fn requires_fail_fast(&self) -> bool {
127 self.enabled && self.has_fail_fast_policy()
128 }
129
130 pub(crate) fn version_fail_fast(&self) -> bool {
131 self.tool_version_definition.is_some() && self.on_tool_version_mismatch == OnMissing::FailFast
132 }
133
134 pub(crate) fn has_fail_fast_policy(&self) -> bool {
136 [
137 self.on_missing_language_tag,
138 self.unknown_language_policy(),
139 self.on_missing_mode_definition,
140 self.on_missing_language_definition,
141 self.on_missing_tool_binary,
142 self.on_no_tools_run,
143 ]
144 .contains(&OnMissing::FailFast)
145 || self.version_fail_fast()
146 }
147}
148
149impl Default for CodeBlockToolsConfig {
150 fn default() -> Self {
151 Self {
152 enabled: false,
153 normalize_language: NormalizeLanguage::default(),
154 on_error: OnError::default(),
155 on_missing_language_definition: OnMissing::default(),
156 on_missing_tool_binary: default_on_missing_tool_binary(),
157 binary_preferences: BTreeMap::new(),
158 tool_version_definition: None,
159 on_tool_version_mismatch: default_on_tool_version_mismatch(),
160 on_missing_language_tag: OnMissing::Ignore,
161 on_unknown_language_tag: None,
162 on_missing_mode_definition: OnMissing::Ignore,
163 on_no_tools_run: OnMissing::Ignore,
164 run_state: None,
165 timeout: default_timeout(),
166 languages: BTreeMap::new(),
167 language_aliases: BTreeMap::new(),
168 tools: BTreeMap::new(),
169 values_withheld: false,
170 }
171 }
172}
173
174#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, schemars::JsonSchema)]
176pub enum ToolVersionDefinition {
177 #[serde(rename = "auto")]
179 Auto,
180 #[serde(rename = "uv.lock")]
182 UvLock,
183 #[serde(rename = "pyproject.toml")]
185 Pyproject,
186 #[serde(rename = ".pre-commit-config.yaml")]
188 PreCommit,
189 #[serde(rename = ".pre-commit-config.yml")]
191 PreCommitYml,
192 #[serde(rename = "prek.toml")]
194 Prek,
195}
196
197impl ToolVersionDefinition {
198 pub(super) fn filename(self) -> &'static str {
199 match self {
200 Self::Auto => "auto",
201 Self::UvLock => "uv.lock",
202 Self::Pyproject => "pyproject.toml",
203 Self::PreCommit => ".pre-commit-config.yaml",
204 Self::PreCommitYml => ".pre-commit-config.yml",
205 Self::Prek => "prek.toml",
206 }
207 }
208}
209
210#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
212#[serde(rename_all = "kebab-case")]
213pub enum BinaryPreference {
214 Project,
216 System,
218 OnlyProject,
220 #[default]
222 OnlySystem,
223}
224
225#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
227#[serde(rename_all = "kebab-case")]
228pub enum FormatMode {
229 #[default]
232 Fallback,
233 Pipeline,
236}
237
238#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
240#[serde(rename_all = "kebab-case")]
241pub enum NormalizeLanguage {
242 #[default]
244 Linguist,
245 Exact,
247}
248
249#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
251#[serde(rename_all = "kebab-case")]
252pub enum OnError {
253 #[default]
255 Fail,
256 Skip,
258 Warn,
260}
261
262#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
264#[serde(rename_all = "kebab-case")]
265pub enum OnMissing {
266 #[default]
268 Ignore,
269 Warn,
272 Fail,
274 FailFast,
276}
277
278impl OnMissing {
279 pub fn skips_the_block(self) -> bool {
286 matches!(self, OnMissing::Ignore | OnMissing::Warn)
287 }
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
292#[serde(rename_all = "kebab-case")]
293pub struct LanguageToolConfig {
294 #[serde(default = "default_true")]
298 pub enabled: bool,
299
300 #[serde(default)]
302 pub lint: Vec<String>,
303
304 #[serde(default)]
306 pub format: Vec<String>,
307
308 #[serde(default)]
310 pub format_mode: FormatMode,
311
312 #[serde(default)]
314 pub on_error: Option<OnError>,
315}
316
317impl Default for LanguageToolConfig {
318 fn default() -> Self {
319 Self {
320 enabled: true,
321 lint: Vec::new(),
322 format: Vec::new(),
323 format_mode: FormatMode::default(),
324 on_error: None,
325 }
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
333#[serde(rename_all = "kebab-case")]
334pub struct ToolDefinition {
335 pub command: Vec<String>,
337
338 #[serde(default = "default_true")]
340 pub stdin: bool,
341
342 #[serde(default = "default_true")]
344 pub stdout: bool,
345
346 #[serde(default)]
348 pub lint_args: Vec<String>,
349
350 #[serde(default)]
352 pub format_args: Vec<String>,
353}
354
355fn default_true() -> bool {
356 true
357}
358
359impl Default for ToolDefinition {
360 fn default() -> Self {
361 Self {
362 command: Vec::new(),
363 stdin: true,
364 stdout: true,
365 lint_args: Vec::new(),
366 format_args: Vec::new(),
367 }
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn test_default_config() {
377 let config = CodeBlockToolsConfig::default();
378 assert!(!config.enabled);
379 assert_eq!(config.normalize_language, NormalizeLanguage::Linguist);
380 assert_eq!(config.on_error, OnError::Fail);
381 assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
382 assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
383 assert_eq!(config.timeout, 30_000);
384 assert!(config.languages.is_empty());
385 assert!(config.language_aliases.is_empty());
386 assert!(config.tools.is_empty());
387 }
388
389 #[test]
390 fn test_deserialize_config() {
391 let toml = r#"
392enabled = true
393normalize-language = "exact"
394on-error = "skip"
395timeout = 60000
396
397[languages.python]
398lint = ["ruff:check"]
399format = ["ruff:format"]
400
401[languages.json]
402format = ["prettier"]
403on-error = "warn"
404
405[language-aliases]
406py = "python"
407bash = "shell"
408
409[tools.custom-tool]
410command = ["my-tool", "--format"]
411stdin = true
412stdout = true
413"#;
414
415 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
416
417 assert!(config.enabled);
418 assert_eq!(config.normalize_language, NormalizeLanguage::Exact);
419 assert_eq!(config.on_error, OnError::Skip);
420 assert_eq!(config.timeout, 60_000);
421
422 let python = config.languages.get("python").expect("Missing python config");
423 assert_eq!(python.lint, vec!["ruff:check"]);
424 assert_eq!(python.format, vec!["ruff:format"]);
425 assert_eq!(python.on_error, None);
426
427 let json = config.languages.get("json").expect("Missing json config");
428 assert!(json.lint.is_empty());
429 assert_eq!(json.format, vec!["prettier"]);
430 assert_eq!(json.on_error, Some(OnError::Warn));
431
432 assert_eq!(config.language_aliases.get("py").map(String::as_str), Some("python"));
433 assert_eq!(config.language_aliases.get("bash").map(String::as_str), Some("shell"));
434
435 let tool = config.tools.get("custom-tool").expect("Missing custom tool");
436 assert_eq!(tool.command, vec!["my-tool", "--format"]);
437 assert!(tool.stdin);
438 assert!(tool.stdout);
439 }
440
441 #[test]
442 fn test_serialize_config() {
443 let mut config = CodeBlockToolsConfig {
444 enabled: true,
445 ..Default::default()
446 };
447 config.languages.insert(
448 "rust".to_string(),
449 LanguageToolConfig {
450 format: vec!["rustfmt".to_string()],
451 ..Default::default()
452 },
453 );
454
455 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
456 assert!(toml.contains("enabled = true"));
457 assert!(toml.contains("[languages.rust]"));
458 assert!(toml.contains("rustfmt"));
459 }
460
461 #[test]
462 fn test_on_missing_options() {
463 let toml = r#"
464enabled = true
465on-missing-language-definition = "fail"
466on-missing-tool-binary = "fail-fast"
467"#;
468
469 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
470
471 assert_eq!(config.on_missing_language_definition, OnMissing::Fail);
472 assert_eq!(config.on_missing_tool_binary, OnMissing::FailFast);
473 }
474
475 #[test]
476 fn test_on_missing_defaults() {
477 let toml = r#"
478enabled = true
479"#;
480
481 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
482
483 assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
486 assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
489 }
490
491 #[test]
492 fn test_on_missing_all_variants() {
493 for (input, expected) in [
495 ("ignore", OnMissing::Ignore),
496 ("warn", OnMissing::Warn),
497 ("fail", OnMissing::Fail),
498 ("fail-fast", OnMissing::FailFast),
499 ] {
500 let toml = format!(
501 r#"
502enabled = true
503on-missing-language-definition = "{input}"
504"#
505 );
506 let config: CodeBlockToolsConfig = toml::from_str(&toml).expect("Failed to parse TOML");
507 assert_eq!(
508 config.on_missing_language_definition, expected,
509 "Failed for variant: {input}"
510 );
511 }
512 }
513
514 #[test]
515 fn test_language_config_enabled_defaults_to_true() {
516 let toml = r#"
518lint = ["ruff:check"]
519"#;
520 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
521 assert!(config.enabled);
522 assert_eq!(config.lint, vec!["ruff:check"]);
523 assert!(config.format.is_empty());
524 }
525
526 #[test]
527 fn test_language_config_enabled_false() {
528 let toml = r#"
530enabled = false
531"#;
532 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
533 assert!(!config.enabled);
534 assert!(config.lint.is_empty());
535 assert!(config.format.is_empty());
536 }
537
538 #[test]
539 fn test_language_config_enabled_false_with_tools() {
540 let toml = r#"
542enabled = false
543lint = ["ruff:check"]
544format = ["ruff:format"]
545"#;
546 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
547 assert!(!config.enabled);
548 assert_eq!(config.lint, vec!["ruff:check"]);
549 assert_eq!(config.format, vec!["ruff:format"]);
550 }
551
552 #[test]
553 fn test_language_config_enabled_in_full_config() {
554 let toml = r#"
556enabled = true
557on-missing-language-definition = "fail"
558
559[languages.python]
560lint = ["ruff:check"]
561
562[languages.plaintext]
563enabled = false
564"#;
565 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
566
567 let python = config.languages.get("python").expect("Missing python config");
568 assert!(python.enabled);
569 assert_eq!(python.lint, vec!["ruff:check"]);
570
571 let plaintext = config.languages.get("plaintext").expect("Missing plaintext config");
572 assert!(!plaintext.enabled);
573 assert!(plaintext.lint.is_empty());
574 }
575
576 #[test]
577 fn test_language_config_default_trait() {
578 let config = LanguageToolConfig::default();
579 assert!(config.enabled);
580 assert!(config.lint.is_empty());
581 assert!(config.format.is_empty());
582 assert!(config.on_error.is_none());
583 }
584
585 #[test]
586 fn test_format_mode_parses_and_defaults_to_fallback() {
587 let toml = r#"
588[languages]
589json = { format = ["jq"] }
590shell = { format = ["shuck:lint-fix", "shuck:format"], format-mode = "pipeline" }
591python = { format = ["ruff:format", "black"], format-mode = "fallback" }
592"#;
593 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
594 assert_eq!(config.languages["json"].format_mode, FormatMode::Fallback);
595 assert_eq!(config.languages["shell"].format_mode, FormatMode::Pipeline);
596 assert_eq!(config.languages["python"].format_mode, FormatMode::Fallback);
597
598 let invalid = "[languages]\njson = { format = [\"jq\"], format-mode = \"chain\" }\n";
599 let error = toml::from_str::<CodeBlockToolsConfig>(invalid).unwrap_err().to_string();
600 assert!(
601 error.contains("format-mode") || (error.contains("fallback") && error.contains("pipeline")),
602 "{error}"
603 );
604 }
605
606 #[test]
607 fn test_format_mode_round_trips() {
608 let config = LanguageToolConfig {
609 format: vec!["jq".to_string()],
610 format_mode: FormatMode::Pipeline,
611 ..Default::default()
612 };
613 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
614 assert!(toml.contains("format-mode = \"pipeline\""), "{toml}");
615 assert_eq!(toml::from_str::<LanguageToolConfig>(&toml).unwrap(), config);
616 }
617
618 #[test]
619 fn test_language_config_serialize_enabled_false() {
620 let config = LanguageToolConfig {
621 enabled: false,
622 ..Default::default()
623 };
624 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
625 assert!(toml.contains("enabled = false"));
626 }
627}