txtx-core 0.4.15

Primitives for parsing, analyzing and executing Txtx runbooks
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
//! Manifest validation functionality
//!
//! This module provides validation of runbook inputs against workspace manifests,
//! checking that environment variables and inputs are properly defined.

use super::rule_id::{AddonScope, RuleIdentifier};
use super::types::{
    LocatedInputRef, ValidationResult, ValidationSuggestion,
};
use txtx_addon_kit::types::diagnostics::Diagnostic;
use crate::manifest::WorkspaceManifest;
use std::collections::{HashMap, HashSet};

/// Configuration for manifest validation
pub struct ManifestValidationConfig {
    /// Whether to use strict validation (e.g., for production environments)
    pub strict_mode: bool,
    /// Additional validation rules to apply
    pub custom_rules: Vec<Box<dyn ManifestValidationRule>>,
}

impl Default for ManifestValidationConfig {
    fn default() -> Self {
        Self { strict_mode: false, custom_rules: Vec::new() }
    }
}

impl ManifestValidationConfig {
    /// Create a strict validation configuration
    pub fn strict() -> Self {
        Self { strict_mode: true, custom_rules: Vec::new() }
    }
}

/// Trait for custom manifest validation rules
pub trait ManifestValidationRule: Send + Sync {
    /// Unique identifier for the rule
    fn id(&self) -> RuleIdentifier;

    /// Description of what the rule checks
    fn description(&self) -> &'static str;

    /// Which addons this rule applies to
    fn addon_scope(&self) -> AddonScope {
        AddonScope::Global // Default to global scope
    }

    /// Check if the rule applies to this input
    fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome;
}

/// Context provided to validation rules
pub struct ManifestValidationContext<'a> {
    pub input_name: &'a str,
    pub full_name: &'a str,
    pub manifest: &'a WorkspaceManifest,
    pub environment: Option<&'a str>,
    pub effective_inputs: &'a HashMap<String, String>,
    pub cli_inputs: &'a [(String, String)],
    pub content: &'a str,
    pub file_path: &'a str,
    pub active_addons: HashSet<String>, // Which addons are used in the runbook
}

/// Outcome of a validation rule check
pub enum ValidationOutcome {
    /// Rule passed
    Pass,
    /// Rule failed with error
    Error {
        message: String,
        context: Option<String>,
        suggestion: Option<String>,
        documentation_link: Option<String>,
    },
    /// Rule generated a warning
    Warning { message: String, suggestion: Option<String> },
}

/// Validate input references against a manifest
pub fn validate_inputs_against_manifest(
    input_refs: &[LocatedInputRef],
    content: &str,
    manifest: &WorkspaceManifest,
    environment: Option<&String>,
    result: &mut ValidationResult,
    file_path: &str,
    cli_inputs: &[(String, String)],
    config: ManifestValidationConfig,
) {
    // Build effective inputs from environment hierarchy
    let effective_inputs = build_effective_inputs(manifest, environment, cli_inputs);

    // Add CLI precedence message if applicable
    if !cli_inputs.is_empty() {
        result.suggestions.push(ValidationSuggestion {
            message: format!(
                "{} CLI inputs provided. CLI inputs take precedence over environment values.",
                cli_inputs.len()
            ),
            example: None,
        });
    }

    // Get validation rules based on configuration
    let rules = if config.strict_mode { get_strict_rules() } else { get_default_rules() };

    // Add any custom rules
    let mut all_rules = rules;
    all_rules.extend(config.custom_rules);

    // Process each input reference through all rules
    for input_ref in input_refs {
        let input_name = strip_input_prefix(&input_ref.name);

        // Create validation context
        let context = ManifestValidationContext {
            input_name,
            full_name: &input_ref.name,
            manifest,
            environment: environment.as_ref().map(|s| s.as_str()),
            effective_inputs: &effective_inputs,
            cli_inputs,
            content,
            file_path,
            active_addons: HashSet::new(), // TODO: Populate with actual addons from runbook
        };

        // Run each rule and process outcomes
        for rule in &all_rules {
            match rule.check(&context) {
                ValidationOutcome::Pass => continue,

                ValidationOutcome::Error {
                    message,
                    context: ctx,
                    suggestion,
                    documentation_link,
                } => {
                    let mut error = Diagnostic::error(message)
                        .with_code(rule.id())
                        .with_file(file_path)
                        .with_line(input_ref.line)
                        .with_column(input_ref.column);

                    if let Some(ctx) = ctx {
                        error = error.with_context(ctx);
                    }

                    if let Some(doc) = documentation_link {
                        error = error.with_documentation(doc);
                    }

                    result.errors.push(error);

                    if let Some(suggestion) = suggestion {
                        result
                            .suggestions
                            .push(ValidationSuggestion { message: suggestion, example: None });
                    }
                }

                ValidationOutcome::Warning { message, suggestion } => {
                    let mut warning = Diagnostic::warning(message)
                        .with_code(rule.id())
                        .with_file(file_path)
                        .with_line(input_ref.line)
                        .with_column(input_ref.column);

                    if let Some(sug) = suggestion {
                        warning = warning.with_suggestion(sug);
                    }

                    result.warnings.push(warning);
                }
            }
        }
    }
}

