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) -> 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 if json {
330 let out = serde_json::json!({
331 "tickets_checked": tickets_checked,
332 "config_errors": config_errors,
333 "warnings": config_warnings,
334 "errors": ticket_issues,
335 });
336 println!("{}", serde_json::to_string_pretty(&out)?);
337 } else {
338 for e in &config_errors {
339 eprintln!("{e}");
340 }
341 for w in &config_warnings {
342 eprintln!("warning: {w}");
343 }
344 for e in &ticket_issues {
345 println!("error [{}] {}: {}", e.kind, e.subject, e.message);
346 }
347 println!(
348 "{} tickets checked, {} config errors, {} warnings, {} ticket errors",
349 tickets_checked,
350 config_errors.len(),
351 config_warnings.len(),
352 ticket_issues.len(),
353 );
354 }
355
356 if config_errors.is_empty() && ticket_issues.is_empty() {
357 if let Ok(hash) = apm_core::hash_stamp::config_hash(root) {
358 let _ = apm_core::hash_stamp::write_stamp(root, &hash);
359 }
360 }
361
362 if has_errors {
363 anyhow::bail!(
364 "{} config errors, {} ticket errors",
365 config_errors.len(),
366 ticket_issues.len()
367 );
368 }
369
370 Ok(())
371}
372
373fn apply_branch_fixes(
374 root: &Path,
375 config: &Config,
376 fixes: Vec<(ticket::Ticket, String, String)>,
377) -> Result<()> {
378 for (mut t, expected_branch, _old_branch) in fixes {
379 let id = t.frontmatter.id.clone();
380 t.frontmatter.branch = Some(expected_branch.clone());
381 let content = t.serialize()?;
382 let filename = t.path.file_name().unwrap().to_string_lossy().to_string();
383 let rel_path = format!("{}/{filename}", config.tickets.dir.to_string_lossy());
384 match git::commit_to_branch(
385 root,
386 &expected_branch,
387 &rel_path,
388 &content,
389 &format!("ticket({id}): fix branch field (validate --fix)"),
390 ) {
391 Ok(_) => println!(" fixed {id}: branch -> {expected_branch}"),
392 Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
393 }
394 }
395 Ok(())
396}
397
398fn apply_on_failure_fixes(root: &Path, config: &Config) -> Result<bool> {
406 let workflow_path = root.join(".apm").join("workflow.toml");
407 if !workflow_path.exists() {
408 return Ok(false);
409 }
410
411 let default_on_failure = apm_core::init::default_on_failure_map();
412 let default_toml = apm_core::init::default_workflow_toml();
413
414 let declared_states: std::collections::HashSet<&str> = config.workflow.states.iter()
415 .map(|s| s.id.as_str())
416 .collect();
417
418 let mut needs_field_patch: Vec<(String, String)> = Vec::new();
420 let mut needs_state_append: std::collections::HashSet<String> = std::collections::HashSet::new();
422
423 for state in &config.workflow.states {
424 for tr in &state.transitions {
425 if matches!(
426 tr.completion,
427 apm_core::config::CompletionStrategy::Merge
428 | apm_core::config::CompletionStrategy::PrOrEpicMerge
429 ) {
430 if tr.on_failure.is_none() {
431 if default_on_failure.contains_key(&tr.to) {
432 needs_field_patch.push((state.id.clone(), tr.to.clone()));
433 let of_name = &default_on_failure[&tr.to];
434 if !declared_states.contains(of_name.as_str()) {
435 needs_state_append.insert(of_name.clone());
436 }
437 }
438 } else if let Some(ref name) = tr.on_failure {
439 if !declared_states.contains(name.as_str()) {
440 needs_state_append.insert(name.clone());
441 }
442 }
443 }
444 }
445 }
446
447 if needs_field_patch.is_empty() && needs_state_append.is_empty() {
448 return Ok(false);
449 }
450
451 let raw = std::fs::read_to_string(&workflow_path)
452 .context("reading .apm/workflow.toml")?;
453 let mut result = raw.clone();
454
455 if !needs_field_patch.is_empty() {
457 result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
458 }
459
460 for name in &needs_state_append {
462 if let Some(block) = extract_state_block_from_default(default_toml, name) {
463 if !result.ends_with('\n') {
464 result.push('\n');
465 }
466 result.push('\n');
467 result.push_str(&block);
468 result.push('\n');
469 println!(" fixed: appended state '{name}' from default template");
470 } else {
471 eprintln!(" warning: state '{name}' not found in default template — add it manually");
472 }
473 }
474
475 if result == raw {
476 return Ok(false);
477 }
478
479 std::fs::write(&workflow_path, &result).context("writing .apm/workflow.toml")?;
480 Ok(true)
481}
482
483fn patch_on_failure_fields(
486 raw: &str,
487 needs_patch: &[(String, String)],
488 default_on_failure: &std::collections::HashMap<String, String>,
489) -> String {
490 enum Scope { TopLevel, InState, InTransition }
491
492 let mut scope = Scope::TopLevel;
493 let mut current_state_id: Option<String> = None;
494 let mut current_to: Option<String> = None;
495 let mut out: Vec<String> = Vec::new();
496
497 for line in raw.lines() {
498 let trimmed = line.trim();
499 if trimmed == "[[workflow.states]]" {
500 scope = Scope::InState;
501 current_state_id = None;
502 current_to = None;
503 out.push(line.to_string());
504 continue;
505 }
506 if trimmed == "[[workflow.states.transitions]]" {
507 scope = Scope::InTransition;
508 current_to = None;
509 out.push(line.to_string());
510 continue;
511 }
512 match scope {
513 Scope::InState => {
514 if let Some(v) = toml_str_val(trimmed, "id") {
515 current_state_id = Some(v);
516 }
517 }
518 Scope::InTransition => {
519 if let Some(v) = toml_str_val(trimmed, "to") {
520 current_to = Some(v);
521 }
522 if let Some(comp) = toml_str_val(trimmed, "completion") {
523 if comp == "merge" || comp == "pr_or_epic_merge" {
524 if let (Some(ref from), Some(ref to)) =
525 (¤t_state_id, ¤t_to)
526 {
527 let want = needs_patch.iter().any(|(f, t)| f == from && t == to);
528 if want {
529 if let Some(of_val) = default_on_failure.get(to) {
530 let indent: String = line
531 .chars()
532 .take_while(|c| c.is_whitespace())
533 .collect();
534 out.push(line.to_string());
535 out.push(format!("{indent}on_failure = \"{of_val}\""));
536 println!(
537 " fixed: added on_failure = \"{of_val}\" to \
538 transition '{from}' → '{to}'"
539 );
540 continue;
541 }
542 }
543 }
544 }
545 }
546 }
547 Scope::TopLevel => {}
548 }
549 out.push(line.to_string());
550 }
551
552 let mut s = out.join("\n");
553 if raw.ends_with('\n') && !s.ends_with('\n') {
554 s.push('\n');
555 }
556 s
557}
558
559fn extract_state_block_from_default(default_toml: &str, state_id: &str) -> Option<String> {
563 let mut in_block = false;
564 let mut block: Vec<&str> = Vec::new();
565
566 for line in default_toml.lines() {
567 let trimmed = line.trim();
568 if trimmed == "[[workflow.states]]" {
569 if in_block {
570 break; }
572 block.clear();
574 block.push(line);
575 } else if !block.is_empty() || in_block {
577 block.push(line);
578 if !in_block {
579 if let Some(v) = toml_str_val(trimmed, "id") {
580 if v == state_id {
581 in_block = true;
582 } else {
583 block.clear(); }
585 }
586 }
587 }
588 }
589
590 if !in_block || block.is_empty() {
591 return None;
592 }
593
594 while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
596 block.pop();
597 }
598
599 Some(block.join("\n"))
600}
601
602fn toml_str_val(line: &str, key: &str) -> Option<String> {
604 if !line.starts_with(key) {
605 return None;
606 }
607 let rest = line[key.len()..].trim_start();
608 if !rest.starts_with('=') {
609 return None;
610 }
611 let after_eq = rest[1..].trim_start();
612 if !after_eq.starts_with('"') {
613 return None;
614 }
615 let inner = &after_eq[1..];
616 let end = inner.find('"')?;
617 Some(inner[..end].to_string())
618}
619
620fn apply_merged_fixes(
621 root: &Path,
622 config: &Config,
623 tickets: &[ticket::Ticket],
624 merged_set: &HashSet<&str>,
625) -> Result<()> {
626 for t in tickets {
627 let fm = &t.frontmatter;
628 let Some(branch) = &fm.branch else { continue };
629 if (fm.state == "in_progress" || fm.state == "implemented")
630 && merged_set.contains(branch.as_str())
631 {
632 let id = fm.id.clone();
633 let old_state = fm.state.clone();
634 match apm_core::ticket::close(root, config, &id, None, "validate --fix", false) {
635 Ok(msgs) => {
636 for msg in &msgs {
637 println!("{msg}");
638 }
639 println!(" fixed {id}: {old_state} → closed");
640 }
641 Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
642 }
643 }
644 }
645 Ok(())
646}