Skip to main content

apm/cmd/
validate.rs

1use anyhow::Result;
2pub use apm_core::validate::validate_config;
3pub use apm_core::validate::validate_warnings;
4use apm_core::{config::Config, git, ticket, ticket_fmt};
5use serde::Serialize;
6use std::collections::HashSet;
7use std::path::Path;
8use crate::ctx::CmdContext;
9
10#[derive(Debug, Serialize)]
11struct Issue {
12    kind: String,
13    subject: String,
14    message: String,
15}
16
17pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: bool) -> Result<()> {
18    let config_errors;
19    let config_warnings;
20    let mut ticket_issues: Vec<Issue> = Vec::new();
21    let mut tickets_checked = 0usize;
22    let config: Config;
23
24    if config_only {
25        config = CmdContext::load_config_only(root)?;
26        config_errors = validate_config(&config, root);
27        config_warnings = validate_warnings(&config);
28    } else {
29        let ctx = CmdContext::load(root, no_aggressive)?;
30        config = ctx.config;
31        config_errors = validate_config(&config, root);
32        config_warnings = validate_warnings(&config);
33        tickets_checked = ctx.tickets.len();
34
35        let tickets = ctx.tickets;
36
37        let state_ids: HashSet<&str> = config.workflow.states.iter()
38            .map(|s| s.id.as_str())
39            .collect();
40
41        let mut branch_fixes: Vec<(ticket::Ticket, String, String)> = Vec::new();
42
43        for t in &tickets {
44            let fm = &t.frontmatter;
45            let ticket_subject = format!("#{}", fm.id);
46
47            if !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str()) {
48                ticket_issues.push(Issue {
49                    kind: "ticket".into(),
50                    subject: ticket_subject.clone(),
51                    message: format!(
52                        "ticket #{} has unknown state '{}'",
53                        fm.id, fm.state
54                    ),
55                });
56            }
57
58            if let Some(branch) = &fm.branch {
59                let canonical = ticket_fmt::branch_name_from_path(&t.path);
60                if let Some(expected) = canonical {
61                    if branch != &expected {
62                        ticket_issues.push(Issue {
63                            kind: "ticket".into(),
64                            subject: ticket_subject.clone(),
65                            message: format!(
66                                "ticket #{} branch field '{}' does not match expected '{}'",
67                                fm.id, branch, expected
68                            ),
69                        });
70                        if fix {
71                            branch_fixes.push((t.clone(), expected, branch.clone()));
72                        }
73                    }
74                }
75            }
76        }
77
78        if fix {
79            apply_branch_fixes(root, &config, branch_fixes)?;
80        }
81    }
82
83    let has_errors = !config_errors.is_empty() || !ticket_issues.is_empty();
84
85    if json {
86        let out = serde_json::json!({
87            "tickets_checked": tickets_checked,
88            "config_errors": config_errors,
89            "warnings": config_warnings,
90            "errors": ticket_issues,
91        });
92        println!("{}", serde_json::to_string_pretty(&out)?);
93    } else {
94        for e in &config_errors {
95            eprintln!("{e}");
96        }
97        for w in &config_warnings {
98            eprintln!("warning: {w}");
99        }
100        for e in &ticket_issues {
101            println!("error [{}] {}: {}", e.kind, e.subject, e.message);
102        }
103        println!(
104            "{} tickets checked, {} config errors, {} warnings, {} ticket errors",
105            tickets_checked,
106            config_errors.len(),
107            config_warnings.len(),
108            ticket_issues.len(),
109        );
110    }
111
112    if has_errors {
113        anyhow::bail!(
114            "{} config errors, {} ticket errors",
115            config_errors.len(),
116            ticket_issues.len()
117        );
118    }
119
120    Ok(())
121}
122
123fn apply_branch_fixes(
124    root: &Path,
125    config: &Config,
126    fixes: Vec<(ticket::Ticket, String, String)>,
127) -> Result<()> {
128    for (mut t, expected_branch, _old_branch) in fixes {
129        let id = t.frontmatter.id.clone();
130        t.frontmatter.branch = Some(expected_branch.clone());
131        let content = t.serialize()?;
132        let filename = t.path.file_name().unwrap().to_string_lossy().to_string();
133        let rel_path = format!("{}/{filename}", config.tickets.dir.to_string_lossy());
134        match git::commit_to_branch(
135            root,
136            &expected_branch,
137            &rel_path,
138            &content,
139            &format!("ticket({id}): fix branch field (validate --fix)"),
140        ) {
141            Ok(_) => println!("  fixed {id}: branch -> {expected_branch}"),
142            Err(e) => eprintln!("  warning: could not fix {id}: {e:#}"),
143        }
144    }
145    Ok(())
146}