Skip to main content

rumdl_lib/code_block_tools/
config.rs

1//! Configuration types for code block tools.
2//!
3//! This module defines the configuration schema for per-language code block
4//! linting and formatting using external tools.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// Master configuration for code block tools.
10///
11/// This is disabled by default for safety - users must explicitly enable it.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
13#[serde(rename_all = "kebab-case")]
14pub struct CodeBlockToolsConfig {
15    /// Master switch (default: false)
16    #[serde(default)]
17    pub enabled: bool,
18
19    /// Language normalization strategy
20    #[serde(default)]
21    pub normalize_language: NormalizeLanguage,
22
23    /// Global error handling strategy
24    #[serde(default)]
25    pub on_error: OnError,
26
27    /// Behavior when a code block language has no tools configured for the current mode
28    /// (e.g., no lint tools for `rumdl check`, no format tools for `rumdl check --fix`)
29    #[serde(default)]
30    pub on_missing_language_definition: OnMissing,
31
32    /// Behavior when a configured tool's binary cannot be found (e.g., not in PATH).
33    /// Defaults to `warn`: the tools rumdl drives are installed separately from
34    /// rumdl, so an absent one is common enough that silence about it is a trap.
35    #[serde(default = "default_on_missing_tool_binary")]
36    pub on_missing_tool_binary: OnMissing,
37
38    /// Timeout per tool execution in milliseconds (default: 30000)
39    #[serde(default = "default_timeout")]
40    #[schemars(schema_with = "schema_timeout")]
41    pub timeout: u64,
42
43    /// Per-language tool configuration
44    #[serde(default)]
45    pub languages: BTreeMap<String, LanguageToolConfig>,
46
47    /// User-defined language aliases (override built-in resolution)
48    /// Example: { "py": "python", "bash": "shell" }
49    #[serde(default)]
50    pub language_aliases: BTreeMap<String, String>,
51
52    /// Custom tool definitions (override built-ins)
53    #[serde(default)]
54    pub tools: BTreeMap<String, ToolDefinition>,
55
56    /// Whether this section came from a config file whose contents may not be quoted
57    /// back (an `extends` target, whose path is arbitrary and whose text the extending
58    /// project need not be able to read). The settings apply as written; only a message
59    /// about one has to leave it out.
60    ///
61    /// Provenance rather than configuration, so it stays out of the serialized form and
62    /// the JSON schema. The whole section is replaced as one value when configs merge,
63    /// so the mark travels with the settings it describes.
64    #[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
77/// Generate a JSON Schema for timeout using standard integer type.
78fn 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/// How a language's `format` list is applied to a code block.
103#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
104#[serde(rename_all = "kebab-case")]
105pub enum FormatMode {
106    /// Try the formatters in order; the first one that succeeds supplies the
107    /// block, even when it changes nothing
108    #[default]
109    Fallback,
110    /// Run every formatter in order, each on the output of the last one that
111    /// succeeded, and replace the block with the final result
112    Pipeline,
113}
114
115/// Language normalization strategy.
116#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
117#[serde(rename_all = "kebab-case")]
118pub enum NormalizeLanguage {
119    /// Resolve language aliases using GitHub Linguist data (e.g., "py" -> "python")
120    #[default]
121    Linguist,
122    /// Use the language tag exactly as written in the code block
123    Exact,
124}
125
126/// Error handling strategy for tool execution failures.
127#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
128#[serde(rename_all = "kebab-case")]
129pub enum OnError {
130    /// Fail the lint/format operation (propagate error)
131    #[default]
132    Fail,
133    /// Continue with the next tool without a warning
134    Skip,
135    /// Log a warning but continue processing
136    Warn,
137}
138
139/// Behavior when a language has no tools configured or a tool binary is missing.
140#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
141#[serde(rename_all = "kebab-case")]
142pub enum OnMissing {
143    /// Silently skip and continue processing
144    #[default]
145    Ignore,
146    /// Say once that the tool is missing, then continue as `ignore` does.
147    ///
148    /// A config warning rather than a finding: the run still exits 0, and
149    /// `--deny-config-warnings` is what turns it into a failure. This is the
150    /// default for a missing tool binary, because that is a fact about the
151    /// machine rather than about the document, and silence there means a run
152    /// that checked none of your code blocks reports success.
153    Warn,
154    /// Record an error for that block, continue processing, exit non-zero at the end
155    Fail,
156    /// Stop immediately on the first occurrence, exit non-zero
157    FailFast,
158}
159
160impl OnMissing {
161    /// Whether this setting leaves the block alone and reports nothing about it.
162    ///
163    /// `warn` reports, but once for the run rather than against a block, so from
164    /// a block's point of view it behaves exactly as `ignore` does. The two are
165    /// therefore equivalent for deciding whether a document has to be parsed at
166    /// all.
167    pub fn skips_the_block(self) -> bool {
168        matches!(self, OnMissing::Ignore | OnMissing::Warn)
169    }
170}
171
172/// Per-language tool configuration.
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
174#[serde(rename_all = "kebab-case")]
175pub struct LanguageToolConfig {
176    /// Whether code block tools are enabled for this language (default: true).
177    /// Set to false to acknowledge a language without configuring tools.
178    /// This satisfies strict mode (on-missing-language-definition) checks.
179    #[serde(default = "default_true")]
180    pub enabled: bool,
181
182    /// Tools to run in lint mode (rumdl check)
183    #[serde(default)]
184    pub lint: Vec<String>,
185
186    /// Tools to run in format mode (rumdl check --fix / rumdl fmt)
187    #[serde(default)]
188    pub format: Vec<String>,
189
190    /// How the `format` list is applied: as fallbacks (default) or as a pipeline
191    #[serde(default)]
192    pub format_mode: FormatMode,
193
194    /// Override global on-error setting for this language
195    #[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/// Definition of an external tool.
212///
213/// This describes how to invoke a tool and how it communicates.
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
215#[serde(rename_all = "kebab-case")]
216pub struct ToolDefinition {
217    /// Command to run (first element is the binary, rest are arguments)
218    pub command: Vec<String>,
219
220    /// Whether the tool reads from stdin (default: true)
221    #[serde(default = "default_true")]
222    pub stdin: bool,
223
224    /// Whether the tool writes to stdout (default: true)
225    #[serde(default = "default_true")]
226    pub stdout: bool,
227
228    /// Additional arguments for lint mode (appended to command)
229    #[serde(default)]
230    pub lint_args: Vec<String>,
231
232    /// Additional arguments for format mode (appended to command)
233    #[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        // A language a config never mentioned is not something rumdl has an
366        // opinion about, so it stays silent.
367        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
368        // A tool the config did name, and the machine does not have, is a gap
369        // between the two that the run has to mention.
370        assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
371    }
372
373    #[test]
374    fn test_on_missing_all_variants() {
375        // Test all variants deserialize correctly
376        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        // Deserializing without `enabled` should default to true
399        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        // Explicitly set enabled = false
411        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        // enabled=false should be respected even when tools are configured
423        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        // Test enabled field within a full CodeBlockToolsConfig
437        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}