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 recognized language has no tools configured in either mode.
28    #[serde(default)]
29    pub on_missing_language_definition: OnMissing,
30
31    /// Behavior when a configured tool's binary is absent from the allowed lookup locations.
32    /// Defaults to `warn`: the tools rumdl drives are installed separately from
33    /// rumdl, so an absent one is common enough that silence about it is a trap.
34    #[serde(default = "default_on_missing_tool_binary")]
35    pub on_missing_tool_binary: OnMissing,
36
37    /// Select already-installed project/PATH executables per underlying binary.
38    #[serde(default, alias = "binary_preferences")]
39    pub binary_preferences: BTreeMap<String, BinaryPreference>,
40
41    /// Policy for unlabeled fenced code blocks.
42    #[serde(default, alias = "on_missing_language_tag")]
43    pub on_missing_language_tag: OnMissing,
44    /// Policy for unknown tags. When omitted, inherits on-missing-language-definition.
45    #[serde(default, skip_serializing_if = "Option::is_none", alias = "on_unknown_language_tag")]
46    pub on_unknown_language_tag: Option<OnMissing>,
47    /// Policy for a configured language missing tools for the active mode.
48    #[serde(default, alias = "on_missing_mode_definition")]
49    pub on_missing_mode_definition: OnMissing,
50    /// Invocation-level policy when no tool or valid cached tool check was used.
51    #[serde(default, alias = "on_no_tools_run")]
52    pub on_no_tools_run: OnMissing,
53    /// Invocation-owned accounting. Never part of user configuration or schema.
54    #[serde(skip)]
55    #[schemars(skip)]
56    pub run_state: Option<std::sync::Arc<super::run_state::RunState>>,
57
58    /// Timeout per tool execution in milliseconds (default: 30000)
59    #[serde(default = "default_timeout")]
60    #[schemars(schema_with = "schema_timeout")]
61    pub timeout: u64,
62
63    /// Per-language tool configuration
64    #[serde(default)]
65    pub languages: BTreeMap<String, LanguageToolConfig>,
66
67    /// User-defined language aliases (override built-in resolution)
68    /// Example: { "py": "python", "bash": "shell" }
69    #[serde(default)]
70    pub language_aliases: BTreeMap<String, String>,
71
72    /// Custom tool definitions (override built-ins)
73    #[serde(default)]
74    pub tools: BTreeMap<String, ToolDefinition>,
75
76    /// Whether this section came from a config file whose contents may not be quoted
77    /// back (an `extends` target, whose path is arbitrary and whose text the extending
78    /// project need not be able to read). The settings apply as written; only a message
79    /// about one has to leave it out.
80    ///
81    /// Provenance rather than configuration, so it stays out of the serialized form and
82    /// the JSON schema. The whole section is replaced as one value when configs merge,
83    /// so the mark travels with the settings it describes.
84    #[serde(skip)]
85    #[schemars(skip)]
86    pub values_withheld: bool,
87}
88
89fn default_timeout() -> u64 {
90    30_000
91}
92
93fn default_on_missing_tool_binary() -> OnMissing {
94    OnMissing::Warn
95}
96
97/// Generate a JSON Schema for timeout using standard integer type.
98fn schema_timeout(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
99    schemars::json_schema!({
100        "type": "integer",
101        "minimum": 0
102    })
103}
104
105impl CodeBlockToolsConfig {
106    /// An omitted unknown-tag policy preserves the existing missing-definition policy.
107    pub fn unknown_language_policy(&self) -> OnMissing {
108        self.on_unknown_language_tag
109            .unwrap_or(self.on_missing_language_definition)
110    }
111
112    pub fn requires_fail_fast(&self) -> bool {
113        self.enabled
114            && [
115                self.on_missing_language_tag,
116                self.unknown_language_policy(),
117                self.on_missing_mode_definition,
118                self.on_missing_language_definition,
119                self.on_missing_tool_binary,
120                self.on_no_tools_run,
121            ]
122            .contains(&OnMissing::FailFast)
123    }
124}
125
126impl Default for CodeBlockToolsConfig {
127    fn default() -> Self {
128        Self {
129            enabled: false,
130            normalize_language: NormalizeLanguage::default(),
131            on_error: OnError::default(),
132            on_missing_language_definition: OnMissing::default(),
133            on_missing_tool_binary: default_on_missing_tool_binary(),
134            binary_preferences: BTreeMap::new(),
135            on_missing_language_tag: OnMissing::Ignore,
136            on_unknown_language_tag: None,
137            on_missing_mode_definition: OnMissing::Ignore,
138            on_no_tools_run: OnMissing::Ignore,
139            run_state: None,
140            timeout: default_timeout(),
141            languages: BTreeMap::new(),
142            language_aliases: BTreeMap::new(),
143            tools: BTreeMap::new(),
144            values_withheld: false,
145        }
146    }
147}
148
149/// Preference for resolving an external executable. Explicit paths bypass this.
150#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
151#[serde(rename_all = "kebab-case")]
152pub enum BinaryPreference {
153    /// Prefer the project's virtual environment or node_modules, then PATH.
154    Project,
155    /// Prefer PATH, then project-local installations.
156    System,
157    /// Require a project-local installation.
158    OnlyProject,
159    /// Preserve PATH-only lookup.
160    #[default]
161    OnlySystem,
162}
163
164/// How a language's `format` list is applied to a code block.
165#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
166#[serde(rename_all = "kebab-case")]
167pub enum FormatMode {
168    /// Try the formatters in order; the first one that succeeds supplies the
169    /// block, even when it changes nothing
170    #[default]
171    Fallback,
172    /// Run every formatter in order, each on the output of the last one that
173    /// succeeded, and replace the block with the final result
174    Pipeline,
175}
176
177/// Language normalization strategy.
178#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
179#[serde(rename_all = "kebab-case")]
180pub enum NormalizeLanguage {
181    /// Resolve language aliases using GitHub Linguist data (e.g., "py" -> "python")
182    #[default]
183    Linguist,
184    /// Use the language tag exactly as written in the code block
185    Exact,
186}
187
188/// Error handling strategy for tool execution failures.
189#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
190#[serde(rename_all = "kebab-case")]
191pub enum OnError {
192    /// Fail the lint/format operation (propagate error)
193    #[default]
194    Fail,
195    /// Continue with the next tool without a warning
196    Skip,
197    /// Log a warning but continue processing
198    Warn,
199}
200
201/// Behavior when a configured coverage or availability condition is unmet.
202#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
203#[serde(rename_all = "kebab-case")]
204pub enum OnMissing {
205    /// Silently skip and continue processing
206    #[default]
207    Ignore,
208    /// Emit one configuration warning per condition, then continue.
209    /// `--deny-config-warnings` makes the warning fatal.
210    Warn,
211    /// Report an error and continue; invocation conditions use exit code 2.
212    Fail,
213    /// Stop immediately on the first occurrence, exit non-zero
214    FailFast,
215}
216
217impl OnMissing {
218    /// Whether this setting leaves the block alone and reports nothing about it.
219    ///
220    /// `warn` reports, but once for the run rather than against a block, so from
221    /// a block's point of view it behaves exactly as `ignore` does. The two are
222    /// therefore equivalent for deciding whether a document has to be parsed at
223    /// all.
224    pub fn skips_the_block(self) -> bool {
225        matches!(self, OnMissing::Ignore | OnMissing::Warn)
226    }
227}
228
229/// Per-language tool configuration.
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
231#[serde(rename_all = "kebab-case")]
232pub struct LanguageToolConfig {
233    /// Whether code block tools are enabled for this language (default: true).
234    /// Set to false to acknowledge a language without configuring tools.
235    /// This satisfies strict mode (on-missing-language-definition) checks.
236    #[serde(default = "default_true")]
237    pub enabled: bool,
238
239    /// Tools to run in lint mode (rumdl check)
240    #[serde(default)]
241    pub lint: Vec<String>,
242
243    /// Tools to run in format mode (rumdl check --fix / rumdl fmt)
244    #[serde(default)]
245    pub format: Vec<String>,
246
247    /// How the `format` list is applied: as fallbacks (default) or as a pipeline
248    #[serde(default)]
249    pub format_mode: FormatMode,
250
251    /// Override global on-error setting for this language
252    #[serde(default)]
253    pub on_error: Option<OnError>,
254}
255
256impl Default for LanguageToolConfig {
257    fn default() -> Self {
258        Self {
259            enabled: true,
260            lint: Vec::new(),
261            format: Vec::new(),
262            format_mode: FormatMode::default(),
263            on_error: None,
264        }
265    }
266}
267
268/// Definition of an external tool.
269///
270/// This describes how to invoke a tool and how it communicates.
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema)]
272#[serde(rename_all = "kebab-case")]
273pub struct ToolDefinition {
274    /// Command to run (first element is the binary, rest are arguments)
275    pub command: Vec<String>,
276
277    /// Whether the tool reads from stdin (default: true)
278    #[serde(default = "default_true")]
279    pub stdin: bool,
280
281    /// Whether the tool writes to stdout (default: true)
282    #[serde(default = "default_true")]
283    pub stdout: bool,
284
285    /// Additional arguments for lint mode (appended to command)
286    #[serde(default)]
287    pub lint_args: Vec<String>,
288
289    /// Additional arguments for format mode (appended to command)
290    #[serde(default)]
291    pub format_args: Vec<String>,
292}
293
294fn default_true() -> bool {
295    true
296}
297
298impl Default for ToolDefinition {
299    fn default() -> Self {
300        Self {
301            command: Vec::new(),
302            stdin: true,
303            stdout: true,
304            lint_args: Vec::new(),
305            format_args: Vec::new(),
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_default_config() {
316        let config = CodeBlockToolsConfig::default();
317        assert!(!config.enabled);
318        assert_eq!(config.normalize_language, NormalizeLanguage::Linguist);
319        assert_eq!(config.on_error, OnError::Fail);
320        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
321        assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
322        assert_eq!(config.timeout, 30_000);
323        assert!(config.languages.is_empty());
324        assert!(config.language_aliases.is_empty());
325        assert!(config.tools.is_empty());
326    }
327
328    #[test]
329    fn test_deserialize_config() {
330        let toml = r#"
331enabled = true
332normalize-language = "exact"
333on-error = "skip"
334timeout = 60000
335
336[languages.python]
337lint = ["ruff:check"]
338format = ["ruff:format"]
339
340[languages.json]
341format = ["prettier"]
342on-error = "warn"
343
344[language-aliases]
345py = "python"
346bash = "shell"
347
348[tools.custom-tool]
349command = ["my-tool", "--format"]
350stdin = true
351stdout = true
352"#;
353
354        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
355
356        assert!(config.enabled);
357        assert_eq!(config.normalize_language, NormalizeLanguage::Exact);
358        assert_eq!(config.on_error, OnError::Skip);
359        assert_eq!(config.timeout, 60_000);
360
361        let python = config.languages.get("python").expect("Missing python config");
362        assert_eq!(python.lint, vec!["ruff:check"]);
363        assert_eq!(python.format, vec!["ruff:format"]);
364        assert_eq!(python.on_error, None);
365
366        let json = config.languages.get("json").expect("Missing json config");
367        assert!(json.lint.is_empty());
368        assert_eq!(json.format, vec!["prettier"]);
369        assert_eq!(json.on_error, Some(OnError::Warn));
370
371        assert_eq!(config.language_aliases.get("py").map(String::as_str), Some("python"));
372        assert_eq!(config.language_aliases.get("bash").map(String::as_str), Some("shell"));
373
374        let tool = config.tools.get("custom-tool").expect("Missing custom tool");
375        assert_eq!(tool.command, vec!["my-tool", "--format"]);
376        assert!(tool.stdin);
377        assert!(tool.stdout);
378    }
379
380    #[test]
381    fn test_serialize_config() {
382        let mut config = CodeBlockToolsConfig {
383            enabled: true,
384            ..Default::default()
385        };
386        config.languages.insert(
387            "rust".to_string(),
388            LanguageToolConfig {
389                format: vec!["rustfmt".to_string()],
390                ..Default::default()
391            },
392        );
393
394        let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
395        assert!(toml.contains("enabled = true"));
396        assert!(toml.contains("[languages.rust]"));
397        assert!(toml.contains("rustfmt"));
398    }
399
400    #[test]
401    fn test_on_missing_options() {
402        let toml = r#"
403enabled = true
404on-missing-language-definition = "fail"
405on-missing-tool-binary = "fail-fast"
406"#;
407
408        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
409
410        assert_eq!(config.on_missing_language_definition, OnMissing::Fail);
411        assert_eq!(config.on_missing_tool_binary, OnMissing::FailFast);
412    }
413
414    #[test]
415    fn test_on_missing_defaults() {
416        let toml = r#"
417enabled = true
418"#;
419
420        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
421
422        // A language a config never mentioned is not something rumdl has an
423        // opinion about, so it stays silent.
424        assert_eq!(config.on_missing_language_definition, OnMissing::Ignore);
425        // A tool the config did name, and the machine does not have, is a gap
426        // between the two that the run has to mention.
427        assert_eq!(config.on_missing_tool_binary, OnMissing::Warn);
428    }
429
430    #[test]
431    fn test_on_missing_all_variants() {
432        // Test all variants deserialize correctly
433        for (input, expected) in [
434            ("ignore", OnMissing::Ignore),
435            ("warn", OnMissing::Warn),
436            ("fail", OnMissing::Fail),
437            ("fail-fast", OnMissing::FailFast),
438        ] {
439            let toml = format!(
440                r#"
441enabled = true
442on-missing-language-definition = "{input}"
443"#
444            );
445            let config: CodeBlockToolsConfig = toml::from_str(&toml).expect("Failed to parse TOML");
446            assert_eq!(
447                config.on_missing_language_definition, expected,
448                "Failed for variant: {input}"
449            );
450        }
451    }
452
453    #[test]
454    fn test_language_config_enabled_defaults_to_true() {
455        // Deserializing without `enabled` should default to true
456        let toml = r#"
457lint = ["ruff:check"]
458"#;
459        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
460        assert!(config.enabled);
461        assert_eq!(config.lint, vec!["ruff:check"]);
462        assert!(config.format.is_empty());
463    }
464
465    #[test]
466    fn test_language_config_enabled_false() {
467        // Explicitly set enabled = false
468        let toml = r#"
469enabled = false
470"#;
471        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
472        assert!(!config.enabled);
473        assert!(config.lint.is_empty());
474        assert!(config.format.is_empty());
475    }
476
477    #[test]
478    fn test_language_config_enabled_false_with_tools() {
479        // enabled=false should be respected even when tools are configured
480        let toml = r#"
481enabled = false
482lint = ["ruff:check"]
483format = ["ruff:format"]
484"#;
485        let config: LanguageToolConfig = toml::from_str(toml).expect("Failed to parse TOML");
486        assert!(!config.enabled);
487        assert_eq!(config.lint, vec!["ruff:check"]);
488        assert_eq!(config.format, vec!["ruff:format"]);
489    }
490
491    #[test]
492    fn test_language_config_enabled_in_full_config() {
493        // Test enabled field within a full CodeBlockToolsConfig
494        let toml = r#"
495enabled = true
496on-missing-language-definition = "fail"
497
498[languages.python]
499lint = ["ruff:check"]
500
501[languages.plaintext]
502enabled = false
503"#;
504        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
505
506        let python = config.languages.get("python").expect("Missing python config");
507        assert!(python.enabled);
508        assert_eq!(python.lint, vec!["ruff:check"]);
509
510        let plaintext = config.languages.get("plaintext").expect("Missing plaintext config");
511        assert!(!plaintext.enabled);
512        assert!(plaintext.lint.is_empty());
513    }
514
515    #[test]
516    fn test_language_config_default_trait() {
517        let config = LanguageToolConfig::default();
518        assert!(config.enabled);
519        assert!(config.lint.is_empty());
520        assert!(config.format.is_empty());
521        assert!(config.on_error.is_none());
522    }
523
524    #[test]
525    fn test_format_mode_parses_and_defaults_to_fallback() {
526        let toml = r#"
527[languages]
528json = { format = ["jq"] }
529shell = { format = ["shuck:lint-fix", "shuck:format"], format-mode = "pipeline" }
530python = { format = ["ruff:format", "black"], format-mode = "fallback" }
531"#;
532        let config: CodeBlockToolsConfig = toml::from_str(toml).expect("Failed to parse TOML");
533        assert_eq!(config.languages["json"].format_mode, FormatMode::Fallback);
534        assert_eq!(config.languages["shell"].format_mode, FormatMode::Pipeline);
535        assert_eq!(config.languages["python"].format_mode, FormatMode::Fallback);
536
537        let invalid = "[languages]\njson = { format = [\"jq\"], format-mode = \"chain\" }\n";
538        let error = toml::from_str::<CodeBlockToolsConfig>(invalid).unwrap_err().to_string();
539        assert!(
540            error.contains("format-mode") || (error.contains("fallback") && error.contains("pipeline")),
541            "{error}"
542        );
543    }
544
545    #[test]
546    fn test_format_mode_round_trips() {
547        let config = LanguageToolConfig {
548            format: vec!["jq".to_string()],
549            format_mode: FormatMode::Pipeline,
550            ..Default::default()
551        };
552        let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
553        assert!(toml.contains("format-mode = \"pipeline\""), "{toml}");
554        assert_eq!(toml::from_str::<LanguageToolConfig>(&toml).unwrap(), config);
555    }
556
557    #[test]
558    fn test_language_config_serialize_enabled_false() {
559        let config = LanguageToolConfig {
560            enabled: false,
561            ..Default::default()
562        };
563        let toml = toml::to_string_pretty(&config).expect("Failed to serialize");
564        assert!(toml.contains("enabled = false"));
565    }
566}