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    #[serde(default)]
34    pub on_missing_tool_binary: OnMissing,
35
36    /// Timeout per tool execution in milliseconds (default: 30000)
37    #[serde(default = "default_timeout")]
38    #[schemars(schema_with = "schema_timeout")]
39    pub timeout: u64,
40
41    /// Per-language tool configuration
42    #[serde(default)]
43    pub languages: BTreeMap<String, LanguageToolConfig>,
44
45    /// User-defined language aliases (override built-in resolution)
46    /// Example: { "py": "python", "bash": "shell" }
47    #[serde(default)]
48    pub language_aliases: BTreeMap<String, String>,
49
50    /// Custom tool definitions (override built-ins)
51    #[serde(default)]
52    pub tools: BTreeMap<String, ToolDefinition>,
53
54    /// Whether this section came from a config file whose contents may not be quoted
55    /// back (an `extends` target, whose path is arbitrary and whose text the extending
56    /// project need not be able to read). The settings apply as written; only a message
57    /// about one has to leave it out.
58    ///
59    /// Provenance rather than configuration, so it stays out of the serialized form and
60    /// the JSON schema. The whole section is replaced as one value when configs merge,
61    /// so the mark travels with the settings it describes.
62    #[serde(skip)]
63    #[schemars(skip)]
64    pub values_withheld: bool,
65}
66
67fn default_timeout() -> u64 {
68    30_000
69}
70
71/// Generate a JSON Schema for timeout using standard integer type.
72fn schema_timeout(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
73    schemars::json_schema!({
74        "type": "integer",
75        "minimum": 0
76    })
77}
78
79impl Default for CodeBlockToolsConfig {
80    fn default() -> Self {
81        Self {
82            enabled: false,
83            normalize_language: NormalizeLanguage::default(),
84            on_error: OnError::default(),
85            on_missing_language_definition: OnMissing::default(),
86            on_missing_tool_binary: OnMissing::default(),
87            timeout: default_timeout(),
88            languages: BTreeMap::new(),
89            language_aliases: BTreeMap::new(),
90            tools: BTreeMap::new(),
91            values_withheld: false,
92        }
93    }
94}
95
96/// Language normalization strategy.
97#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
98#[serde(rename_all = "kebab-case")]
99pub enum NormalizeLanguage {
100    /// Resolve language aliases using GitHub Linguist data (e.g., "py" -> "python")
101    #[default]
102    Linguist,
103    /// Use the language tag exactly as written in the code block
104    Exact,
105}
106
107/// Error handling strategy for tool execution failures.
108#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
109#[serde(rename_all = "kebab-case")]
110pub enum OnError {
111    /// Fail the lint/format operation (propagate error)
112    #[default]
113    Fail,
114    /// Skip the code block and continue processing
115    Skip,
116    /// Log a warning but continue processing
117    Warn,
118}
119
120/// Behavior when a language has no tools configured or a tool binary is missing.
121#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
122#[serde(rename_all = "kebab-case")]
123pub enum OnMissing {
124    /// Silently skip and continue processing (default for backward compatibility)
125    #[default]
126    Ignore,
127    /// Record an error for that block, continue processing, exit non-zero at the end
128    Fail,
129    /// Stop immediately on the first occurrence, exit non-zero
130    FailFast,
131}
132
133/// Per-language tool configuration.
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
135#[serde(rename_all = "kebab-case")]
136pub struct LanguageToolConfig {
137    /// Whether code block tools are enabled for this language (default: true).
138    /// Set to false to acknowledge a language without configuring tools.
139    /// This satisfies strict mode (on-missing-language-definition) checks.
140    #[serde(default = "default_true")]
141    pub enabled: bool,
142
143    /// Tools to run in lint mode (rumdl check)
144    #[serde(default)]
145    pub lint: Vec<String>,
146
147    /// Tools to run in format mode (rumdl check --fix / rumdl fmt)
148    #[serde(default)]
149    pub format: Vec<String>,
150
151    /// Override global on-error setting for this language
152    #[serde(default)]
153    pub on_error: Option<OnError>,
154}
155
156impl Default for LanguageToolConfig {
157    fn default() -> Self {
158        Self {
159            enabled: true,
160            lint: Vec::new(),
161            format: Vec::new(),
162            on_error: None,
163        }
164    }
165}
166
167/// Definition of an external tool.
168///
169/// This describes how to invoke a tool and how it communicates.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
171#[serde(rename_all = "kebab-case")]
172pub struct ToolDefinition {
173    /// Command to run (first element is the binary, rest are arguments)
174    pub command: Vec<String>,
175
176    /// Whether the tool reads from stdin (default: true)
177    #[serde(default = "default_true")]
178    pub stdin: bool,
179
180    /// Whether the tool writes to stdout (default: true)
181    #[serde(default = "default_true")]
182    pub stdout: bool,
183
184    /// Additional arguments for lint mode (appended to command)
185    #[serde(default)]
186    pub lint_args: Vec<String>,
187
188    /// Additional arguments for format mode (appended to command)
189    #[serde(default)]
190    pub format_args: Vec<String>,
191}
192
193fn default_true() -> bool {
194    true
195}
196
197impl Default for ToolDefinition {
198    fn default() -> Self {
199        Self {
200            command: Vec::new(),
201            stdin: true,
202            stdout: true,
203            lint_args: Vec::new(),
204            format_args: Vec::new(),
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_default_config() {
215        let config = CodeBlockToolsConfig::default();
216        assert!(!config.enabled);
217        assert_eq!(config.normalize_language, NormalizeLanguage::Linguist);
218        assert_eq!(config.on_error, OnError::Fail);
219        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
220        assert_eq!(config.on_missing_tool_binary, OnMissing::Ignore);
221        assert_eq!(config.timeout, 30_000);
222        assert!(config.languages.is_empty());
223        assert!(config.language_aliases.is_empty());
224        assert!(config.tools.is_empty());
225    }
226
227    #[test]
228    fn test_deserialize_config() {
229        let toml = r#"
230enabled = true
231normalize-language = "exact"
232on-error = "skip"
233timeout = 60000
234
235[languages.python]
236lint = ["ruff:check"]
237format = ["ruff:format"]
238
239[languages.json]
240format = ["prettier"]
241on-error = "warn"
242
243[language-aliases]
244py = "python"
245bash = "shell"
246
247[tools.custom-tool]
248command = ["my-tool", "--format"]
249stdin = true
250stdout = true
251"#;
252
253        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
254
255        assert!(config.enabled);
256        assert_eq!(config.normalize_language, NormalizeLanguage::Exact);
257        assert_eq!(config.on_error, OnError::Skip);
258        assert_eq!(config.timeout, 60_000);
259
260        let python = config.languages.get("python").expect("Missing python config");
261        assert_eq!(python.lint, vec!["ruff:check"]);
262        assert_eq!(python.format, vec!["ruff:format"]);
263        assert_eq!(python.on_error, None);
264
265        let json = config.languages.get("json").expect("Missing json config");
266        assert!(json.lint.is_empty());
267        assert_eq!(json.format, vec!["prettier"]);
268        assert_eq!(json.on_error, Some(OnError::Warn));
269
270        assert_eq!(config.language_aliases.get("py").map(String::as_str), Some("python"));
271        assert_eq!(config.language_aliases.get("bash").map(String::as_str), Some("shell"));
272
273        let tool = config.tools.get("custom-tool").expect("Missing custom tool");
274        assert_eq!(tool.command, vec!["my-tool", "--format"]);
275        assert!(tool.stdin);
276        assert!(tool.stdout);
277    }
278
279    #[test]
280    fn test_serialize_config() {
281        let mut config = CodeBlockToolsConfig {
282            enabled: true,
283            ..Default::default()
284        };
285        config.languages.insert(
286            "rust".to_string(),
287            LanguageToolConfig {
288                format: vec!["rustfmt".to_string()],
289                ..Default::default()
290            },
291        );
292
293        let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
294        assert!(toml.contains("enabled = true"));
295        assert!(toml.contains("[languages.rust]"));
296        assert!(toml.contains("rustfmt"));
297    }
298
299    #[test]
300    fn test_on_missing_options() {
301        let toml = r#"
302enabled = true
303on-missing-language-definition = "fail"
304on-missing-tool-binary = "fail-fast"
305"#;
306
307        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
308
309        assert_eq!(config.on_missing_language_definition, OnMissing::Fail);
310        assert_eq!(config.on_missing_tool_binary, OnMissing::FailFast);
311    }
312
313    #[test]
314    fn test_on_missing_default_ignore() {
315        let toml = r#"
316enabled = true
317"#;
318
319        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
320
321        // Both should default to Ignore for backward compatibility
322        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
323        assert_eq!(config.on_missing_tool_binary, OnMissing::Ignore);
324    }
325
326    #[test]
327    fn test_on_missing_all_variants() {
328        // Test all variants deserialize correctly
329        for (input, expected) in [
330            ("ignore", OnMissing::Ignore),
331            ("fail", OnMissing::Fail),
332            ("fail-fast", OnMissing::FailFast),
333        ] {
334            let toml = format!(
335                r#"
336enabled = true
337on-missing-language-definition = "{input}"
338"#
339            );
340            let config: CodeBlockToolsConfig = toml::from_str(&toml).expect("Failed to parse TOML");
341            assert_eq!(
342                config.on_missing_language_definition, expected,
343                "Failed for variant: {input}"
344            );
345        }
346    }
347
348    #[test]
349    fn test_language_config_enabled_defaults_to_true() {
350        // Deserializing without `enabled` should default to true
351        let toml = r#"
352lint = ["ruff:check"]
353"#;
354        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
355        assert!(config.enabled);
356        assert_eq!(config.lint, vec!["ruff:check"]);
357        assert!(config.format.is_empty());
358    }
359
360    #[test]
361    fn test_language_config_enabled_false() {
362        // Explicitly set enabled = false
363        let toml = r#"
364enabled = false
365"#;
366        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
367        assert!(!config.enabled);
368        assert!(config.lint.is_empty());
369        assert!(config.format.is_empty());
370    }
371
372    #[test]
373    fn test_language_config_enabled_false_with_tools() {
374        // enabled=false should be respected even when tools are configured
375        let toml = r#"
376enabled = false
377lint = ["ruff:check"]
378format = ["ruff:format"]
379"#;
380        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
381        assert!(!config.enabled);
382        assert_eq!(config.lint, vec!["ruff:check"]);
383        assert_eq!(config.format, vec!["ruff:format"]);
384    }
385
386    #[test]
387    fn test_language_config_enabled_in_full_config() {
388        // Test enabled field within a full CodeBlockToolsConfig
389        let toml = r#"
390enabled = true
391on-missing-language-definition = "fail"
392
393[languages.python]
394lint = ["ruff:check"]
395
396[languages.plaintext]
397enabled = false
398"#;
399        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
400
401        let python = config.languages.get("python").expect("Missing python config");
402        assert!(python.enabled);
403        assert_eq!(python.lint, vec!["ruff:check"]);
404
405        let plaintext = config.languages.get("plaintext").expect("Missing plaintext config");
406        assert!(!plaintext.enabled);
407        assert!(plaintext.lint.is_empty());
408    }
409
410    #[test]
411    fn test_language_config_default_trait() {
412        let config = LanguageToolConfig::default();
413        assert!(config.enabled);
414        assert!(config.lint.is_empty());
415        assert!(config.format.is_empty());
416        assert!(config.on_error.is_none());
417    }
418
419    #[test]
420    fn test_language_config_serialize_enabled_false() {
421        let config = LanguageToolConfig {
422            enabled: false,
423            ..Default::default()
424        };
425        let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
426        assert!(toml.contains("enabled = false"));
427    }
428}