azdolint 0.2.0

CLI tool that validates Azure DevOps pipeline YAML files by checking that referenced variable groups and variables exist
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
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
use clap::Parser;
use std::process;

use azdolint::azure::AzureDevOpsClient;
use azdolint::error::OutputFormatter;
use azdolint::parser::{
    detect_template, extract_template_references, extract_variable_references,
    extract_variable_references_from_content, parse_pipeline_file, resolve_template_path,
};
use azdolint::validator::{validate_variable_groups, validate_variables, VariableSource};

/// Azure DevOps pipeline YAML validator
///
/// Validates that variable groups and variables referenced in Azure DevOps
/// pipeline YAML files actually exist in Azure DevOps.
#[derive(Parser, Debug)]
#[command(name = "azdo-linter")]
#[command(about = "Validates Azure DevOps pipeline YAML variable references")]
struct Args {
    /// Path to the Azure DevOps pipeline YAML file to validate
    #[arg(short, long)]
    pipeline_file: String,

    /// Azure DevOps organization name (e.g., 'myorg' from https://dev.azure.com/myorg)
    #[arg(short, long)]
    organization: String,

    /// Azure DevOps project name
    #[arg(short = 'j', long)]
    project: String,

    /// Pipeline name in Azure DevOps (enables validation against pipeline-level variables)
    #[arg(short = 'n', long)]
    pipeline_name: Option<String>,

    /// Pipeline ID in Azure DevOps (more reliable than name, find it in the URL as pipelineId=XXX)
    #[arg(short = 'i', long)]
    pipeline_id: Option<i32>,

    /// Enable verbose output for debugging
    #[arg(short, long, default_value_t = false)]
    verbose: bool,
}

/// Exit codes for the validator
/// 0 = Success (all validations passed)
/// 1 = Validation failures (some variable groups or variables not found)
/// 2 = Error (could not complete validation due to errors)
const EXIT_SUCCESS: i32 = 0;
const EXIT_VALIDATION_FAILURE: i32 = 1;
const EXIT_ERROR: i32 = 2;

fn main() {
    let args = Args::parse();

    if args.verbose {
        println!("Pipeline file: {}", args.pipeline_file);
        println!("Organization: {}", args.organization);
        println!("Project: {}", args.project);
        if let Some(id) = args.pipeline_id {
            println!("Pipeline ID: {id}");
        }
        if let Some(ref name) = args.pipeline_name {
            println!("Pipeline name: {name}");
        }
    }

    match run_validation(&args) {
        Ok(has_failures) => {
            if has_failures {
                process::exit(EXIT_VALIDATION_FAILURE);
            } else {
                process::exit(EXIT_SUCCESS);
            }
        }
        Err(e) => {
            eprintln!("Error: {e}");
            process::exit(EXIT_ERROR);
        }
    }
}

