1use 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
14pub struct ManifestValidationConfig {
16 pub strict_mode: bool,
18 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 pub fn strict() -> Self {
31 Self { strict_mode: true, custom_rules: Vec::new() }
32 }
33}
34
35pub trait ManifestValidationRule: Send + Sync {
37 fn id(&self) -> RuleIdentifier;
39
40 fn description(&self) -> &'static str;
42
43 fn addon_scope(&self) -> AddonScope {
45 AddonScope::Global }
47
48 fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome;
50}
51
52pub 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>, }
64
65pub enum ValidationOutcome {
67 Pass,
69 Error {
71 message: String,
72 context: Option<String>,
73 suggestion: Option<String>,
74 documentation_link: Option<String>,
75 },
76 Warning { message: String, suggestion: Option<String> },
78}
79
80pub 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 let effective_inputs = build_effective_inputs(manifest, environment, cli_inputs);
93
94 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 let rules = if config.strict_mode { get_strict_rules() } else { get_default_rules() };
107
108 let mut all_rules = rules;
110 all_rules.extend(config.custom_rules);
111
112 for input_ref in input_refs {
114 let input_name = strip_input_prefix(&input_ref.name);
115
116 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(), };
128
129 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
181fn 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 if let Some(global) = manifest.environments.get("global") {
191 inputs.extend(global.iter().map(|(k, v)| (k.clone(), v.clone())));
192 }
193
194 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 for (key, value) in cli_inputs {
203 inputs.insert(key.clone(), value.clone());
204 }
205
206 inputs
207}
208
209fn strip_input_prefix(name: &str) -> &str {
211 name.strip_prefix("input.")
212 .or_else(|| name.strip_prefix("var."))
213 .unwrap_or(name)
214}
215
216fn get_default_rules() -> Vec<Box<dyn ManifestValidationRule>> {
218 vec![Box::new(UndefinedInputRule), Box::new(DeprecatedInputRule)]
219}
220
221fn get_strict_rules() -> Vec<Box<dyn ManifestValidationRule>> {
223 vec![Box::new(UndefinedInputRule), Box::new(DeprecatedInputRule), Box::new(RequiredInputRule)]
224}
225
226use super::rule_id::CoreRuleId;
229
230struct 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 if !context.effective_inputs.contains_key(context.input_name) {
245 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
271struct 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 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
301struct 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 let required_for_production = ["api_url", "api_token", "chain_id"];
316
317 if context.environment == Some("production") || context.environment == Some("prod") {
319 for required in required_for_production {
320 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 assert_eq!(result.errors.len(), 0);
415
416 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 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 assert_eq!(result.errors.len(), 0);
443 }
444}