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