rumdl_lib/code_block_tools/
config.rs1use 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)]
30 pub on_missing_language_definition: OnMissing,
31
32 #[serde(default = "default_on_missing_tool_binary")]
36 pub on_missing_tool_binary: OnMissing,
37
38 #[serde(default = "default_timeout")]
40 #[schemars(schema_with = "schema_timeout")]
41 pub timeout: u64,
42
43 #[serde(default)]
45 pub languages: BTreeMap<String, LanguageToolConfig>,
46
47 #[serde(default)]
50 pub language_aliases: BTreeMap<String, String>,
51
52 #[serde(default)]
54 pub tools: BTreeMap<String, ToolDefinition>,
55
56 #[serde(skip)]
65 #[schemars(skip)]
66 pub values_withheld: bool,
67}
68
69fn default_timeout() -> u64 {
70 30_000
71}
72
73fn default_on_missing_tool_binary() -> OnMissing {
74 OnMissing::Warn
75}
76
77fn schema_timeout(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
79 schemars::json_schema!({
80 "type": "integer",
81 "minimum": 0
82 })
83}
84
85impl Default for CodeBlockToolsConfig {
86 fn default() -> Self {
87 Self {
88 enabled: false,
89 normalize_language: NormalizeLanguage::default(),
90 on_error: OnError::default(),
91 on_missing_language_definition: OnMissing::default(),
92 on_missing_tool_binary: default_on_missing_tool_binary(),
93 timeout: default_timeout(),
94 languages: BTreeMap::new(),
95 language_aliases: BTreeMap::new(),
96 tools: BTreeMap::new(),
97 values_withheld: false,
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
104#[serde(rename_all = "kebab-case")]
105pub enum FormatMode {
106 #[default]
109 Fallback,
110 Pipeline,
113}
114
115#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
117#[serde(rename_all = "kebab-case")]
118pub enum NormalizeLanguage {
119 #[default]
121 Linguist,
122 Exact,
124}
125
126#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
128#[serde(rename_all = "kebab-case")]
129pub enum OnError {
130 #[default]
132 Fail,
133 Skip,
135 Warn,
137}
138
139#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
141#[serde(rename_all = "kebab-case")]
142pub enum OnMissing {
143 #[default]
145 Ignore,
146 Warn,
154 Fail,
156 FailFast,
158}
159
160impl OnMissing {
161 pub fn skips_the_block(self) -> bool {
168 matches!(self, OnMissing::Ignore | OnMissing::Warn)
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
174#[serde(rename_all = "kebab-case")]
175pub struct LanguageToolConfig {
176 #[serde(default = "default_true")]
180 pub enabled: bool,
181
182 #[serde(default)]
184 pub lint: Vec<String>,
185
186 #[serde(default)]
188 pub format: Vec<String>,
189
190 #[serde(default)]
192 pub format_mode: FormatMode,
193
194 #[serde(default)]
196 pub on_error: Option<OnError>,
197}
198
199impl Default for LanguageToolConfig {
200 fn default() -> Self {
201 Self {
202 enabled: true,
203 lint: Vec::new(),
204 format: Vec::new(),
205 format_mode: FormatMode::default(),
206 on_error: None,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
215#[serde(rename_all = "kebab-case")]
216pub struct ToolDefinition {
217 pub command: Vec<String>,
219
220 #[serde(default = "default_true")]
222 pub stdin: bool,
223
224 #[serde(default = "default_true")]
226 pub stdout: bool,
227
228 #[serde(default)]
230 pub lint_args: Vec<String>,
231
232 #[serde(default)]
234 pub format_args: Vec<String>,
235}
236
237fn default_true() -> bool {
238 true
239}
240
241impl Default for ToolDefinition {
242 fn default() -> Self {
243 Self {
244 command: Vec::new(),
245 stdin: true,
246 stdout: true,
247 lint_args: Vec::new(),
248 format_args: Vec::new(),
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_default_config() {
259 let config = CodeBlockToolsConfig::default();
260 assert!(!config.enabled);
261 assert_eq!(config.normalize_language, NormalizeLanguage::Linguist);
262 assert_eq!(config.on_error, OnError::Fail);
263 assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
264 assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
265 assert_eq!(config.timeout, 30_000);
266 assert!(config.languages.is_empty());
267 assert!(config.language_aliases.is_empty());
268 assert!(config.tools.is_empty());
269 }
270
271 #[test]
272 fn test_deserialize_config() {
273 let toml = r#"
274enabled = true
275normalize-language = "exact"
276on-error = "skip"
277timeout = 60000
278
279[languages.python]
280lint = ["ruff:check"]
281format = ["ruff:format"]
282
283[languages.json]
284format = ["prettier"]
285on-error = "warn"
286
287[language-aliases]
288py = "python"
289bash = "shell"
290
291[tools.custom-tool]
292command = ["my-tool", "--format"]
293stdin = true
294stdout = true
295"#;
296
297 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
298
299 assert!(config.enabled);
300 assert_eq!(config.normalize_language, NormalizeLanguage::Exact);
301 assert_eq!(config.on_error, OnError::Skip);
302 assert_eq!(config.timeout, 60_000);
303
304 let python = config.languages.get("python").expect("Missing python config");
305 assert_eq!(python.lint, vec!["ruff:check"]);
306 assert_eq!(python.format, vec!["ruff:format"]);
307 assert_eq!(python.on_error, None);
308
309 let json = config.languages.get("json").expect("Missing json config");
310 assert!(json.lint.is_empty());
311 assert_eq!(json.format, vec!["prettier"]);
312 assert_eq!(json.on_error, Some(OnError::Warn));
313
314 assert_eq!(config.language_aliases.get("py").map(String::as_str), Some("python"));
315 assert_eq!(config.language_aliases.get("bash").map(String::as_str), Some("shell"));
316
317 let tool = config.tools.get("custom-tool").expect("Missing custom tool");
318 assert_eq!(tool.command, vec!["my-tool", "--format"]);
319 assert!(tool.stdin);
320 assert!(tool.stdout);
321 }
322
323 #[test]
324 fn test_serialize_config() {
325 let mut config = CodeBlockToolsConfig {
326 enabled: true,
327 ..Default::default()
328 };
329 config.languages.insert(
330 "rust".to_string(),
331 LanguageToolConfig {
332 format: vec!["rustfmt".to_string()],
333 ..Default::default()
334 },
335 );
336
337 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
338 assert!(toml.contains("enabled = true"));
339 assert!(toml.contains("[languages.rust]"));
340 assert!(toml.contains("rustfmt"));
341 }
342
343 #[test]
344 fn test_on_missing_options() {
345 let toml = r#"
346enabled = true
347on-missing-language-definition = "fail"
348on-missing-tool-binary = "fail-fast"
349"#;
350
351 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
352
353 assert_eq!(config.on_missing_language_definition, OnMissing::Fail);
354 assert_eq!(config.on_missing_tool_binary, OnMissing::FailFast);
355 }
356
357 #[test]
358 fn test_on_missing_defaults() {
359 let toml = r#"
360enabled = true
361"#;
362
363 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
364
365 assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
368 assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
371 }
372
373 #[test]
374 fn test_on_missing_all_variants() {
375 for (input, expected) in [
377 ("ignore", OnMissing::Ignore),
378 ("warn", OnMissing::Warn),
379 ("fail", OnMissing::Fail),
380 ("fail-fast", OnMissing::FailFast),
381 ] {
382 let toml = format!(
383 r#"
384enabled = true
385on-missing-language-definition = "{input}"
386"#
387 );
388 let config: CodeBlockToolsConfig = toml::from_str(&toml).expect("Failed to parse TOML");
389 assert_eq!(
390 config.on_missing_language_definition, expected,
391 "Failed for variant: {input}"
392 );
393 }
394 }
395
396 #[test]
397 fn test_language_config_enabled_defaults_to_true() {
398 let toml = r#"
400lint = ["ruff:check"]
401"#;
402 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
403 assert!(config.enabled);
404 assert_eq!(config.lint, vec!["ruff:check"]);
405 assert!(config.format.is_empty());
406 }
407
408 #[test]
409 fn test_language_config_enabled_false() {
410 let toml = r#"
412enabled = false
413"#;
414 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
415 assert!(!config.enabled);
416 assert!(config.lint.is_empty());
417 assert!(config.format.is_empty());
418 }
419
420 #[test]
421 fn test_language_config_enabled_false_with_tools() {
422 let toml = r#"
424enabled = false
425lint = ["ruff:check"]
426format = ["ruff:format"]
427"#;
428 let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
429 assert!(!config.enabled);
430 assert_eq!(config.lint, vec!["ruff:check"]);
431 assert_eq!(config.format, vec!["ruff:format"]);
432 }
433
434 #[test]
435 fn test_language_config_enabled_in_full_config() {
436 let toml = r#"
438enabled = true
439on-missing-language-definition = "fail"
440
441[languages.python]
442lint = ["ruff:check"]
443
444[languages.plaintext]
445enabled = false
446"#;
447 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
448
449 let python = config.languages.get("python").expect("Missing python config");
450 assert!(python.enabled);
451 assert_eq!(python.lint, vec!["ruff:check"]);
452
453 let plaintext = config.languages.get("plaintext").expect("Missing plaintext config");
454 assert!(!plaintext.enabled);
455 assert!(plaintext.lint.is_empty());
456 }
457
458 #[test]
459 fn test_language_config_default_trait() {
460 let config = LanguageToolConfig::default();
461 assert!(config.enabled);
462 assert!(config.lint.is_empty());
463 assert!(config.format.is_empty());
464 assert!(config.on_error.is_none());
465 }
466
467 #[test]
468 fn test_format_mode_parses_and_defaults_to_fallback() {
469 let toml = r#"
470[languages]
471json = { format = ["jq"] }
472shell = { format = ["shuck:lint-fix", "shuck:format"], format-mode = "pipeline" }
473python = { format = ["ruff:format", "black"], format-mode = "fallback" }
474"#;
475 let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
476 assert_eq!(config.languages["json"].format_mode, FormatMode::Fallback);
477 assert_eq!(config.languages["shell"].format_mode, FormatMode::Pipeline);
478 assert_eq!(config.languages["python"].format_mode, FormatMode::Fallback);
479
480 let invalid = "[languages]\njson = { format = [\"jq\"], format-mode = \"chain\" }\n";
481 let error = toml::from_str::<CodeBlockToolsConfig>(invalid).unwrap_err().to_string();
482 assert!(
483 error.contains("format-mode") || (error.contains("fallback") && error.contains("pipeline")),
484 "{error}"
485 );
486 }
487
488 #[test]
489 fn test_format_mode_round_trips() {
490 let config = LanguageToolConfig {
491 format: vec!["jq".to_string()],
492 format_mode: FormatMode::Pipeline,
493 ..Default::default()
494 };
495 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
496 assert!(toml.contains("format-mode = \"pipeline\""), "{toml}");
497 assert_eq!(toml::from_str::<LanguageToolConfig>(&toml).unwrap(), config);
498 }
499
500 #[test]
501 fn test_language_config_serialize_enabled_false() {
502 let config = LanguageToolConfig {
503 enabled: false,
504 ..Default::default()
505 };
506 let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
507 assert!(toml.contains("enabled = false"));
508 }
509}