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 .map_or(false, |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 .map_or(false, |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.map_or(false, |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 }
151
152 fs::write(&config_path, doc.to_string())
154 .with_context(|| format!("writing {}", config_path.display()))?;
155
156 let migrated_config = apm_core::config::Config::load(root)
158 .context("migration produced an unparseable config (this is a bug)")?;
159 let errors = apm_core::validate::validate_config(&migrated_config, root);
160 if !errors.is_empty() {
161 anyhow::bail!(
162 "migration produced an invalid config:\n{}",
163 errors.join("\n")
164 );
165 }
166
167 Ok(true)
168}
169
170#[derive(Debug, Serialize)]
171struct Issue {
172 kind: String,
173 subject: String,
174 message: String,
175}
176
177pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: bool, verbose: bool) -> Result<()> {
178 if fix && apply_config_migration_fixes(root)? {
180 println!("migrated [workers] config to agent-driven shape; legacy command/args/model removed");
181 }
182
183 let config_errors;
184 let config_warnings;
185 let mut ticket_issues: Vec<Issue> = Vec::new();
186 let mut tickets_checked = 0usize;
187 let config: Config;
188
189 if config_only {
190 config = CmdContext::load_config_only(root)?;
191 let pair = apm_core::validate::validate_all(&config, root);
192 config_errors = pair.0;
193 config_warnings = pair.1;
194 } else {
195 let ctx = CmdContext::load(root, no_aggressive)?;
196 config = ctx.config;
197 let pair = apm_core::validate::validate_all(&config, root);
198 config_errors = pair.0;
199 config_warnings = pair.1;
200 tickets_checked = ctx.tickets.len();
201
202 let tickets = ctx.tickets;
203
204 let merged = apm_core::git::merged_into_main(root, &config.project.default_branch).unwrap_or_default();
205 let merged_set: HashSet<String> = merged.into_iter().collect();
206
207 let state_ids: HashSet<&str> = config.workflow.states.iter()
208 .map(|s| s.id.as_str())
209 .collect();
210
211 let mut branch_fixes: Vec<(ticket::Ticket, String, String)> = Vec::new();
212
213 for t in &tickets {
214 let fm = &t.frontmatter;
215 let ticket_subject = format!("#{}", fm.id);
216
217 if !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str()) {
218 ticket_issues.push(Issue {
219 kind: "ticket".into(),
220 subject: ticket_subject.clone(),
221 message: format!(
222 "ticket #{} has unknown state '{}'",
223 fm.id, fm.state
224 ),
225 });
226 }
227
228 if let Some(branch) = &fm.branch {
229 let canonical = ticket_fmt::branch_name_from_path(&t.path);
230 if let Some(expected) = canonical {
231 if branch != &expected {
232 ticket_issues.push(Issue {
233 kind: "ticket".into(),
234 subject: ticket_subject.clone(),
235 message: format!(
236 "ticket #{} branch field '{}' does not match expected '{}'",
237 fm.id, branch, expected
238 ),
239 });
240 if fix {
241 branch_fixes.push((t.clone(), expected, branch.clone()));
242 }
243 }
244 }
245 }
246 }
247
248 for (subject, message) in validate_depends_on(&config, &tickets) {
249 ticket_issues.push(Issue {
250 kind: "depends_on".into(),
251 subject,
252 message,
253 });
254 }
255
256 for issue in verify_tickets(root, &config, &tickets, &merged_set) {
257 ticket_issues.push(Issue {
258 kind: "integrity".into(),
259 subject: String::new(),
260 message: issue,
261 });
262 }
263
264 if fix {
265 apply_branch_fixes(root, &config, branch_fixes)?;
266 let merged_refs: HashSet<&str> = merged_set.iter().map(|s| s.as_str()).collect();
267 apply_merged_fixes(root, &config, &tickets, &merged_refs)?;
268 }
269 }
270
271 if fix {
272 apply_on_failure_fixes(root, &config)?;
273 let pattern = apm_core::init::worktree_gitignore_pattern(&config.worktrees.dir);
274 if let Some(p) = pattern {
275 let mut msgs = Vec::new();
276 apm_core::init::ensure_gitignore(&root.join(".gitignore"), Some(&p), &mut msgs)?;
277 for m in &msgs {
278 println!(" fixed: {m}");
279 }
280 }
281 }
282
283 let has_errors = !config_errors.is_empty() || !ticket_issues.is_empty();
284
285 let audit = if verbose {
286 Some(apm_core::validate::audit_agent_resolution(&config, root))
287 } else {
288 None
289 };
290
291 if json {
292 let mut out = serde_json::json!({
293 "tickets_checked": tickets_checked,
294 "config_errors": config_errors,
295 "warnings": config_warnings,
296 "errors": ticket_issues,
297 });
298 if let Some(ref ar) = audit {
299 out["agent_resolution"] = serde_json::to_value(ar)?;
300 }
301 println!("{}", serde_json::to_string_pretty(&out)?);
302 } else {
303 for e in &config_errors {
304 eprintln!("{e}");
305 }
306 for w in &config_warnings {
307 eprintln!("warning: {w}");
308 }
309 for e in &ticket_issues {
310 println!("error [{}] {}: {}", e.kind, e.subject, e.message);
311 }
312 println!(
313 "{} tickets checked, {} config errors, {} warnings, {} ticket errors",
314 tickets_checked,
315 config_errors.len(),
316 config_warnings.len(),
317 ticket_issues.len(),
318 );
319 if let Some(ref ar) = audit {
320 print_agent_resolution_audit(ar);
321 }
322 }
323
324 if config_errors.is_empty() && ticket_issues.is_empty() {
325 if let Ok(hash) = apm_core::hash_stamp::config_hash(root) {
326 let _ = apm_core::hash_stamp::write_stamp(root, &hash);
327 }
328 }
329
330 if has_errors {
331 anyhow::bail!(
332 "{} config errors, {} ticket errors",
333 config_errors.len(),
334 ticket_issues.len()
335 );
336 }
337
338 Ok(())
339}
340
341fn print_agent_resolution_audit(audit: &[apm_core::validate::TransitionAudit]) {
342 let n = audit.len();
343 println!("\nAgent resolution audit -- {n} spawn transition{}:", if n == 1 { "" } else { "s" });
344
345 for ta in audit {
346 let wp_str = match &ta.worker_profile {
347 Some(p) => format!(" [worker_profile: {p}]"),
348 None => String::new(),
349 };
350 println!("\n {} -> {}{}", ta.from_state, ta.to_state, wp_str);
351 println!(" {:<14}{}", "agent:", ta.agent);
352 println!(" {:<14}{}", "role:", ta.role);
353 println!(" {:<14}{}", "wrapper:", ta.wrapper);
354 }
355}
356
357fn apply_branch_fixes(
358 root: &Path,
359 config: &Config,
360 fixes: Vec<(ticket::Ticket, String, String)>,
361) -> Result<()> {
362 for (mut t, expected_branch, _old_branch) in fixes {
363 let id = t.frontmatter.id.clone();
364 t.frontmatter.branch = Some(expected_branch.clone());
365 let content = t.serialize()?;
366 let filename = t.path.file_name().unwrap().to_string_lossy().to_string();
367 let rel_path = format!("{}/{filename}", config.tickets.dir.to_string_lossy());
368 match git::commit_to_branch(
369 root,
370 &expected_branch,
371 &rel_path,
372 &content,
373 &format!("ticket({id}): fix branch field (validate --fix)"),
374 ) {
375 Ok(_) => println!(" fixed {id}: branch -> {expected_branch}"),
376 Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
377 }
378 }
379 Ok(())
380}
381
382fn apply_on_failure_fixes(root: &Path, config: &Config) -> Result<bool> {
390 let workflow_path = root.join(".apm").join("workflow.toml");
391 if !workflow_path.exists() {
392 return Ok(false);
393 }
394
395 let default_on_failure = apm_core::init::default_on_failure_map();
396 let default_toml = apm_core::init::default_workflow_toml();
397
398 let declared_states: std::collections::HashSet<&str> = config.workflow.states.iter()
399 .map(|s| s.id.as_str())
400 .collect();
401
402 let mut needs_field_patch: Vec<(String, String)> = Vec::new();
404 let mut needs_state_append: std::collections::HashSet<String> = std::collections::HashSet::new();
406
407 for state in &config.workflow.states {
408 for tr in &state.transitions {
409 if matches!(
410 tr.completion,
411 apm_core::config::CompletionStrategy::Merge
412 | apm_core::config::CompletionStrategy::PrOrEpicMerge
413 ) {
414 if tr.on_failure.is_none() {
415 if default_on_failure.contains_key(&tr.to) {
416 needs_field_patch.push((state.id.clone(), tr.to.clone()));
417 let of_name = &default_on_failure[&tr.to];
418 if !declared_states.contains(of_name.as_str()) {
419 needs_state_append.insert(of_name.clone());
420 }
421 }
422 } else if let Some(ref name) = tr.on_failure {
423 if !declared_states.contains(name.as_str()) {
424 needs_state_append.insert(name.clone());
425 }
426 }
427 }
428 }
429 }
430
431 if needs_field_patch.is_empty() && needs_state_append.is_empty() {
432 return Ok(false);
433 }
434
435 let raw = std::fs::read_to_string(&workflow_path)
436 .context("reading .apm/workflow.toml")?;
437 let mut result = raw.clone();
438
439 if !needs_field_patch.is_empty() {
441 result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
442 }
443
444 for name in &needs_state_append {
446 if let Some(block) = extract_state_block_from_default(default_toml, name) {
447 if !result.ends_with('\n') {
448 result.push('\n');
449 }
450 result.push('\n');
451 result.push_str(&block);
452 result.push('\n');
453 println!(" fixed: appended state '{name}' from default template");
454 } else {
455 eprintln!(" warning: state '{name}' not found in default template — add it manually");
456 }
457 }
458
459 if result == raw {
460 return Ok(false);
461 }
462
463 std::fs::write(&workflow_path, &result).context("writing .apm/workflow.toml")?;
464 Ok(true)
465}
466
467fn patch_on_failure_fields(
470 raw: &str,
471 needs_patch: &[(String, String)],
472 default_on_failure: &std::collections::HashMap<String, String>,
473) -> String {
474 enum Scope { TopLevel, InState, InTransition }
475
476 let mut scope = Scope::TopLevel;
477 let mut current_state_id: Option<String> = None;
478 let mut current_to: Option<String> = None;
479 let mut out: Vec<String> = Vec::new();
480
481 for line in raw.lines() {
482 let trimmed = line.trim();
483 if trimmed == "[[workflow.states]]" {
484 scope = Scope::InState;
485 current_state_id = None;
486 current_to = None;
487 out.push(line.to_string());
488 continue;
489 }
490 if trimmed == "[[workflow.states.transitions]]" {
491 scope = Scope::InTransition;
492 current_to = None;
493 out.push(line.to_string());
494 continue;
495 }
496 match scope {
497 Scope::InState => {
498 if let Some(v) = toml_str_val(trimmed, "id") {
499 current_state_id = Some(v);
500 }
501 }
502 Scope::InTransition => {
503 if let Some(v) = toml_str_val(trimmed, "to") {
504 current_to = Some(v);
505 }
506 if let Some(comp) = toml_str_val(trimmed, "completion") {
507 if comp == "merge" || comp == "pr_or_epic_merge" {
508 if let (Some(ref from), Some(ref to)) =
509 (¤t_state_id, ¤t_to)
510 {
511 let want = needs_patch.iter().any(|(f, t)| f == from && t == to);
512 if want {
513 if let Some(of_val) = default_on_failure.get(to) {
514 let indent: String = line
515 .chars()
516 .take_while(|c| c.is_whitespace())
517 .collect();
518 out.push(line.to_string());
519 out.push(format!("{indent}on_failure = \"{of_val}\""));
520 println!(
521 " fixed: added on_failure = \"{of_val}\" to \
522 transition '{from}' → '{to}'"
523 );
524 continue;
525 }
526 }
527 }
528 }
529 }
530 }
531 Scope::TopLevel => {}
532 }
533 out.push(line.to_string());
534 }
535
536 let mut s = out.join("\n");
537 if raw.ends_with('\n') && !s.ends_with('\n') {
538 s.push('\n');
539 }
540 s
541}
542
543fn extract_state_block_from_default(default_toml: &str, state_id: &str) -> Option<String> {
547 let mut in_block = false;
548 let mut block: Vec<&str> = Vec::new();
549
550 for line in default_toml.lines() {
551 let trimmed = line.trim();
552 if trimmed == "[[workflow.states]]" {
553 if in_block {
554 break; }
556 block.clear();
558 block.push(line);
559 } else if !block.is_empty() || in_block {
561 block.push(line);
562 if !in_block {
563 if let Some(v) = toml_str_val(trimmed, "id") {
564 if v == state_id {
565 in_block = true;
566 } else {
567 block.clear(); }
569 }
570 }
571 }
572 }
573
574 if !in_block || block.is_empty() {
575 return None;
576 }
577
578 while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
580 block.pop();
581 }
582
583 Some(block.join("\n"))
584}
585
586fn toml_str_val(line: &str, key: &str) -> Option<String> {
588 if !line.starts_with(key) {
589 return None;
590 }
591 let rest = line[key.len()..].trim_start();
592 if !rest.starts_with('=') {
593 return None;
594 }
595 let after_eq = rest[1..].trim_start();
596 if !after_eq.starts_with('"') {
597 return None;
598 }
599 let inner = &after_eq[1..];
600 let end = inner.find('"')?;
601 Some(inner[..end].to_string())
602}
603
604fn apply_merged_fixes(
605 root: &Path,
606 config: &Config,
607 tickets: &[ticket::Ticket],
608 merged_set: &HashSet<&str>,
609) -> Result<()> {
610 for t in tickets {
611 let fm = &t.frontmatter;
612 let Some(branch) = &fm.branch else { continue };
613 if (fm.state == "in_progress" || fm.state == "implemented")
614 && merged_set.contains(branch.as_str())
615 {
616 let id = fm.id.clone();
617 let old_state = fm.state.clone();
618 match apm_core::ticket::close(root, config, &id, None, "validate --fix", false) {
619 Ok(msgs) => {
620 for msg in &msgs {
621 println!("{msg}");
622 }
623 println!(" fixed {id}: {old_state} → closed");
624 }
625 Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
626 }
627 }
628 }
629 Ok(())
630}