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, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
122#[serde(rename_all = "kebab-case")]
123pub struct LanguageToolConfig {
124    /// Tools to run in lint mode (rumdl check)
125    #[serde(default)]
126    pub lint: Vec<String>,
127
128    /// Tools to run in format mode (rumdl check --fix / rumdl fmt)
129    #[serde(default)]
130    pub format: Vec<String>,
131
132    /// Override global on-error setting for this language
133    #[serde(default)]
134    pub on_error: Option<OnError>,
135}
136
137/// Definition of an external tool.
138///
139/// This describes how to invoke a tool and how it communicates.
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
141#[serde(rename_all = "kebab-case")]
142pub struct ToolDefinition {
143    /// Command to run (first element is the binary, rest are arguments)
144    pub command: Vec<String>,
145
146    /// Whether the tool reads from stdin (default: true)
147    #[serde(default = "default_true")]
148    pub stdin: bool,
149
150    /// Whether the tool writes to stdout (default: true)
151    #[serde(default = "default_true")]
152    pub stdout: bool,
153
154    /// Additional arguments for lint mode (appended to command)
155    #[serde(default)]
156    pub lint_args: Vec<String>,
157
158    /// Additional arguments for format mode (appended to command)
159    #[serde(default)]
160    pub format_args: Vec<String>,
161}
162
163fn default_true() -> bool {
164    true
165}
166
167impl Default for ToolDefinition {
168    fn default() -> Self {
169        Self {
170            command: Vec::new(),
171            stdin: true,
172            stdout: true,
173            lint_args: Vec::new(),
174            format_args: Vec::new(),
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_default_config() {
185        let config = CodeBlockToolsConfig::default();
186        assert!(!config.enabled);
187        assert_eq!(config.normalize_language, NormalizeLanguage::Linguist);
188        assert_eq!(config.on_error, OnError::Fail);
189        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
190        assert_eq!(config.on_missing_tool_binary, OnMissing::Ignore);
191        assert_eq!(config.timeout, 30_000);
192        assert!(config.languages.is_empty());
193        assert!(config.language_aliases.is_empty());
194        assert!(config.tools.is_empty());
195    }
196
197    #[test]
198    fn test_deserialize_config() {
199        let toml = r#"
200enabled = true
201normalize-language = "exact"
202on-error = "skip"
203timeout = 60000
204
205[languages.python]
206lint = ["ruff:check"]
207format = ["ruff:format"]
208
209[languages.json]
210format = ["prettier"]
211on-error = "warn"
212
213[language-aliases]
214py = "python"
215bash = "shell"
216
217[tools.custom-tool]
218command = ["my-tool", "--format"]
219stdin = true
220stdout = true
221"#;
222
223        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
224
225        assert!(config.enabled);
226        assert_eq!(config.normalize_language, NormalizeLanguage::Exact);
227        assert_eq!(config.on_error, OnError::Skip);
228        assert_eq!(config.timeout, 60_000);
229
230        let python = config.languages.get("python").expect("Missing python config");
231        assert_eq!(python.lint, vec!["ruff:check"]);
232        assert_eq!(python.format, vec!["ruff:format"]);
233        assert_eq!(python.on_error, None);
234
235        let json = config.languages.get("json").expect("Missing json config");
236        assert!(json.lint.is_empty());
237        assert_eq!(json.format, vec!["prettier"]);
238        assert_eq!(json.on_error, Some(OnError::Warn));
239
240        assert_eq!(config.language_aliases.get("py").map(String::as_str), Some("python"));
241        assert_eq!(config.language_aliases.get("bash").map(String::as_str), Some("shell"));
242
243        let tool = config.tools.get("custom-tool").expect("Missing custom tool");
244        assert_eq!(tool.command, vec!["my-tool", "--format"]);
245        assert!(tool.stdin);
246        assert!(tool.stdout);
247    }
248
249    #[test]
250    fn test_serialize_config() {
251        let mut config = CodeBlockToolsConfig {
252            enabled: true,
253            ..Default::default()
254        };
255        config.languages.insert(
256            "rust".to_string(),
257            LanguageToolConfig {
258                lint: vec![],
259                format: vec!["rustfmt".to_string()],
260                on_error: None,
261            },
262        );
263
264        let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
265        assert!(toml.contains("enabled = true"));
266        assert!(toml.contains("[languages.rust]"));
267        assert!(toml.contains("rustfmt"));
268    }
269
270    #[test]
271    fn test_on_missing_options() {
272        let toml = r#"
273enabled = true
274on-missing-language-definition = "fail"
275on-missing-tool-binary = "fail-fast"
276"#;
277
278        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
279
280        assert_eq!(config.on_missing_language_definition, OnMissing::Fail);
281        assert_eq!(config.on_missing_tool_binary, OnMissing::FailFast);
282    }
283
284    #[test]
285    fn test_on_missing_default_ignore() {
286        let toml = r#"
287enabled = true
288"#;
289
290        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
291
292        // Both should default to Ignore for backward compatibility
293        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
294        assert_eq!(config.on_missing_tool_binary, OnMissing::Ignore);
295    }
296
297    #[test]
298    fn test_on_missing_all_variants() {
299        // Test all variants deserialize correctly
300        for (input, expected) in [
301            ("ignore", OnMissing::Ignore),
302            ("fail", OnMissing::Fail),
303            ("fail-fast", OnMissing::FailFast),
304        ] {
305            let toml = format!(
306                r#"
307enabled = true
308on-missing-language-definition = "{input}"
309"#
310            );
311            let config: CodeBlockToolsConfig = toml::from_str(&toml).expect("Failed to parse TOML");
312            assert_eq!(
313                config.on_missing_language_definition, expected,
314                "Failed for variant: {input}"
315            );
316        }
317    }
318}