/// Run the validation workflow and return whether any validation failures occurred
fn run_validation(args: &Args) -> Result<bool, anyhow::Error> {
    println!("Azure DevOps Pipeline Validator");
    println!("================================");
    println!();

    // Parse the pipeline file
    if args.verbose {
        println!("{}", OutputFormatter::info(&format!("Parsing pipeline file: {}", args.pipeline_file)));
    }

    // Check if this is a template file
    let template_info = detect_template(&args.pipeline_file)?;
    if template_info.is_template {
        println!(
            "{}",
            OutputFormatter::warning("This appears to be a template file")
        );
        println!();
        println!("  Template files cannot be validated in isolation because they expect");
        println!("  variables to be provided by the parent pipeline that includes them.");
        println!();
        if !template_info.parameter_names.is_empty() {
            println!("  Template parameters defined:");
            for param in &template_info.parameter_names {
                println!("    - {param}");
            }
            println!();
        }
        println!("  To validate variables used in this template, run the linter against");
        println!("  the parent pipeline that includes this template.");
        println!();
        println!("================================");
        println!("RESULT: SKIPPED (template file)");
        println!("================================");
        return Ok(false); // Exit successfully, not a validation failure
    }

    let pipeline = parse_pipeline_file(&args.pipeline_file)?;

    // Extract variable groups from the pipeline (searches all levels: top, stage, job)
    let variable_groups = pipeline.get_variable_groups();
    if args.verbose {
        println!("{}", OutputFormatter::info(&format!("Found {} variable group(s) referenced", variable_groups.len())));
        for group in &variable_groups {
            println!("       - {group}");
        }
    }

    // Extract inline variables defined in the pipeline
    let inline_variables = pipeline.get_inline_variable_names();
    if args.verbose {
        println!("{}", OutputFormatter::info(&format!("Found {} inline variable(s) defined", inline_variables.len())));
        for var in &inline_variables {
            println!("       - {var}");
        }
    }

    // Extract variable references from the pipeline
    // (excludes PowerShell expressions, system variables, and runtime outputs)
    let variable_references = extract_variable_references(&args.pipeline_file)?;
    if args.verbose {
        println!(
            "{}",
            OutputFormatter::info(&format!("Found {} variable reference(s) to validate", variable_references.len()))
        );
        for var in &variable_references {
            println!("       - $({var})");
        }
    }

    // Initialize Azure DevOps client
    let client = AzureDevOpsClient::new(args.organization.clone(), args.project.clone());

    // Check Azure CLI availability
    if args.verbose {
        println!("{}", OutputFormatter::info("Checking Azure CLI availability..."));
    }
    client.check_cli_available()?;
    if args.verbose {
        println!("{}", OutputFormatter::success("Azure CLI is available and configured"));
    }

    // Fetch pipeline definition variables if pipeline ID or name provided
    // Prefer pipeline_id over pipeline_name as it's more reliable
    let pipeline_definition_vars: Vec<String> = if let Some(pipeline_id) = args.pipeline_id {
        if args.verbose {
            println!(
                "{}",
                OutputFormatter::info(&format!("Fetching variables from pipeline ID: {pipeline_id}"))
            );
        }
        match client.get_pipeline_variable_names_by_id(pipeline_id) {
            Ok(vars) => {
                if args.verbose {
                    println!(
                        "{}",
                        OutputFormatter::info(&format!(
                            "Found {} pipeline definition variable(s)",
                            vars.len()
                        ))
                    );
                    for var in &vars {
                        println!("       - {var}");
                    }
                }
                vars
            }
            Err(e) => {
                // Warn but don't fail - pipeline might not have variables
                println!(
                    "{}",
                    OutputFormatter::warning(&format!("Could not fetch pipeline variables: {e}"))
                );
                Vec::new()
            }
        }
    } else if let Some(ref pipeline_name) = args.pipeline_name {
        if args.verbose {
            println!(
                "{}",
                OutputFormatter::info(&format!("Fetching variables from pipeline: {pipeline_name}"))
            );
        }
        match client.get_pipeline_variable_names(pipeline_name) {
            Ok(vars) => {
                if args.verbose {
                    println!(
                        "{}",
                        OutputFormatter::info(&format!(
                            "Found {} pipeline definition variable(s)",
                            vars.len()
                        ))
                    );
                    for var in &vars {
                        println!("       - {var}");
                    }
                }
                vars
            }
            Err(e) => {
                // Warn but don't fail - pipeline might not have variables
                println!(
                    "{}",
                    OutputFormatter::warning(&format!("Could not fetch pipeline variables: {e}"))
                );
                Vec::new()
            }
        }
    } else {
        Vec::new()
    };

    println!("{}", OutputFormatter::section("Variable Groups"));

    // Validate variable groups exist
    let group_results = validate_variable_groups(variable_groups, &client)?;

    // Track counts for summary
    let mut group_pass_count = 0;
    let mut group_fail_count = 0;

    // Print group validation results
    for result in &group_results {
        if result.exists {
            group_pass_count += 1;
            println!("{}", OutputFormatter::success(&format!("Variable group '{}' exists", result.group_name)));
        } else {
            group_fail_count += 1;
            println!("{}", OutputFormatter::failure(&format!("Variable group '{}' not found", result.group_name)));
            if let Some(ref error) = result.error {
                if args.verbose {
                    println!("         Error: {error}");
                }
            }
            // Provide actionable suggestion
            println!(
                "         Suggestion: Create the variable group in Azure DevOps at:\n         https://dev.azure.com/{}/{}/_library?itemType=VariableGroups",
                args.organization, args.project
            );
        }
    }

    if group_results.is_empty() {
        println!("{}", OutputFormatter::info("No variable groups referenced in pipeline"));
    }

    println!("{}", OutputFormatter::section("Variable References"));

    // Validate variables exist in groups, are defined inline, or are on the pipeline definition
    let variable_results = validate_variables(
        variable_references,
        &group_results,
        &inline_variables,
        &pipeline_definition_vars,
        &client,
    )?;

    // Track counts for summary
    let mut var_pass_count = 0;
    let mut var_fail_count = 0;

    // Print variable validation results
    for result in &variable_results {
        if result.exists {
            var_pass_count += 1;
            match &result.source {
                VariableSource::Group(group_name) => {
                    println!(
                        "{}",
                        OutputFormatter::success(&format!("Variable '{}' found in group '{}'", result.variable_name, group_name))
                    );
                }
                VariableSource::Inline => {
                    println!(
                        "{}",
                        OutputFormatter::success(&format!("Variable '{}' defined inline in pipeline", result.variable_name))
                    );
                }
                VariableSource::PipelineDefinition => {
                    println!(
                        "{}",
                        OutputFormatter::success(&format!("Variable '{}' defined on pipeline", result.variable_name))
                    );
                }
                VariableSource::NotFound => {
                    // This shouldn't happen if exists is true, but handle it gracefully
                    println!("{}", OutputFormatter::success(&format!("Variable '{}' found", result.variable_name)));
                }
            }
        } else {
            var_fail_count += 1;
            println!(
                "{}",
                OutputFormatter::failure(&format!("Variable '{}' not found in any referenced group", result.variable_name))
            );
            if let Some(ref error) = result.error {
                if args.verbose {
                    println!("         Error: {error}");
                }
            }
            // Provide actionable suggestion
            println!("         Suggestion: Add this variable to one of the referenced variable groups,");
            println!("         define it inline in the pipeline YAML, or add it to the pipeline definition.");
            if args.pipeline_id.is_none() && args.pipeline_name.is_none() {
                println!("         Tip: Use --pipeline-id or --pipeline-name to check variables defined on the pipeline itself.");
            }
        }
    }

    if variable_results.is_empty() {
        println!("{}", OutputFormatter::info("No variable references found in pipeline"));
    }

    // Validate templates referenced in the pipeline
    let template_refs = extract_template_references(&args.pipeline_file)?;
    let mut template_pass_count = 0;
    let mut template_fail_count = 0;

    if !template_refs.is_empty() {
        for template_ref in &template_refs {
            let resolved_path = resolve_template_path(&args.pipeline_file, &template_ref.template_path);

            // Build section header
            let stage_info = template_ref
                .stage_name
                .as_ref()
                .map(|s| format!(" (stage: {s})"))
                .unwrap_or_default();
            let groups_info = if template_ref.available_groups.is_empty() {
                String::new()
            } else {
                format!(", groups: {}", template_ref.available_groups.join(", "))
            };

            println!(
                "{}",
                OutputFormatter::section(&format!(
                    "Template: {}{}{}",
                    template_ref.template_path, stage_info, groups_info
                ))
            );

            // Check if template file exists
            if !std::path::Path::new(&resolved_path).exists() {
                println!(
                    "{}",
                    OutputFormatter::warning(&format!(
                        "Template file not found: {} (resolved to: {})",
                        template_ref.template_path, resolved_path
                    ))
                );
                println!("         The template may be in a different repository or location.");
                continue;
            }

            // Read and extract variable references from template
            let template_content = std::fs::read_to_string(&resolved_path)?;
            let template_var_refs = extract_variable_references_from_content(&template_content)?;

            if template_var_refs.is_empty() {
                println!(
                    "{}",
                    OutputFormatter::info("No variable references found in template")
                );
                continue;
            }

            if args.verbose {
                println!(
                    "{}",
                    OutputFormatter::info(&format!(
                        "Found {} variable reference(s) in template",
                        template_var_refs.len()
                    ))
                );
            }

            // Validate template's variable groups exist (filter to only those we haven't validated yet)
            let new_groups: Vec<String> = template_ref
                .available_groups
                .iter()
                .filter(|g| !group_results.iter().any(|r| &r.group_name == *g))
                .cloned()
                .collect();

            let template_group_results = if !new_groups.is_empty() {
                validate_variable_groups(new_groups, &client)?
            } else {
                Vec::new()
            };

            // Combine all group results for validation
            let all_group_results: Vec<_> = group_results
                .iter()
                .chain(template_group_results.iter())
                .filter(|r| template_ref.available_groups.contains(&r.group_name))
                .cloned()
                .collect();

            // Validate template variables
            let template_var_results = validate_variables(
                template_var_refs,
                &all_group_results,
                &template_ref.available_inline_vars,
                &pipeline_definition_vars,
                &client,
            )?;

            // Print template variable validation results
            for result in &template_var_results {
                if result.exists {
                    template_pass_count += 1;
                    match &result.source {
                        VariableSource::Group(group_name) => {
                            println!(
                                "{}",
                                OutputFormatter::success(&format!(
                                    "Variable '{}' found in group '{}'",
                                    result.variable_name, group_name
                                ))
                            );
                        }
                        VariableSource::Inline => {
                            println!(
                                "{}",
                                OutputFormatter::success(&format!(
                                    "Variable '{}' defined inline in parent pipeline",
                                    result.variable_name
                                ))
                            );
                        }
                        VariableSource::PipelineDefinition => {
                            println!(
                                "{}",
                                OutputFormatter::success(&format!(
                                    "Variable '{}' defined on pipeline",
                                    result.variable_name
                                ))
                            );
                        }
                        VariableSource::NotFound => {
                            println!(
                                "{}",
                                OutputFormatter::success(&format!("Variable '{}' found", result.variable_name))
                            );
                        }
                    }
                } else {
                    template_fail_count += 1;
                    println!(
                        "{}",
                        OutputFormatter::failure(&format!(
                            "Variable '{}' not found in available groups",
                            result.variable_name
                        ))
                    );
                    if !template_ref.available_groups.is_empty() {
                        println!(
                            "         Available groups: {}",
                            template_ref.available_groups.join(", ")
                        );
                    }
                    println!("         Suggestion: Add this variable to one of the available variable groups.");
                }
            }
        }
    }

    // Calculate totals
    let total_passed = group_pass_count + var_pass_count + template_pass_count;
    let total_failed = group_fail_count + var_fail_count + template_fail_count;

    // Print summary using OutputFormatter
    println!("{}", OutputFormatter::summary(total_passed, total_failed));

    Ok(total_failed > 0)
}