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}