Skip to main content

apm/cmd/
validate.rs

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/// Rewrites `.apm/config.toml` (or `apm.toml`) to migrate legacy worker config
13/// fields to the current `worker_profile = "agent/role"` shape.
14///
15/// Handles two generations of legacy format:
16/// - V1→V2: `[workers] command/args/model` → `[workers] agent/options.model`
17/// - V2→V3: `[workers] agent/options.model` → `[workers] default/model`
18///
19/// Returns `true` when the file was rewritten, `false` when no migration was needed
20/// or migration was blocked by a non-Claude command.
21pub fn apply_config_migration_fixes(root: &Path) -> Result<bool> {
22    use std::fs;
23
24    // 1. Locate config file
25    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    // 2. Parse with toml_edit (preserves comments, whitespace, key order)
40    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    // 3. Detect legacy fields.
47    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    // 4. Guard: non-claude command.
64    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    // 5. V1→interim: migrate command/args to remove them (will become default below)
83    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        // Keep model temporarily; it will be normalized in step 6.
98        let _ = model_val;
99    }
100
101    // 6. V2→V3: agent/options.model → default/model
102    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    // 7. Remove [worker_profiles] with a warning (manual workflow.toml update required)
141    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        // If [workers].default is still absent after removal, add a placeholder so the
151        // config remains parseable and passes re-validation.
152        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    // 8. Write back
168    fs::write(&config_path, doc.to_string())
169        .with_context(|| format!("writing {}", config_path.display()))?;
170
171    // 9. Re-validate
172    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    // Config migration runs first so the freshly-written config is loaded below.
194    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
397/// Returns `true` when `workflow.toml` was modified.
398/// Repairs in a single write pass:
399/// (a) inserts a missing `on_failure` field after each `completion` line
400///     for Merge/PrOrEpicMerge transitions, porting the value from the
401///     default template's matching transition.
402/// (b) appends any state block referenced by `on_failure` that is absent
403///     from the project's workflow.
404fn 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    // Collect (from_state, to) pairs where on_failure is absent and we know the default value.
418    let mut needs_field_patch: Vec<(String, String)> = Vec::new();
419    // Collect state names that are referenced by on_failure but not declared.
420    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    // 5a: Insert missing on_failure fields.
455    if !needs_field_patch.is_empty() {
456        result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
457    }
458
459    // 5b: Append missing state blocks.
460    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
482/// Insert `on_failure = "..."` after each `completion = "..."` line for the
483/// transitions listed in `needs_patch`.
484fn 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                            (&current_state_id, &current_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
558/// Scan the default workflow template and return the full TOML block for the
559/// state with `id = state_id`, including its transition sub-tables.
560/// Returns `None` when the state is not found in the template.
561fn 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; // reached the next state, done
570            }
571            // Start collecting a candidate block.
572            block.clear();
573            block.push(line);
574            // in_block stays false until we confirm the id.
575        } 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(); // wrong state
583                    }
584                }
585            }
586        }
587    }
588
589    if !in_block || block.is_empty() {
590        return None;
591    }
592
593    // Strip trailing blank lines.
594    while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
595        block.pop();
596    }
597
598    Some(block.join("\n"))
599}
600
601/// Parse `key = "value"` (with optional whitespace) from a trimmed TOML line.
602fn 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}