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