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}