/// Build effective inputs by merging manifest environments with CLI inputs
fn build_effective_inputs(
    manifest: &WorkspaceManifest,
    environment: Option<&String>,
    cli_inputs: &[(String, String)],
) -> HashMap<String, String> {
    let mut inputs = HashMap::new();

    // First, add global environment (txtx's default environment)
    if let Some(global) = manifest.environments.get("global") {
        inputs.extend(global.iter().map(|(k, v)| (k.clone(), v.clone())));
    }

    // Then, overlay the specific environment if provided
    if let Some(env_name) = environment {
        if let Some(env_vars) = manifest.environments.get(env_name) {
            inputs.extend(env_vars.iter().map(|(k, v)| (k.clone(), v.clone())));
        }
    }

    // Finally, overlay CLI inputs (highest precedence)
    for (key, value) in cli_inputs {
        inputs.insert(key.clone(), value.clone());
    }

    inputs
}

/// Strip common input prefixes
fn strip_input_prefix(name: &str) -> &str {
    name.strip_prefix("input.")
        .or_else(|| name.strip_prefix("var."))
        .unwrap_or(name)
}

/// Get default validation rules
fn get_default_rules() -> Vec<Box<dyn ManifestValidationRule>> {
    vec![Box::new(UndefinedInputRule), Box::new(DeprecatedInputRule)]
}

/// Get strict validation rules (for production environments)
fn get_strict_rules() -> Vec<Box<dyn ManifestValidationRule>> {
    vec![Box::new(UndefinedInputRule), Box::new(DeprecatedInputRule), Box::new(RequiredInputRule)]
}

// Built-in validation rules

use super::rule_id::CoreRuleId;

/// Rule: Check for undefined inputs
struct UndefinedInputRule;

impl ManifestValidationRule for UndefinedInputRule {
    fn id(&self) -> RuleIdentifier {
        RuleIdentifier::Core(CoreRuleId::UndefinedInput)
    }

    fn description(&self) -> &'static str {
        "Checks if input references exist in the manifest or CLI inputs"
    }

    fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome {
        // Check if the input exists in effective inputs
        if !context.effective_inputs.contains_key(context.input_name) {
            // Check if it's provided via CLI
            let cli_provided = context.cli_inputs.iter().any(|(k, _)| k == context.input_name);

            if !cli_provided {
                return ValidationOutcome::Error {
                    message: format!("Undefined input '{}'", context.full_name),
                    context: Some(format!(
                        "Input '{}' is not defined in the {} environment or provided via CLI",
                        context.input_name,
                        context.environment.unwrap_or("default")
                    )),
                    suggestion: Some(format!(
                        "Define '{}' in your manifest or provide it via CLI: --input {}=value",
                        context.input_name, context.input_name
                    )),
                    documentation_link: Some(
                        "https://docs.txtx.rs/manifests/environments".to_string(),
                    ),
                };
            }
        }

        ValidationOutcome::Pass
    }
}

/// Rule: Check for deprecated inputs
struct DeprecatedInputRule;

impl ManifestValidationRule for DeprecatedInputRule {
    fn id(&self) -> RuleIdentifier {
        RuleIdentifier::Core(CoreRuleId::DeprecatedInput)
    }

