Skip to main content

txtx_core/validation/
context.rs

1//! Shared validation context
2//!
3//! This module provides a unified context for all validation operations,
4//! reducing parameter passing and making validation state management cleaner.
5//!
6//! # C4 Architecture Annotations
7
8use super::types::{LocatedInputRef, ValidationResult};
9use crate::kit::types::commands::CommandSpecification;
10use crate::manifest::WorkspaceManifest;
11use std::collections::HashMap;
12use std::path::Path;
13
14/// Shared context for validation operations
15///
16/// This struct contains all the data needed by various validators,
17/// reducing the need to pass multiple parameters through the validation pipeline.
18///
19#[derive(Clone)]
20pub struct ValidationContext {
21    /// The content being validated
22    pub content: String,
23
24    /// Path to the file being validated
25    pub file_path: String,
26
27    /// Optional workspace manifest for environment/input validation
28    pub manifest: Option<WorkspaceManifest>,
29
30    /// Current environment name (e.g., "production", "staging")
31    pub environment: Option<String>,
32
33    /// CLI inputs provided by the user (key-value pairs)
34    pub cli_inputs: Vec<(String, String)>,
35
36    /// Addon specifications for validation
37    pub addon_specs: Option<HashMap<String, Vec<(String, CommandSpecification)>>>,
38
39    /// Effective inputs computed from manifest, environment, and CLI
40    effective_inputs: Option<HashMap<String, String>>,
41
42    /// Collected input references during validation
43    pub input_refs: Vec<LocatedInputRef>,
44}
45
46impl ValidationContext {
47    /// Create a new validation context with minimal required information
48    pub fn new(content: impl Into<String>, file_path: impl Into<String>) -> Self {
49        Self {
50            content: content.into(),
51            file_path: file_path.into(),
52            manifest: None,
53            environment: None,
54            cli_inputs: Vec::new(),
55            addon_specs: None,
56            effective_inputs: None,
57            input_refs: Vec::new(),
58        }
59    }
60
61    /// Set the workspace manifest
62    pub fn with_manifest(mut self, manifest: WorkspaceManifest) -> Self {
63        self.manifest = Some(manifest);
64        self.effective_inputs = None; // Reset cache
65        self
66    }
67
68    /// Set the current environment
69    pub fn with_environment(mut self, environment: impl Into<String>) -> Self {
70        self.environment = Some(environment.into());
71        self.effective_inputs = None; // Reset cache
72        self
73    }
74
75    /// Set CLI inputs
76    pub fn with_cli_inputs(mut self, cli_inputs: Vec<(String, String)>) -> Self {
77        self.cli_inputs = cli_inputs;
78        self.effective_inputs = None; // Reset cache
79        self
80    }
81
82    /// Set addon specifications
83    pub fn with_addon_specs(
84        mut self,
85        specs: HashMap<String, Vec<(String, CommandSpecification)>>,
86    ) -> Self {
87        self.addon_specs = Some(specs);
88        self
89    }
90
91    /// Get the file path as a Path
92    pub fn file_path_as_path(&self) -> &Path {
93        Path::new(&self.file_path)
94    }
95
96    /// Get the current environment as a string reference
97    pub fn environment_ref(&self) -> Option<&String> {
98        self.environment.as_ref()
99    }
100
101    /// Get effective inputs (cached computation)
102    pub fn effective_inputs(&mut self) -> &HashMap<String, String> {
103        if self.effective_inputs.is_none() {
104            self.effective_inputs = Some(self.compute_effective_inputs());
105        }
106        self.effective_inputs.as_ref().expect("effective_inputs was just initialized")
107    }
108
109    /// Compute effective inputs from manifest, environment, and CLI
110    fn compute_effective_inputs(&self) -> HashMap<String, String> {
111        let mut inputs = HashMap::new();
112
113        if let Some(manifest) = &self.manifest {
114            // First, add defaults from manifest
115            if let Some(defaults) = manifest.environments.get("defaults") {
116                inputs.extend(defaults.iter().map(|(k, v)| (k.clone(), v.clone())));
117            }
118
119            // Then, overlay the specific environment if provided
120            if let Some(env_name) = &self.environment {
121                if let Some(env_vars) = manifest.environments.get(env_name) {
122                    inputs.extend(env_vars.iter().map(|(k, v)| (k.clone(), v.clone())));
123                }
124            }
125        }
126
127        // Finally, overlay CLI inputs (highest precedence)
128        inputs.extend(self.cli_inputs.iter().cloned());
129
130        inputs
131    }
132
133    /// Add an input reference found during validation
134    pub fn add_input_ref(&mut self, input_ref: LocatedInputRef) {
135        self.input_refs.push(input_ref);
136    }
137
138    /// Load addon specifications from the registry
139    pub fn load_addon_specs(&mut self) -> &HashMap<String, Vec<(String, CommandSpecification)>> {
140        if self.addon_specs.is_none() {
141            // TODO: This is a stopgap solution until we implement a proper compiler pipeline.
142            //
143            // Current limitation: txtx-core cannot directly depend on addon implementations
144            // (evm, svm, etc.) due to:
145            // - Heavy dependencies that would bloat core
146            // - WASM compatibility requirements
147            // - Optional addon features
148            // - Circular dependency concerns
149            //
150            // Current workaround: Two validation paths exist:
151            // 1. Simple validation (here) - returns empty specs, limited validation
152            // 2. Full validation (CLI/LSP) - passes in actual addon specs
153            //
154            // Future solution: A proper compiler pipeline with phases:
155            // Parse → Resolve (load addons) → Type Check → Optimize → Codegen
156            // The resolver phase would load addon specs based on addon declarations
157            // in the runbook, making them available for all subsequent phases.
158            // This would eliminate the architectural split between validation paths.
159            //
160            // For now, return empty map - actual implementation would use addon_registry
161            self.addon_specs = Some(HashMap::new());
162        }
163        self.addon_specs.as_ref().unwrap()
164    }
165}
166
167/// Builder pattern for ValidationContext
168pub struct ValidationContextBuilder {
169    context: ValidationContext,
170}
171
172impl ValidationContextBuilder {
173    /// Create a new builder
174    pub fn new(content: impl Into<String>, file_path: impl Into<String>) -> Self {
175        Self { context: ValidationContext::new(content, file_path) }
176    }
177
178    /// Set the workspace manifest
179    pub fn manifest(mut self, manifest: WorkspaceManifest) -> Self {
180        self.context.manifest = Some(manifest);
181        self
182    }
183
184    /// Set the current environment
185    pub fn environment(mut self, environment: impl Into<String>) -> Self {
186        self.context.environment = Some(environment.into());
187        self
188    }
189
190    /// Set CLI inputs
191    pub fn cli_inputs(mut self, cli_inputs: Vec<(String, String)>) -> Self {
192        self.context.cli_inputs = cli_inputs;
193        self
194    }
195
196    /// Set addon specifications
197    pub fn addon_specs(
198        mut self,
199        specs: HashMap<String, Vec<(String, CommandSpecification)>>,
200    ) -> Self {
201        self.context.addon_specs = Some(specs);
202        self
203    }
204
205    /// Build the ValidationContext
206    pub fn build(self) -> ValidationContext {
207        self.context
208    }
209}
210
211/// Extension trait for ValidationContext to support different validation styles
212pub trait ValidationContextExt {
213    /// Run HCL validation with this context
214    fn validate_hcl(&mut self, result: &mut ValidationResult) -> Result<(), String>;
215
216    /// Run manifest validation with this context
217    fn validate_manifest(
218        &mut self,
219        config: super::ManifestValidationConfig,
220        result: &mut ValidationResult,
221    );
222
223    /// Run full validation pipeline
224    fn validate_full(&mut self, result: &mut ValidationResult) -> Result<(), String>;
225}
226
227impl ValidationContextExt for ValidationContext {
228    fn validate_hcl(&mut self, result: &mut ValidationResult) -> Result<(), String> {
229        // Delegate to HCL validator
230        if let Some(specs) = self.addon_specs.clone() {
231            let input_refs = super::hcl_validator::validate_with_hcl_and_addons(
232                &self.content,
233                result,
234                &self.file_path,
235                specs,
236            )?;
237            self.input_refs = input_refs;
238        } else {
239            let input_refs =
240                super::hcl_validator::validate_with_hcl(&self.content, result, &self.file_path)?;
241            self.input_refs = input_refs;
242        }
243        Ok(())
244    }
245
246    fn validate_manifest(
247        &mut self,
248        config: super::ManifestValidationConfig,
249        result: &mut ValidationResult,
250    ) {
251        if let Some(manifest) = &self.manifest {
252            super::manifest_validator::validate_inputs_against_manifest(
253                &self.input_refs,
254                &self.content,
255                manifest,
256                self.environment.as_ref(),
257                result,
258                &self.file_path,
259                &self.cli_inputs,
260                config,
261            );
262        }
263    }
264
265    fn validate_full(&mut self, result: &mut ValidationResult) -> Result<(), String> {
266        // First run HCL validation
267        self.validate_hcl(result)?;
268
269        // Then run manifest validation if we have a manifest
270        if self.manifest.is_some() {
271            let config = if self.environment.as_deref() == Some("production")
272                || self.environment.as_deref() == Some("prod")
273            {
274                // Use strict validation with linter rules for production
275                let mut cfg = super::ManifestValidationConfig::strict();
276                cfg.custom_rules.extend(super::linter_rules::get_strict_linter_rules());
277                cfg
278            } else {
279                // Use default validation with standard linter rules
280                let mut cfg = super::ManifestValidationConfig::default();
281                cfg.custom_rules.extend(super::linter_rules::get_linter_rules());
282                cfg
283            };
284
285            self.validate_manifest(config, result);
286        }
287
288        Ok(())
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use txtx_addon_kit::indexmap::IndexMap;
296
297    fn create_test_manifest() -> WorkspaceManifest {
298        let mut environments = IndexMap::new();
299
300        let mut defaults = IndexMap::new();
301        defaults.insert("api_url".to_string(), "https://api.example.com".to_string());
302        environments.insert("defaults".to_string(), defaults);
303
304        let mut production = IndexMap::new();
305        production.insert("api_url".to_string(), "https://api.prod.example.com".to_string());
306        production.insert("api_token".to_string(), "prod-token".to_string());
307        environments.insert("production".to_string(), production);
308
309        WorkspaceManifest {
310            name: "test".to_string(),
311            id: "test-id".to_string(),
312            runbooks: Vec::new(),
313            environments,
314            location: None,
315        }
316    }
317
318    #[test]
319    fn test_validation_context_builder() {
320        let manifest = create_test_manifest();
321        let context = ValidationContextBuilder::new("test content", "test.tx")
322            .manifest(manifest)
323            .environment("production")
324            .cli_inputs(vec![("debug".to_string(), "true".to_string())])
325            .build();
326
327        assert_eq!(context.content, "test content");
328        assert_eq!(context.file_path, "test.tx");
329        assert_eq!(context.environment, Some("production".to_string()));
330        assert_eq!(context.cli_inputs.len(), 1);
331    }
332
333    #[test]
334    fn test_effective_inputs() {
335        let manifest = create_test_manifest();
336        let mut context = ValidationContext::new("test", "test.tx")
337            .with_manifest(manifest)
338            .with_environment("production")
339            .with_cli_inputs(vec![("api_url".to_string(), "https://override.com".to_string())]);
340
341        let inputs = context.effective_inputs();
342
343        // CLI should override manifest value
344        assert_eq!(inputs.get("api_url"), Some(&"https://override.com".to_string()));
345        // Production value should be present
346        assert_eq!(inputs.get("api_token"), Some(&"prod-token".to_string()));
347    }
348}