Skip to main content

txtx_core/validation/
manifest_validator.rs

1//! Manifest validation functionality
2//!
3//! This module provides validation of runbook inputs against workspace manifests,
4//! checking that environment variables and inputs are properly defined.
5
6use super::rule_id::{AddonScope, RuleIdentifier};
7use super::types::{
8    LocatedInputRef, ValidationResult, ValidationSuggestion,
9};
10use txtx_addon_kit::types::diagnostics::Diagnostic;
11use crate::manifest::WorkspaceManifest;
12use std::collections::{HashMap, HashSet};
13
14/// Configuration for manifest validation
15pub struct ManifestValidationConfig {
16    /// Whether to use strict validation (e.g., for production environments)
17    pub strict_mode: bool,
18    /// Additional validation rules to apply
19    pub custom_rules: Vec<Box<dyn ManifestValidationRule>>,
20}
21
22impl Default for ManifestValidationConfig {
23    fn default() -> Self {
24        Self { strict_mode: false, custom_rules: Vec::new() }
25    }
26}
27
28impl ManifestValidationConfig {
29    /// Create a strict validation configuration
30    pub fn strict() -> Self {
31        Self { strict_mode: true, custom_rules: Vec::new() }
32    }
33}
34
35/// Trait for custom manifest validation rules
36pub trait ManifestValidationRule: Send + Sync {
37    /// Unique identifier for the rule
38    fn id(&self) -> RuleIdentifier;
39
40    /// Description of what the rule checks
41    fn description(&self) -> &'static str;
42
43    /// Which addons this rule applies to
44    fn addon_scope(&self) -> AddonScope {
45        AddonScope::Global // Default to global scope
46    }
47
48    /// Check if the rule applies to this input
49    fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome;
50}
51
52/// Context provided to validation rules
53pub struct ManifestValidationContext<'a> {
54    pub input_name: &'a str,
55    pub full_name: &'a str,
56    pub manifest: &'a WorkspaceManifest,
57    pub environment: Option<&'a str>,
58    pub effective_inputs: &'a HashMap<String, String>,
59    pub cli_inputs: &'a [(String, String)],
60    pub content: &'a str,
61    pub file_path: &'a str,
62    pub active_addons: HashSet<String>, // Which addons are used in the runbook
63}
64
65/// Outcome of a validation rule check
66pub enum ValidationOutcome {
67    /// Rule passed
68    Pass,
69    /// Rule failed with error
70    Error {
71        message: String,
72        context: Option<String>,
73        suggestion: Option<String>,
74        documentation_link: Option<String>,
75    },
76    /// Rule generated a warning
77    Warning { message: String, suggestion: Option<String> },
78}
79
80/// Validate input references against a manifest
81pub fn validate_inputs_against_manifest(
82    input_refs: &[LocatedInputRef],
83    content: &str,
84    manifest: &WorkspaceManifest,
85    environment: Option<&String>,
86    result: &mut ValidationResult,
87    file_path: &str,
88    cli_inputs: &[(String, String)],
89    config: ManifestValidationConfig,
90) {
91    // Build effective inputs from environment hierarchy
92    let effective_inputs = build_effective_inputs(manifest, environment, cli_inputs);
93
94    // Add CLI precedence message if applicable
95    if !cli_inputs.is_empty() {
96        result.suggestions.push(ValidationSuggestion {
97            message: format!(
98                "{} CLI inputs provided. CLI inputs take precedence over environment values.",
99                cli_inputs.len()
100            ),
101            example: None,
102        });
103    }
104
105    // Get validation rules based on configuration
106    let rules = if config.strict_mode { get_strict_rules() } else { get_default_rules() };
107
108    // Add any custom rules
109    let mut all_rules = rules;
110    all_rules.extend(config.custom_rules);
111
112    // Process each input reference through all rules
113    for input_ref in input_refs {
114        let input_name = strip_input_prefix(&input_ref.name);
115
116        // Create validation context
117        let context = ManifestValidationContext {
118            input_name,
119            full_name: &input_ref.name,
120            manifest,
121            environment: environment.as_ref().map(|s| s.as_str()),
122            effective_inputs: &effective_inputs,
123            cli_inputs,
124            content,
125            file_path,
126            active_addons: HashSet::new(), // TODO: Populate with actual addons from runbook
127        };
128
129        // Run each rule and process outcomes
130        for rule in &all_rules {
131            match rule.check(&context) {
132                ValidationOutcome::Pass => continue,
133
134                ValidationOutcome::Error {
135                    message,
136                    context: ctx,
137                    suggestion,
138                    documentation_link,
139                } => {
140                    let mut error = Diagnostic::error(message)
141                        .with_code(rule.id())
142                        .with_file(file_path)
143                        .with_line(input_ref.line)
144                        .with_column(input_ref.column);
145
146                    if let Some(ctx) = ctx {
147                        error = error.with_context(ctx);
148                    }
149
150                    if let Some(doc) = documentation_link {
151                        error = error.with_documentation(doc);
152                    }
153
154                    result.errors.push(error);
155
156                    if let Some(suggestion) = suggestion {
157                        result
158                            .suggestions
159                            .push(ValidationSuggestion { message: suggestion, example: None });
160                    }
161                }
162
163                ValidationOutcome::Warning { message, suggestion } => {
164                    let mut warning = Diagnostic::warning(message)
165                        .with_code(rule.id())
166                        .with_file(file_path)
167                        .with_line(input_ref.line)
168                        .with_column(input_ref.column);
169
170                    if let Some(sug) = suggestion {
171                        warning = warning.with_suggestion(sug);
172                    }
173
174                    result.warnings.push(warning);
175                }
176            }
177        }
178    }
179}
180
181/// Build effective inputs by merging manifest environments with CLI inputs
182fn build_effective_inputs(
183    manifest: &WorkspaceManifest,
184    environment: Option<&String>,
185    cli_inputs: &[(String, String)],
186) -> HashMap<String, String> {
187    let mut inputs = HashMap::new();
188
189    // First, add global environment (txtx's default environment)
190    if let Some(global) = manifest.environments.get("global") {
191        inputs.extend(global.iter().map(|(k, v)| (k.clone(), v.clone())));
192    }
193
194    // Then, overlay the specific environment if provided
195    if let Some(env_name) = environment {
196        if let Some(env_vars) = manifest.environments.get(env_name) {
197            inputs.extend(env_vars.iter().map(|(k, v)| (k.clone(), v.clone())));
198        }
199    }
200
201    // Finally, overlay CLI inputs (highest precedence)
202    for (key, value) in cli_inputs {
203        inputs.insert(key.clone(), value.clone());
204    }
205
206    inputs
207}
208
209/// Strip common input prefixes
210fn strip_input_prefix(name: &str) -> &str {
211    name.strip_prefix("input.")
212        .or_else(|| name.strip_prefix("var."))
213        .unwrap_or(name)
214}
215
216/// Get default validation rules
217fn get_default_rules() -> Vec<Box<dyn ManifestValidationRule>> {
218    vec![Box::new(UndefinedInputRule), Box::new(DeprecatedInputRule)]
219}
220
221/// Get strict validation rules (for production environments)
222fn get_strict_rules() -> Vec<Box<dyn ManifestValidationRule>> {
223    vec![Box::new(UndefinedInputRule), Box::new(DeprecatedInputRule), Box::new(RequiredInputRule)]
224}
225
226// Built-in validation rules
227
228use super::rule_id::CoreRuleId;
229
230/// Rule: Check for undefined inputs
231struct UndefinedInputRule;
232
233impl ManifestValidationRule for UndefinedInputRule {
234    fn id(&self) -> RuleIdentifier {
235        RuleIdentifier::Core(CoreRuleId::UndefinedInput)
236    }
237
238    fn description(&self) -> &'static str {
239        "Checks if input references exist in the manifest or CLI inputs"
240    }
241
242    fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome {
243        // Check if the input exists in effective inputs
244        if !context.effective_inputs.contains_key(context.input_name) {
245            // Check if it's provided via CLI
246            let cli_provided = context.cli_inputs.iter().any(|(k, _)| k == context.input_name);
247
248            if !cli_provided {
249                return ValidationOutcome::Error {
250                    message: format!("Undefined input '{}'", context.full_name),
251                    context: Some(format!(
252                        "Input '{}' is not defined in the {} environment or provided via CLI",
253                        context.input_name,
254                        context.environment.unwrap_or("default")
255                    )),
256                    suggestion: Some(format!(
257                        "Define '{}' in your manifest or provide it via CLI: --input {}=value",
258                        context.input_name, context.input_name
259                    )),
260                    documentation_link: Some(
261                        "https://docs.txtx.rs/manifests/environments".to_string(),
262                    ),
263                };
264            }
265        }
266
267        ValidationOutcome::Pass
268    }
269}
270
271/// Rule: Check for deprecated inputs
272struct DeprecatedInputRule;
273
274impl ManifestValidationRule for DeprecatedInputRule {
275    fn id(&self) -> RuleIdentifier {
276        RuleIdentifier::Core(CoreRuleId::DeprecatedInput)
277    }
278
279    fn description(&self) -> &'static str {
280        "Warns about deprecated input names"
281    }
282
283    fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome {
284        // List of deprecated inputs and their replacements
285        let deprecated_inputs =
286            [("api_key", "api_token"), ("endpoint_url", "api_url"), ("rpc_endpoint", "rpc_url")];
287
288        for (deprecated, replacement) in deprecated_inputs {
289            if context.input_name == deprecated {
290                return ValidationOutcome::Warning {
291                    message: format!("Input '{}' is deprecated", context.full_name),
292                    suggestion: Some(format!("Use '{}' instead", replacement)),
293                };
294            }
295        }
296
297        ValidationOutcome::Pass
298    }
299}
300
301/// Rule: Check for required inputs (strict mode only)
302struct RequiredInputRule;
303
304impl ManifestValidationRule for RequiredInputRule {
305    fn id(&self) -> RuleIdentifier {
306        RuleIdentifier::Core(CoreRuleId::RequiredInput)
307    }
308
309    fn description(&self) -> &'static str {
310        "Ensures required inputs are provided in production environments"
311    }
312
313    fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome {
314        // In strict mode, certain inputs are required
315        let required_for_production = ["api_url", "api_token", "chain_id"];
316
317        // Only check if we're in production environment
318        if context.environment == Some("production") || context.environment == Some("prod") {
319            for required in required_for_production {
320                // Check if this is a reference to a required input
321                if context.input_name.contains(required)
322                    && !context.effective_inputs.contains_key(required)
323                {
324                    return ValidationOutcome::Warning {
325                        message: format!(
326                            "Required input '{}' not found for production environment",
327                            required
328                        ),
329                        suggestion: Some(format!(
330                            "Ensure '{}' is defined in your production environment",
331                            required
332                        )),
333                    };
334                }
335            }
336        }
337
338        ValidationOutcome::Pass
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use txtx_addon_kit::indexmap::IndexMap;
346
347    fn create_test_manifest() -> WorkspaceManifest {
348        let mut environments = IndexMap::new();
349
350        let mut defaults = IndexMap::new();
351        defaults.insert("api_url".to_string(), "https://api.example.com".to_string());
352        environments.insert("defaults".to_string(), defaults);
353
354        let mut production = IndexMap::new();
355        production.insert("api_url".to_string(), "https://api.prod.example.com".to_string());
356        production.insert("api_token".to_string(), "prod-token".to_string());
357        production.insert("chain_id".to_string(), "1".to_string());
358        environments.insert("production".to_string(), production);
359
360        WorkspaceManifest {
361            name: "test".to_string(),
362            id: "test-id".to_string(),
363            runbooks: Vec::new(),
364            environments,
365            location: None,
366        }
367    }
368
369    #[test]
370    fn test_undefined_input_detection() {
371        let manifest = create_test_manifest();
372        let mut result = ValidationResult::new();
373
374        let input_refs =
375            vec![LocatedInputRef { name: "env.undefined_var".to_string(), line: 10, column: 5 }];
376
377        validate_inputs_against_manifest(
378            &input_refs,
379            "test content",
380            &manifest,
381            Some(&"production".to_string()),
382            &mut result,
383            "test.tx",
384            &[],
385            ManifestValidationConfig::default(),
386        );
387
388        assert_eq!(result.errors.len(), 1);
389        assert!(result.errors[0].message.contains("Undefined input"));
390    }
391
392    #[test]
393    fn test_cli_input_precedence() {
394        let manifest = create_test_manifest();
395        let mut result = ValidationResult::new();
396
397        let input_refs =
398            vec![LocatedInputRef { name: "input.cli_provided".to_string(), line: 10, column: 5 }];
399
400        let cli_inputs = vec![("cli_provided".to_string(), "cli-value".to_string())];
401
402        validate_inputs_against_manifest(
403            &input_refs,
404            "test content",
405            &manifest,
406            Some(&"production".to_string()),
407            &mut result,
408            "test.tx",
409            &cli_inputs,
410            ManifestValidationConfig::default(),
411        );
412
413        // Should not error because CLI input is provided
414        assert_eq!(result.errors.len(), 0);
415
416        // Should have suggestion about CLI precedence
417        assert_eq!(result.suggestions.len(), 1);
418        assert!(result.suggestions[0].message.contains("CLI inputs provided"));
419    }
420
421    #[test]
422    fn test_strict_mode_validation() {
423        let manifest = create_test_manifest();
424        let mut result = ValidationResult::new();
425
426        // Reference exists but let's test strict mode warnings
427        let input_refs =
428            vec![LocatedInputRef { name: "input.api_url".to_string(), line: 10, column: 5 }];
429
430        validate_inputs_against_manifest(
431            &input_refs,
432            "test content",
433            &manifest,
434            Some(&"production".to_string()),
435            &mut result,
436            "test.tx",
437            &[],
438            ManifestValidationConfig::strict(),
439        );
440
441        // In strict mode, we should get no errors for valid inputs
442        assert_eq!(result.errors.len(), 0);
443    }
444}