Skip to main content

apm/cmd/
validate.rs

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