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
12pub fn apply_config_migration_fixes(root: &Path) -> Result<bool> {
22 use std::fs;
23
24 let config_path = {
26 let p = root.join(".apm").join("config.toml");
27 if p.exists() {
28 p
29 } else {
30 let p = root.join("apm.toml");
31 if p.exists() {
32 p
33 } else {
34 return Ok(false);
35 }
36 }
37 };
38
39 let content = fs::read_to_string(&config_path)
41 .with_context(|| format!("reading {}", config_path.display()))?;
42 let mut doc = content
43 .parse::<toml_edit::DocumentMut>()
44 .with_context(|| format!("parsing {}", config_path.display()))?;
45
46 let has_v1_legacy = doc
48 .get("workers")
49 .and_then(|v| v.as_table())
50 .is_some_and(|t| t.contains_key("command") || t.contains_key("args"));
51
52 let has_v2_legacy = doc
53 .get("workers")
54 .and_then(|v| v.as_table())
55 .is_some_and(|t| t.contains_key("agent"));
56
57 let has_worker_profiles = doc.get("worker_profiles").is_some();
58
59 if !has_v1_legacy && !has_v2_legacy && !has_worker_profiles {
60 return Ok(false);
61 }
62
63 if let Some(cmd) = doc
65 .get("workers")
66 .and_then(|v| v.as_table())
67 .and_then(|t| t.get("command"))
68 .and_then(|v| v.as_str())
69 {
70 if cmd != "claude" {
71 #[allow(clippy::print_stderr)]
72 {
73 eprintln!(
74 "warning: [workers] command = {:?} is not \"claude\" \u{2014} cannot auto-migrate; choose a wrapper manually",
75 cmd
76 );
77 }
78 return Ok(false);
79 }
80 }
81
82 if has_v1_legacy {
84 let has_command;
85 let has_args;
86 let model_val: Option<String>;
87 {
88 let workers = doc.get("workers").and_then(|v| v.as_table())
89 .expect("workers is a table");
90 has_command = workers.contains_key("command");
91 has_args = workers.contains_key("args");
92 model_val = workers.get("model").and_then(|v| v.as_str()).map(|s| s.to_string());
93 }
94 let workers = doc.get_mut("workers").and_then(|v| v.as_table_mut()).expect("workers");
95 if has_command { workers.remove("command"); }
96 if has_args { workers.remove("args"); }
97 let _ = model_val;
99 }
100
101 let agent_val: Option<String>;
103 let options_model: Option<String>;
104 let has_model_at_top: bool;
105 {
106 let workers = doc.get("workers").and_then(|v| v.as_table());
107 agent_val = workers
108 .and_then(|t| t.get("agent"))
109 .and_then(|v| v.as_str())
110 .map(|s| s.to_string());
111 options_model = workers
112 .and_then(|t| t.get("options"))
113 .and_then(|v| v.as_table())
114 .and_then(|t| t.get("model"))
115 .and_then(|v| v.as_str())
116 .map(|s| s.to_string());
117 has_model_at_top = workers.is_some_and(|t| t.contains_key("model"));
118 }
119
120 if agent_val.is_some() || options_model.is_some() || has_model_at_top {
121 let agent = agent_val.as_deref().unwrap_or("claude");
122 let model = options_model.as_deref()
123 .or_else(|| doc.get("workers").and_then(|v| v.as_table())
124 .and_then(|t| t.get("model"))
125 .and_then(|v| v.as_str()));
126
127 let default_wp = format!("{agent}/coder");
128 let model_str = model.map(|s| s.to_string());
129
130 let workers = doc.get_mut("workers").and_then(|v| v.as_table_mut()).expect("workers");
131 workers.remove("agent");
132 workers.remove("model");
133 if workers.contains_key("options") { workers.remove("options"); }
134 workers.insert("default", toml_edit::value(default_wp.as_str()));
135 if let Some(ref m) = model_str {
136 workers.insert("model", toml_edit::value(m.as_str()));
137 }
138 }
139
140 if has_worker_profiles {
142 doc.remove("worker_profiles");
143 #[allow(clippy::print_stderr)]
144 {
145 eprintln!(
146 "warning: [worker_profiles] removed; add `worker_profile = \"<agent>/<role>\"` \
147 to spawn transitions in .apm/workflow.toml manually"
148 );
149 }
150 let needs_default = doc
153 .get("workers")
154 .and_then(|v| v.as_table())
155 .map(|t| !t.contains_key("default"))
156 .unwrap_or(true);
157 if needs_default {
158 if doc.get("workers").is_none() {
159 doc.insert("workers", toml_edit::Item::Table(toml_edit::Table::new()));
160 }
161 if let Some(workers) = doc.get_mut("workers").and_then(|v| v.as_table_mut()) {
162 workers.insert("default", toml_edit::value("claude/coder"));
163 }
164 }
165 }
166
167 fs::write(&config_path, doc.to_string())
169 .with_context(|| format!("writing {}", config_path.display()))?;
170
171 let migrated_config = apm_core::config::Config::load(root)
173 .context("migration produced an unparseable config (this is a bug)")?;
174 let errors = apm_core::validate::validate_config(&migrated_config, root);
175 if !errors.is_empty() {
176 anyhow::bail!(
177 "migration produced an invalid config:\n{}",
178 errors.join("\n")
179 );
180 }
181
182 Ok(true)
183}
184
185#[derive(Debug, Serialize)]
186struct Issue {
187 kind: String,
188 subject: String,
189 message: String,
190}
191
192pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: bool, verbose: bool) -> Result<()> {
193 if fix && apply_config_migration_fixes(root)? {
195 println!("migrated [workers] config to agent-driven shape; legacy command/args/model removed");
196 }
197
198 let config_errors;
199 let config_warnings;
200 let mut ticket_issues: Vec<Issue> = Vec::new();
201 let mut tickets_checked = 0usize;
202 let config: Config;
203
204 if config_only {
205 config = CmdContext::load_config_only(root)?;
206 let pair = apm_core::validate::validate_all(&config, root);
207 config_errors = pair.0;
208 config_warnings = pair.1;
209 } else {
210 let ctx = CmdContext::load(root, no_aggressive)?;
211 config = ctx.config;
212 let pair = apm_core::validate::validate_all(&config, root);
213 config_errors = pair.0;
214 config_warnings = pair.1;
215 tickets_checked = ctx.tickets.len();
216
217 let tickets = ctx.tickets;
218
219 let merged = apm_core::git::merged_into_main(root, &config.project.default_branch).unwrap_or_default();
220 let merged_set: HashSet<String> = merged.into_iter().collect();
221
222 let state_ids: HashSet<&str> = config.workflow.states.iter()
223 .map(|s| s.id.as_str())
224 .collect();
225
226 let mut branch_fixes: Vec<(ticket::Ticket, String, String)> = Vec::new();
227
228 for t in &tickets {
229 let fm = &t.frontmatter;
230 let ticket_subject = format!("#{}", fm.id);
231
232 if !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str()) {
233 ticket_issues.push(Issue {
234 kind: "ticket".into(),
235 subject: ticket_subject.clone(),
236 message: format!(
237 "ticket #{} has unknown state '{}'",
238 fm.id, fm.state
239 ),
240 });
241 }
242
243 if let Some(branch) = &fm.branch {
244 let canonical = ticket_fmt::branch_name_from_path(&t.path);
245 if let Some(expected) = canonical {
246 if branch != &expected {
247 ticket_issues.push(Issue {
248 kind: "ticket".into(),
249 subject: ticket_subject.clone(),
250 message: format!(
251 "ticket #{} branch field '{}' does not match expected '{}'",
252 fm.id, branch, expected
253 ),
254 });
255 if fix {
256 branch_fixes.push((t.clone(), expected, branch.clone()));
257 }
258 }
259 }
260 }
261 }
262
263 for (subject, message) in validate_depends_on(&config, &tickets) {
264 ticket_issues.push(Issue {
265 kind: "depends_on".into(),
266 subject,
267 message,
268 });
269 }
270
271 for issue in verify_tickets(root, &config, &tickets, &merged_set) {
272 ticket_issues.push(Issue {
273 kind: "integrity".into(),
274 subject: String::new(),
275 message: issue,
276 });
277 }
278
279 if fix {
280 apply_branch_fixes(root, &config, branch_fixes)?;
281 let merged_refs: HashSet<&str> = merged_set.iter().map(|s| s.as_str()).collect();
282 apply_merged_fixes(root, &config, &tickets, &merged_refs)?;
283 }
284 }
285
286 if fix {
287 apply_on_failure_fixes(root, &config)?;
288 let pattern = apm_core::init::worktree_gitignore_pattern(&config.worktrees.dir);
289 if let Some(p) = pattern {
290 let mut msgs = Vec::new();
291 apm_core::init::ensure_gitignore(&root.join(".gitignore"), Some(&p), &mut msgs)?;
292 for m in &msgs {
293 println!(" fixed: {m}");
294 }
295 }
296 }
297
298 let has_errors = !config_errors.is_empty() || !ticket_issues.is_empty();
299
300 let audit = if verbose {
301 Some(apm_core::validate::audit_agent_resolution(&config, root))
302 } else {
303 None
304 };
305
306 if json {
307 let mut out = serde_json::json!({
308 "tickets_checked": tickets_checked,
309 "config_errors": config_errors,
310 "warnings": config_warnings,
311 "errors": ticket_issues,
312 });
313 if let Some(ref ar) = audit {
314 out["agent_resolution"] = serde_json::to_value(ar)?;
315 }
316 println!("{}", serde_json::to_string_pretty(&out)?);
317 } else {
318 for e in &config_errors {
319 eprintln!("{e}");
320 }
321 for w in &config_warnings {
322 eprintln!("warning: {w}");
323 }
324 for e in &ticket_issues {
325 println!("error [{}] {}: {}", e.kind, e.subject, e.message);
326 }
327 println!(
328 "{} tickets checked, {} config errors, {} warnings, {} ticket errors",
329 tickets_checked,
330 config_errors.len(),
331 config_warnings.len(),
332 ticket_issues.len(),
333 );
334 if let Some(ref ar) = audit {
335 print_agent_resolution_audit(ar);
336 }
337 }
338
339 if config_errors.is_empty() && ticket_issues.is_empty() {
340 if let Ok(hash) = apm_core::hash_stamp::config_hash(root) {
341 let _ = apm_core::hash_stamp::write_stamp(root, &hash);
342 }
343 }
344
345 if has_errors {
346 anyhow::bail!(
347 "{} config errors, {} ticket errors",
348 config_errors.len(),
349 ticket_issues.len()
350 );
351 }
352
353 Ok(())
354}
355
356fn print_agent_resolution_audit(audit: &[apm_core::validate::TransitionAudit]) {
357 let n = audit.len();
358 println!("\nAgent resolution audit -- {n} spawn transition{}:", if n == 1 { "" } else { "s" });
359
360 for ta in audit {
361 let wp_str = match &ta.worker_profile {
362 Some(p) => format!(" [worker_profile: {p}]"),
363 None => String::new(),
364 };
365 println!("\n {} -> {}{}", ta.from_state, ta.to_state, wp_str);
366 println!(" {:<14}{}", "agent:", ta.agent);
367 println!(" {:<14}{}", "role:", ta.role);
368 println!(" {:<14}{}", "wrapper:", ta.wrapper);
369 }
370}
371
372fn apply_branch_fixes(
373 root: &Path,
374 config: &Config,
375 fixes: Vec<(ticket::Ticket, String, String)>,
376) -> Result<()> {
377 for (mut t, expected_branch, _old_branch) in fixes {
378 let id = t.frontmatter.id.clone();
379 t.frontmatter.branch = Some(expected_branch.clone());
380 let content = t.serialize()?;
381 let filename = t.path.file_name().unwrap().to_string_lossy().to_string();
382 let rel_path = format!("{}/{filename}", config.tickets.dir.to_string_lossy());
383 match git::commit_to_branch(
384 root,
385 &expected_branch,
386 &rel_path,
387 &content,
388 &format!("ticket({id}): fix branch field (validate --fix)"),
389 ) {
390 Ok(_) => println!(" fixed {id}: branch -> {expected_branch}"),
391 Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
392 }
393 }
394 Ok(())
395}
396
397fn apply_on_failure_fixes(root: &Path, config: &Config) -> Result<bool> {
405 let workflow_path = root.join(".apm").join("workflow.toml");
406 if !workflow_path.exists() {
407 return Ok(false);
408 }
409
410 let default_on_failure = apm_core::init::default_on_failure_map();
411 let default_toml = apm_core::init::default_workflow_toml();
412
413 let declared_states: std::collections::HashSet<&str> = config.workflow.states.iter()
414 .map(|s| s.id.as_str())
415 .collect();
416
417 let mut needs_field_patch: Vec<(String, String)> = Vec::new();
419 let mut needs_state_append: std::collections::HashSet<String> = std::collections::HashSet::new();
421
422 for state in &config.workflow.states {
423 for tr in &state.transitions {
424 if matches!(
425 tr.completion,
426 apm_core::config::CompletionStrategy::Merge
427 | apm_core::config::CompletionStrategy::PrOrEpicMerge
428 ) {
429 if tr.on_failure.is_none() {
430 if default_on_failure.contains_key(&tr.to) {
431 needs_field_patch.push((state.id.clone(), tr.to.clone()));
432 let of_name = &default_on_failure[&tr.to];
433 if !declared_states.contains(of_name.as_str()) {
434 needs_state_append.insert(of_name.clone());
435 }
436 }
437 } else if let Some(ref name) = tr.on_failure {
438 if !declared_states.contains(name.as_str()) {
439 needs_state_append.insert(name.clone());
440 }
441 }
442 }
443 }
444 }
445
446 if needs_field_patch.is_empty() && needs_state_append.is_empty() {
447 return Ok(false);
448 }
449
450 let raw = std::fs::read_to_string(&workflow_path)
451 .context("reading .apm/workflow.toml")?;
452 let mut result = raw.clone();
453
454 if !needs_field_patch.is_empty() {
456 result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
457 }
458
459 for name in &needs_state_append {
461 if let Some(block) = extract_state_block_from_default(default_toml, name) {
462 if !result.ends_with('\n') {
463 result.push('\n');
464 }
465 result.push('\n');
466 result.push_str(&block);
467 result.push('\n');
468 println!(" fixed: appended state '{name}' from default template");
469 } else {
470 eprintln!(" warning: state '{name}' not found in default template — add it manually");
471 }
472 }
473
474 if result == raw {
475 return Ok(false);
476 }
477
478 std::fs::write(&workflow_path, &result).context("writing .apm/workflow.toml")?;
479 Ok(true)
480}
481
482fn patch_on_failure_fields(
485 raw: &str,
486 needs_patch: &[(String, String)],
487 default_on_failure: &std::collections::HashMap<String, String>,
488) -> String {
489 enum Scope { TopLevel, InState, InTransition }
490
491 let mut scope = Scope::TopLevel;
492 let mut current_state_id: Option<String> = None;
493 let mut current_to: Option<String> = None;
494 let mut out: Vec<String> = Vec::new();
495
496 for line in raw.lines() {
497 let trimmed = line.trim();
498 if trimmed == "[[workflow.states]]" {
499 scope = Scope::InState;
500 current_state_id = None;
501 current_to = None;
502 out.push(line.to_string());
503 continue;
504 }
505 if trimmed == "[[workflow.states.transitions]]" {
506 scope = Scope::InTransition;
507 current_to = None;
508 out.push(line.to_string());
509 continue;
510 }
511 match scope {
512 Scope::InState => {
513 if let Some(v) = toml_str_val(trimmed, "id") {
514 current_state_id = Some(v);
515 }
516 }
517 Scope::InTransition => {
518 if let Some(v) = toml_str_val(trimmed, "to") {
519 current_to = Some(v);
520 }
521 if let Some(comp) = toml_str_val(trimmed, "completion") {
522 if comp == "merge" || comp == "pr_or_epic_merge" {
523 if let (Some(ref from), Some(ref to)) =
524 (¤t_state_id, ¤t_to)
525 {
526 let want = needs_patch.iter().any(|(f, t)| f == from && t == to);
527 if want {
528 if let Some(of_val) = default_on_failure.get(to) {
529 let indent: String = line
530 .chars()
531 .take_while(|c| c.is_whitespace())
532 .collect();
533 out.push(line.to_string());
534 out.push(format!("{indent}on_failure = \"{of_val}\""));
535 println!(
536 " fixed: added on_failure = \"{of_val}\" to \
537 transition '{from}' → '{to}'"
538 );
539 continue;
540 }
541 }
542 }
543 }
544 }
545 }
546 Scope::TopLevel => {}
547 }
548 out.push(line.to_string());
549 }
550
551 let mut s = out.join("\n");
552 if raw.ends_with('\n') && !s.ends_with('\n') {
553 s.push('\n');
554 }
555 s
556}
557
558fn extract_state_block_from_default(default_toml: &str, state_id: &str) -> Option<String> {
562 let mut in_block = false;
563 let mut block: Vec<&str> = Vec::new();
564
565 for line in default_toml.lines() {
566 let trimmed = line.trim();
567 if trimmed == "[[workflow.states]]" {
568 if in_block {
569 break; }
571 block.clear();
573 block.push(line);
574 } else if !block.is_empty() || in_block {
576 block.push(line);
577 if !in_block {
578 if let Some(v) = toml_str_val(trimmed, "id") {
579 if v == state_id {
580 in_block = true;
581 } else {
582 block.clear(); }
584 }
585 }
586 }
587 }
588
589 if !in_block || block.is_empty() {
590 return None;
591 }
592
593 while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
595 block.pop();
596 }
597
598 Some(block.join("\n"))
599}
600
601fn toml_str_val(line: &str, key: &str) -> Option<String> {
603 if !line.starts_with(key) {
604 return None;
605 }
606 let rest = line[key.len()..].trim_start();
607 if !rest.starts_with('=') {
608 return None;
609 }
610 let after_eq = rest[1..].trim_start();
611 if !after_eq.starts_with('"') {
612 return None;
613 }
614 let inner = &after_eq[1..];
615 let end = inner.find('"')?;
616 Some(inner[..end].to_string())
617}
618
619fn apply_merged_fixes(
620 root: &Path,
621 config: &Config,
622 tickets: &[ticket::Ticket],
623 merged_set: &HashSet<&str>,
624) -> Result<()> {
625 for t in tickets {
626 let fm = &t.frontmatter;
627 let Some(branch) = &fm.branch else { continue };
628 if (fm.state == "in_progress" || fm.state == "implemented")
629 && merged_set.contains(branch.as_str())
630 {
631 let id = fm.id.clone();
632 let old_state = fm.state.clone();
633 match apm_core::ticket::close(root, config, &id, None, "validate --fix", false) {
634 Ok(msgs) => {
635 for msg in &msgs {
636 println!("{msg}");
637 }
638 println!(" fixed {id}: {old_state} → closed");
639 }
640 Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
641 }
642 }
643 }
644 Ok(())
645}