1use anyhow::{Context, Result};
2pub use apm_core::validate::validate_config;
3pub use apm_core::validate::validate_depends_on;
4pub use apm_core::validate::validate_warnings;
5pub use apm_core::validate::verify_tickets;
6use apm_core::{config::Config, git, ticket, ticket_fmt};
7use serde::Serialize;
8use std::collections::HashSet;
9use std::path::Path;
10use crate::ctx::CmdContext;
11
12#[derive(Debug, Serialize)]
13struct Issue {
14 kind: String,
15 subject: String,
16 message: String,
17}
18
19pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: bool) -> Result<()> {
20 let config_errors;
21 let config_warnings;
22 let mut ticket_issues: Vec<Issue> = Vec::new();
23 let mut tickets_checked = 0usize;
24 let config: Config;
25
26 if config_only {
27 config = CmdContext::load_config_only(root)?;
28 config_errors = validate_config(&config, root);
29 config_warnings = validate_warnings(&config);
30 } else {
31 let ctx = CmdContext::load(root, no_aggressive)?;
32 config = ctx.config;
33 config_errors = validate_config(&config, root);
34 config_warnings = validate_warnings(&config);
35 tickets_checked = ctx.tickets.len();
36
37 let tickets = ctx.tickets;
38
39 let merged = apm_core::git::merged_into_main(root, &config.project.default_branch).unwrap_or_default();
40 let merged_set: HashSet<String> = merged.into_iter().collect();
41
42 let state_ids: HashSet<&str> = config.workflow.states.iter()
43 .map(|s| s.id.as_str())
44 .collect();
45
46 let mut branch_fixes: Vec<(ticket::Ticket, String, String)> = Vec::new();
47
48 for t in &tickets {
49 let fm = &t.frontmatter;
50 let ticket_subject = format!("#{}", fm.id);
51
52 if !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str()) {
53 ticket_issues.push(Issue {
54 kind: "ticket".into(),
55 subject: ticket_subject.clone(),
56 message: format!(
57 "ticket #{} has unknown state '{}'",
58 fm.id, fm.state
59 ),
60 });
61 }
62
63 if let Some(branch) = &fm.branch {
64 let canonical = ticket_fmt::branch_name_from_path(&t.path);
65 if let Some(expected) = canonical {
66 if branch != &expected {
67 ticket_issues.push(Issue {
68 kind: "ticket".into(),
69 subject: ticket_subject.clone(),
70 message: format!(
71 "ticket #{} branch field '{}' does not match expected '{}'",
72 fm.id, branch, expected
73 ),
74 });
75 if fix {
76 branch_fixes.push((t.clone(), expected, branch.clone()));
77 }
78 }
79 }
80 }
81 }
82
83 for (subject, message) in validate_depends_on(&config, &tickets) {
84 ticket_issues.push(Issue {
85 kind: "depends_on".into(),
86 subject,
87 message,
88 });
89 }
90
91 for issue in verify_tickets(root, &config, &tickets, &merged_set) {
92 ticket_issues.push(Issue {
93 kind: "integrity".into(),
94 subject: String::new(),
95 message: issue,
96 });
97 }
98
99 if fix {
100 apply_branch_fixes(root, &config, branch_fixes)?;
101 let merged_refs: HashSet<&str> = merged_set.iter().map(|s| s.as_str()).collect();
102 apply_merged_fixes(root, &config, &tickets, &merged_refs)?;
103 }
104 }
105
106 if fix {
107 apply_on_failure_fixes(root, &config)?;
108 let pattern = apm_core::init::worktree_gitignore_pattern(&config.worktrees.dir);
109 if let Some(p) = pattern {
110 let mut msgs = Vec::new();
111 apm_core::init::ensure_gitignore(&root.join(".gitignore"), Some(&p), &mut msgs)?;
112 for m in &msgs {
113 println!(" fixed: {m}");
114 }
115 }
116 }
117
118 let has_errors = !config_errors.is_empty() || !ticket_issues.is_empty();
119
120 if json {
121 let out = serde_json::json!({
122 "tickets_checked": tickets_checked,
123 "config_errors": config_errors,
124 "warnings": config_warnings,
125 "errors": ticket_issues,
126 });
127 println!("{}", serde_json::to_string_pretty(&out)?);
128 } else {
129 for e in &config_errors {
130 eprintln!("{e}");
131 }
132 for w in &config_warnings {
133 eprintln!("warning: {w}");
134 }
135 for e in &ticket_issues {
136 println!("error [{}] {}: {}", e.kind, e.subject, e.message);
137 }
138 println!(
139 "{} tickets checked, {} config errors, {} warnings, {} ticket errors",
140 tickets_checked,
141 config_errors.len(),
142 config_warnings.len(),
143 ticket_issues.len(),
144 );
145 }
146
147 if config_errors.is_empty() && ticket_issues.is_empty() {
148 if let Ok(hash) = apm_core::hash_stamp::config_hash(root) {
149 let _ = apm_core::hash_stamp::write_stamp(root, &hash);
150 }
151 }
152
153 if has_errors {
154 anyhow::bail!(
155 "{} config errors, {} ticket errors",
156 config_errors.len(),
157 ticket_issues.len()
158 );
159 }
160
161 Ok(())
162}
163
164fn apply_branch_fixes(
165 root: &Path,
166 config: &Config,
167 fixes: Vec<(ticket::Ticket, String, String)>,
168) -> Result<()> {
169 for (mut t, expected_branch, _old_branch) in fixes {
170 let id = t.frontmatter.id.clone();
171 t.frontmatter.branch = Some(expected_branch.clone());
172 let content = t.serialize()?;
173 let filename = t.path.file_name().unwrap().to_string_lossy().to_string();
174 let rel_path = format!("{}/{filename}", config.tickets.dir.to_string_lossy());
175 match git::commit_to_branch(
176 root,
177 &expected_branch,
178 &rel_path,
179 &content,
180 &format!("ticket({id}): fix branch field (validate --fix)"),
181 ) {
182 Ok(_) => println!(" fixed {id}: branch -> {expected_branch}"),
183 Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
184 }
185 }
186 Ok(())
187}
188
189fn apply_on_failure_fixes(root: &Path, config: &Config) -> Result<bool> {
197 let workflow_path = root.join(".apm").join("workflow.toml");
198 if !workflow_path.exists() {
199 return Ok(false);
200 }
201
202 let default_on_failure = apm_core::init::default_on_failure_map();
203 let default_toml = apm_core::init::default_workflow_toml();
204
205 let declared_states: std::collections::HashSet<&str> = config.workflow.states.iter()
206 .map(|s| s.id.as_str())
207 .collect();
208
209 let mut needs_field_patch: Vec<(String, String)> = Vec::new();
211 let mut needs_state_append: std::collections::HashSet<String> = std::collections::HashSet::new();
213
214 for state in &config.workflow.states {
215 for tr in &state.transitions {
216 if matches!(
217 tr.completion,
218 apm_core::config::CompletionStrategy::Merge
219 | apm_core::config::CompletionStrategy::PrOrEpicMerge
220 ) {
221 if tr.on_failure.is_none() {
222 if default_on_failure.contains_key(&tr.to) {
223 needs_field_patch.push((state.id.clone(), tr.to.clone()));
224 let of_name = &default_on_failure[&tr.to];
225 if !declared_states.contains(of_name.as_str()) {
226 needs_state_append.insert(of_name.clone());
227 }
228 }
229 } else if let Some(ref name) = tr.on_failure {
230 if !declared_states.contains(name.as_str()) {
231 needs_state_append.insert(name.clone());
232 }
233 }
234 }
235 }
236 }
237
238 if needs_field_patch.is_empty() && needs_state_append.is_empty() {
239 return Ok(false);
240 }
241
242 let raw = std::fs::read_to_string(&workflow_path)
243 .context("reading .apm/workflow.toml")?;
244 let mut result = raw.clone();
245
246 if !needs_field_patch.is_empty() {
248 result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
249 }
250
251 for name in &needs_state_append {
253 if let Some(block) = extract_state_block_from_default(default_toml, name) {
254 if !result.ends_with('\n') {
255 result.push('\n');
256 }
257 result.push('\n');
258 result.push_str(&block);
259 result.push('\n');
260 println!(" fixed: appended state '{name}' from default template");
261 } else {
262 eprintln!(" warning: state '{name}' not found in default template — add it manually");
263 }
264 }
265
266 if result == raw {
267 return Ok(false);
268 }
269
270 std::fs::write(&workflow_path, &result).context("writing .apm/workflow.toml")?;
271 Ok(true)
272}
273
274fn patch_on_failure_fields(
277 raw: &str,
278 needs_patch: &[(String, String)],
279 default_on_failure: &std::collections::HashMap<String, String>,
280) -> String {
281 enum Scope { TopLevel, InState, InTransition }
282
283 let mut scope = Scope::TopLevel;
284 let mut current_state_id: Option<String> = None;
285 let mut current_to: Option<String> = None;
286 let mut out: Vec<String> = Vec::new();
287
288 for line in raw.lines() {
289 let trimmed = line.trim();
290 if trimmed == "[[workflow.states]]" {
291 scope = Scope::InState;
292 current_state_id = None;
293 current_to = None;
294 out.push(line.to_string());
295 continue;
296 }
297 if trimmed == "[[workflow.states.transitions]]" {
298 scope = Scope::InTransition;
299 current_to = None;
300 out.push(line.to_string());
301 continue;
302 }
303 match scope {
304 Scope::InState => {
305 if let Some(v) = toml_str_val(trimmed, "id") {
306 current_state_id = Some(v);
307 }
308 }
309 Scope::InTransition => {
310 if let Some(v) = toml_str_val(trimmed, "to") {
311 current_to = Some(v);
312 }
313 if let Some(comp) = toml_str_val(trimmed, "completion") {
314 if comp == "merge" || comp == "pr_or_epic_merge" {
315 if let (Some(ref from), Some(ref to)) =
316 (¤t_state_id, ¤t_to)
317 {
318 let want = needs_patch.iter().any(|(f, t)| f == from && t == to);
319 if want {
320 if let Some(of_val) = default_on_failure.get(to) {
321 let indent: String = line
322 .chars()
323 .take_while(|c| c.is_whitespace())
324 .collect();
325 out.push(line.to_string());
326 out.push(format!("{indent}on_failure = \"{of_val}\""));
327 println!(
328 " fixed: added on_failure = \"{of_val}\" to \
329 transition '{from}' → '{to}'"
330 );
331 continue;
332 }
333 }
334 }
335 }
336 }
337 }
338 Scope::TopLevel => {}
339 }
340 out.push(line.to_string());
341 }
342
343 let mut s = out.join("\n");
344 if raw.ends_with('\n') && !s.ends_with('\n') {
345 s.push('\n');
346 }
347 s
348}
349
350fn extract_state_block_from_default(default_toml: &str, state_id: &str) -> Option<String> {
354 let mut in_block = false;
355 let mut block: Vec<&str> = Vec::new();
356
357 for line in default_toml.lines() {
358 let trimmed = line.trim();
359 if trimmed == "[[workflow.states]]" {
360 if in_block {
361 break; }
363 block.clear();
365 block.push(line);
366 } else if !block.is_empty() || in_block {
368 block.push(line);
369 if !in_block {
370 if let Some(v) = toml_str_val(trimmed, "id") {
371 if v == state_id {
372 in_block = true;
373 } else {
374 block.clear(); }
376 }
377 }
378 }
379 }
380
381 if !in_block || block.is_empty() {
382 return None;
383 }
384
385 while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
387 block.pop();
388 }
389
390 Some(block.join("\n"))
391}
392
393fn toml_str_val(line: &str, key: &str) -> Option<String> {
395 if !line.starts_with(key) {
396 return None;
397 }
398 let rest = line[key.len()..].trim_start();
399 if !rest.starts_with('=') {
400 return None;
401 }
402 let after_eq = rest[1..].trim_start();
403 if !after_eq.starts_with('"') {
404 return None;
405 }
406 let inner = &after_eq[1..];
407 let end = inner.find('"')?;
408 Some(inner[..end].to_string())
409}
410
411fn apply_merged_fixes(
412 root: &Path,
413 config: &Config,
414 tickets: &[ticket::Ticket],
415 merged_set: &HashSet<&str>,
416) -> Result<()> {
417 for t in tickets {
418 let fm = &t.frontmatter;
419 let Some(branch) = &fm.branch else { continue };
420 if (fm.state == "in_progress" || fm.state == "implemented")
421 && merged_set.contains(branch.as_str())
422 {
423 let id = fm.id.clone();
424 let old_state = fm.state.clone();
425 match apm_core::ticket::close(root, config, &id, None, "validate --fix", false) {
426 Ok(msgs) => {
427 for msg in &msgs {
428 println!("{msg}");
429 }
430 println!(" fixed {id}: {old_state} → closed");
431 }
432 Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
433 }
434 }
435 }
436 Ok(())
437}