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