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/// Language normalization strategy.
103#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
104#[serde(rename_all = "kebab-case")]
105pub enum NormalizeLanguage {
106    /// Resolve language aliases using GitHub Linguist data (e.g., "py" -> "python")
107    #[default]
108    Linguist,
109    /// Use the language tag exactly as written in the code block
110    Exact,
111}
112
113/// Error handling strategy for tool execution failures.
114#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
115#[serde(rename_all = "kebab-case")]
116pub enum OnError {
117    /// Fail the lint/format operation (propagate error)
118    #[default]
119    Fail,
120    /// Skip the code block and continue processing
121    Skip,
122    /// Log a warning but continue processing
123    Warn,
124}
125
126/// Behavior when a language has no tools configured or a tool binary is missing.
127#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
128#[serde(rename_all = "kebab-case")]
129pub enum OnMissing {
130    /// Silently skip and continue processing
131    #[default]
132    Ignore,
133    /// Say once that the tool is missing, then continue as `ignore` does.
134    ///
135    /// A config warning rather than a finding: the run still exits 0, and
136    /// `--deny-config-warnings` is what turns it into a failure. This is the
137    /// default for a missing tool binary, because that is a fact about the
138    /// machine rather than about the document, and silence there means a run
139    /// that checked none of your code blocks reports success.
140    Warn,
141    /// Record an error for that block, continue processing, exit non-zero at the end
142    Fail,
143    /// Stop immediately on the first occurrence, exit non-zero
144    FailFast,
145}
146
147impl OnMissing {
148    /// Whether this setting leaves the block alone and reports nothing about it.
149    ///
150    /// `warn` reports, but once for the run rather than against a block, so from
151    /// a block's point of view it behaves exactly as `ignore` does. The two are
152    /// therefore equivalent for deciding whether a document has to be parsed at
153    /// all.
154    pub fn skips_the_block(self) -> bool {
155        matches!(self, OnMissing::Ignore | OnMissing::Warn)
156    }
157}
158
159/// Per-language tool configuration.
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
161#[serde(rename_all = "kebab-case")]
162pub struct LanguageToolConfig {
163    /// Whether code block tools are enabled for this language (default: true).
164    /// Set to false to acknowledge a language without configuring tools.
165    /// This satisfies strict mode (on-missing-language-definition) checks.
166    #[serde(default = "default_true")]
167    pub enabled: bool,
168
169    /// Tools to run in lint mode (rumdl check)
170    #[serde(default)]
171    pub lint: Vec<String>,
172
173    /// Tools to run in format mode (rumdl check --fix / rumdl fmt)
174    #[serde(default)]
175    pub format: Vec<String>,
176
177    /// Override global on-error setting for this language
178    #[serde(default)]
179    pub on_error: Option<OnError>,
180}
181
182impl Default for LanguageToolConfig {
183    fn default() -> Self {
184        Self {
185            enabled: true,
186            lint: Vec::new(),
187            format: Vec::new(),
188            on_error: None,
189        }
190    }
191}
192
193/// Definition of an external tool.
194///
195/// This describes how to invoke a tool and how it communicates.
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
197#[serde(rename_all = "kebab-case")]
198pub struct ToolDefinition {
199    /// Command to run (first element is the binary, rest are arguments)
200    pub command: Vec<String>,
201
202    /// Whether the tool reads from stdin (default: true)
203    #[serde(default = "default_true")]
204    pub stdin: bool,
205
206    /// Whether the tool writes to stdout (default: true)
207    #[serde(default = "default_true")]
208    pub stdout: bool,
209
210    /// Additional arguments for lint mode (appended to command)
211    #[serde(default)]
212    pub lint_args: Vec<String>,
213
214    /// Additional arguments for format mode (appended to command)
215    #[serde(default)]
216    pub format_args: Vec<String>,
217}
218
219fn default_true() -> bool {
220    true
221}
222
223impl Default for ToolDefinition {
224    fn default() -> Self {
225        Self {
226            command: Vec::new(),
227            stdin: true,
228            stdout: true,
229            lint_args: Vec::new(),
230            format_args: Vec::new(),
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_default_config() {
241        let config = CodeBlockToolsConfig::default();
242        assert!(!config.enabled);
243        assert_eq!(config.normalize_language, NormalizeLanguage::Linguist);
244        assert_eq!(config.on_error, OnError::Fail);
245        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
246        assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
247        assert_eq!(config.timeout, 30_000);
248        assert!(config.languages.is_empty());
249        assert!(config.language_aliases.is_empty());
250        assert!(config.tools.is_empty());
251    }
252
253    #[test]
254    fn test_deserialize_config() {
255        let toml = r#"
256enabled = true
257normalize-language = "exact"
258on-error = "skip"
259timeout = 60000
260
261[languages.python]
262lint = ["ruff:check"]
263format = ["ruff:format"]
264
265[languages.json]
266format = ["prettier"]
267on-error = "warn"
268
269[language-aliases]
270py = "python"
271bash = "shell"
272
273[tools.custom-tool]
274command = ["my-tool", "--format"]
275stdin = true
276stdout = true
277"#;
278
279        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
280
281        assert!(config.enabled);
282        assert_eq!(config.normalize_language, NormalizeLanguage::Exact);
283        assert_eq!(config.on_error, OnError::Skip);
284        assert_eq!(config.timeout, 60_000);
285
286        let python = config.languages.get("python").expect("Missing python config");
287        assert_eq!(python.lint, vec!["ruff:check"]);
288        assert_eq!(python.format, vec!["ruff:format"]);
289        assert_eq!(python.on_error, None);
290
291        let json = config.languages.get("json").expect("Missing json config");
292        assert!(json.lint.is_empty());
293        assert_eq!(json.format, vec!["prettier"]);
294        assert_eq!(json.on_error, Some(OnError::Warn));
295
296        assert_eq!(config.language_aliases.get("py").map(String::as_str), Some("python"));
297        assert_eq!(config.language_aliases.get("bash").map(String::as_str), Some("shell"));
298
299        let tool = config.tools.get("custom-tool").expect("Missing custom tool");
300        assert_eq!(tool.command, vec!["my-tool", "--format"]);
301        assert!(tool.stdin);
302        assert!(tool.stdout);
303    }
304
305    #[test]
306    fn test_serialize_config() {
307        let mut config = CodeBlockToolsConfig {
308            enabled: true,
309            ..Default::default()
310        };
311        config.languages.insert(
312            "rust".to_string(),
313            LanguageToolConfig {
314                format: vec!["rustfmt".to_string()],
315                ..Default::default()
316            },
317        );
318
319        let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
320        assert!(toml.contains("enabled = true"));
321        assert!(toml.contains("[languages.rust]"));
322        assert!(toml.contains("rustfmt"));
323    }
324
325    #[test]
326    fn test_on_missing_options() {
327        let toml = r#"
328enabled = true
329on-missing-language-definition = "fail"
330on-missing-tool-binary = "fail-fast"
331"#;
332
333        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
334
335        assert_eq!(config.on_missing_language_definition, OnMissing::Fail);
336        assert_eq!(config.on_missing_tool_binary, OnMissing::FailFast);
337    }
338
339    #[test]
340    fn test_on_missing_defaults() {
341        let toml = r#"
342enabled = true
343"#;
344
345        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
346
347        // A language a config never mentioned is not something rumdl has an
348        // opinion about, so it stays silent.
349        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
350        // A tool the config did name, and the machine does not have, is a gap
351        // between the two that the run has to mention.
352        assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
353    }
354
355    #[test]
356    fn test_on_missing_all_variants() {
357        // Test all variants deserialize correctly
358        for (input, expected) in [
359            ("ignore", OnMissing::Ignore),
360            ("warn", OnMissing::Warn),
361            ("fail", OnMissing::Fail),
362            ("fail-fast", OnMissing::FailFast),
363        ] {
364            let toml = format!(
365                r#"
366enabled = true
367on-missing-language-definition = "{input}"
368"#
369            );
370            let config: CodeBlockToolsConfig = toml::from_str(&toml).expect("Failed to parse TOML");
371            assert_eq!(
372                config.on_missing_language_definition, expected,
373                "Failed for variant: {input}"
374            );
375        }
376    }
377
378    #[test]
379    fn test_language_config_enabled_defaults_to_true() {
380        // Deserializing without `enabled` should default to true
381        let toml = r#"
382lint = ["ruff:check"]
383"#;
384        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
385        assert!(config.enabled);
386        assert_eq!(config.lint, vec!["ruff:check"]);
387        assert!(config.format.is_empty());
388    }
389
390    #[test]
391    fn test_language_config_enabled_false() {
392        // Explicitly set enabled = false
393        let toml = r#"
394enabled = false
395"#;
396        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
397        assert!(!config.enabled);
398        assert!(config.lint.is_empty());
399        assert!(config.format.is_empty());
400    }
401
402    #[test]
403    fn test_language_config_enabled_false_with_tools() {
404        // enabled=false should be respected even when tools are configured
405        let toml = r#"
406enabled = false
407lint = ["ruff:check"]
408format = ["ruff:format"]
409"#;
410        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
411        assert!(!config.enabled);
412        assert_eq!(config.lint, vec!["ruff:check"]);
413        assert_eq!(config.format, vec!["ruff:format"]);
414    }
415
416    #[test]
417    fn test_language_config_enabled_in_full_config() {
418        // Test enabled field within a full CodeBlockToolsConfig
419        let toml = r#"
420enabled = true
421on-missing-language-definition = "fail"
422
423[languages.python]
424lint = ["ruff:check"]
425
426[languages.plaintext]
427enabled = false
428"#;
429        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
430
431        let python = config.languages.get("python").expect("Missing python config");
432        assert!(python.enabled);
433        assert_eq!(python.lint, vec!["ruff:check"]);
434
435        let plaintext = config.languages.get("plaintext").expect("Missing plaintext config");
436        assert!(!plaintext.enabled);
437        assert!(plaintext.lint.is_empty());
438    }
439
440    #[test]
441    fn test_language_config_default_trait() {
442        let config = LanguageToolConfig::default();
443        assert!(config.enabled);
444        assert!(config.lint.is_empty());
445        assert!(config.format.is_empty());
446        assert!(config.on_error.is_none());
447    }
448
449    #[test]
450    fn test_language_config_serialize_enabled_false() {
451        let config = LanguageToolConfig {
452            enabled: false,
453            ..Default::default()
454        };
455        let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
456        assert!(toml.contains("enabled = false"));
457    }
458}