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 replace legacy
13/// `[workers] command/args/model` fields with the agent-wrapper shape.
14///
15/// Returns `true` when the file was rewritten, `false` when no legacy fields
16/// were detected (no-op) or when migration was blocked by a non-Claude command.
17pub fn apply_config_migration_fixes(root: &Path) -> Result<bool> {
18    use std::fs;
19
20    // 1. Locate config file
21    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    // 2. Parse with toml_edit (preserves comments, whitespace, key order)
36    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    // 3. Detect legacy fields.
43    // Use .get() throughout: DocumentMut::index panics for missing top-level keys.
44    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    // 4. Guard: non-claude command — block migration and warn if any command
74    //    is not "claude" (we can't safely choose a wrapper for unknown tools).
75    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    // 5. Migrate [workers]
116    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 is &mut Table; Table::IndexMut creates keys when missing.
148            // options was just inserted as Item::Table, so ["options"] returns &mut Item::Table.
149            // ["model"] on Item::Table creates the "model" entry via Item::IndexMut.
150            workers["options"]["model"] = toml_edit::value(model.as_str());
151        }
152    }
153
154    // 6. Migrate each [worker_profiles.<name>]
155    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            // Remove command; do NOT add agent at profile level (inherits from [workers])
182            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    // 7. Write back (toml_edit preserves comments, whitespace, and unrelated sections)
197    fs::write(&config_path, doc.to_string())
198        .with_context(|| format!("writing {}", config_path.display()))?;
199
200    // 8. Re-validate: confirm the migration did not produce an invalid config.
201    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    // Config migration runs first so the freshly-written config is loaded below.
223    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        // Compute max value width across the 3 sourced fields for alignment.
408        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        // Label column is 14 chars wide (matches "instructions: ").
418        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
465/// Returns `true` when `workflow.toml` was modified.
466/// Repairs in a single write pass:
467/// (a) inserts a missing `on_failure` field after each `completion` line
468///     for Merge/PrOrEpicMerge transitions, porting the value from the
469///     default template's matching transition.
470/// (b) appends any state block referenced by `on_failure` that is absent
471///     from the project's workflow.
472fn 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    // Collect (from_state, to) pairs where on_failure is absent and we know the default value.
486    let mut needs_field_patch: Vec<(String, String)> = Vec::new();
487    // Collect state names that are referenced by on_failure but not declared.
488    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    // 5a: Insert missing on_failure fields.
523    if !needs_field_patch.is_empty() {
524        result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
525    }
526
527    // 5b: Append missing state blocks.
528    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
550/// Insert `on_failure = "..."` after each `completion = "..."` line for the
551/// transitions listed in `needs_patch`.
552fn 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                            (&current_state_id, &current_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
626/// Scan the default workflow template and return the full TOML block for the
627/// state with `id = state_id`, including its transition sub-tables.
628/// Returns `None` when the state is not found in the template.
629fn 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; // reached the next state, done
638            }
639            // Start collecting a candidate block.
640            block.clear();
641            block.push(line);
642            // in_block stays false until we confirm the id.
643        } 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(); // wrong state
651                    }
652                }
653            }
654        }
655    }
656
657    if !in_block || block.is_empty() {
658        return None;
659    }
660
661    // Strip trailing blank lines.
662    while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
663        block.pop();
664    }
665
666    Some(block.join("\n"))
667}
668
669/// Parse `key = "value"` (with optional whitespace) from a trimmed TOML line.
670fn 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}