    fn description(&self) -> &'static str {
        "Warns about deprecated input names"
    }

    fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome {
        // List of deprecated inputs and their replacements
        let deprecated_inputs =
            [("api_key", "api_token"), ("endpoint_url", "api_url"), ("rpc_endpoint", "rpc_url")];

        for (deprecated, replacement) in deprecated_inputs {
            if context.input_name == deprecated {
                return ValidationOutcome::Warning {
                    message: format!("Input '{}' is deprecated", context.full_name),
                    suggestion: Some(format!("Use '{}' instead", replacement)),
                };
            }
        }

        ValidationOutcome::Pass
    }
}

/// Rule: Check for required inputs (strict mode only)
struct RequiredInputRule;

impl ManifestValidationRule for RequiredInputRule {
    fn id(&self) -> RuleIdentifier {
        RuleIdentifier::Core(CoreRuleId::RequiredInput)
    }

    fn description(&self) -> &'static str {
        "Ensures required inputs are provided in production environments"
    }

    fn check(&self, context: &ManifestValidationContext) -> ValidationOutcome {
        // In strict mode, certain inputs are required
        let required_for_production = ["api_url", "api_token", "chain_id"];

        // Only check if we're in production environment
        if context.environment == Some("production") || context.environment == Some("prod") {
            for required in required_for_production {
                // Check if this is a reference to a required input
                if context.input_name.contains(required)
                    && !context.effective_inputs.contains_key(required)
                {
                    return ValidationOutcome::Warning {
                        message: format!(
                            "Required input '{}' not found for production environment",
                            required
                        ),
                        suggestion: Some(format!(
                            "Ensure '{}' is defined in your production environment",
                            required
                        )),
                    };
                }
            }
        }

        ValidationOutcome::Pass
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use txtx_addon_kit::indexmap::IndexMap;

    fn create_test_manifest() -> WorkspaceManifest {
        let mut environments = IndexMap::new();

        let mut defaults = IndexMap::new();
        defaults.insert("api_url".to_string(), "https://api.example.com".to_string());
        environments.insert("defaults".to_string(), defaults);

        let mut production = IndexMap::new();
        production.insert("api_url".to_string(), "https://api.prod.example.com".to_string());
        production.insert("api_token".to_string(), "prod-token".to_string());
        production.insert("chain_id".to_string(), "1".to_string());
        environments.insert("production".to_string(), production);

        WorkspaceManifest {
            name: "test".to_string(),
            id: "test-id".to_string(),
            runbooks: Vec::new(),
            environments,
            location: None,
        }
    }

    #[test]
    fn test_undefined_input_detection() {
        let manifest = create_test_manifest();
        let mut result = ValidationResult::new();

        let input_refs =
            vec![LocatedInputRef { name: "env.undefined_var".to_string(), line: 10, column: 5 }];

        validate_inputs_against_manifest(
            &input_refs,
            "test content",
            &manifest,
            Some(&"production".to_string()),
            &mut result,
            "test.tx",
            &[],
            ManifestValidationConfig::default(),
        );

        assert_eq!(result.errors.len(), 1);
        assert!(result.errors[0].message.contains("Undefined input"));
    }

    #[test]
    fn test_cli_input_precedence() {
        let manifest = create_test_manifest();
        let mut result = ValidationResult::new();

        let input_refs =
            vec![LocatedInputRef { name: "input.cli_provided".to_string(), line: 10, column: 5 }];

        let cli_inputs = vec![("cli_provided".to_string(), "cli-value".to_string())];

        validate_inputs_against_manifest(
            &input_refs,
            "test content",
            &manifest,
            Some(&"production".to_string()),
            &mut result,
            "test.tx",
            &cli_inputs,
            ManifestValidationConfig::default(),
        );

        // Should not error because CLI input is provided
        assert_eq!(result.errors.len(), 0);

        // Should have suggestion about CLI precedence
        assert_eq!(result.suggestions.len(), 1);
        assert!(result.suggestions[0].message.contains("CLI inputs provided"));
    }

    #[test]
    fn test_strict_mode_validation() {
        let manifest = create_test_manifest();
        let mut result = ValidationResult::new();

        // Reference exists but let's test strict mode warnings
        let input_refs =
            vec![LocatedInputRef { name: "input.api_url".to_string(), line: 10, column: 5 }];

        validate_inputs_against_manifest(
            &input_refs,
            "test content",
            &manifest,
            Some(&"production".to_string()),
            &mut result,
            "test.tx",
            &[],
            ManifestValidationConfig::strict(),
        );

        // In strict mode, we should get no errors for valid inputs
        assert_eq!(result.errors.len(), 0);
    }
}