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        .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    // 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.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    // 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    }
151
152    // 8. Write back
153    fs::write(&config_path, doc.to_string())
154        .with_context(|| format!("writing {}", config_path.display()))?;
155
156    // 9. Re-validate
157    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    // Config migration runs first so the freshly-written config is loaded below.
179    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
382/// Returns `true` when `workflow.toml` was modified.
383/// Repairs in a single write pass:
384/// (a) inserts a missing `on_failure` field after each `completion` line
385///     for Merge/PrOrEpicMerge transitions, porting the value from the
386///     default template's matching transition.
387/// (b) appends any state block referenced by `on_failure` that is absent
388///     from the project's workflow.
389fn 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    // Collect (from_state, to) pairs where on_failure is absent and we know the default value.
403    let mut needs_field_patch: Vec<(String, String)> = Vec::new();
404    // Collect state names that are referenced by on_failure but not declared.
405    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    // 5a: Insert missing on_failure fields.
440    if !needs_field_patch.is_empty() {
441        result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
442    }
443
444    // 5b: Append missing state blocks.
445    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
467/// Insert `on_failure = "..."` after each `completion = "..."` line for the
468/// transitions listed in `needs_patch`.
469fn 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                            (&current_state_id, &current_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
543/// Scan the default workflow template and return the full TOML block for the
544/// state with `id = state_id`, including its transition sub-tables.
545/// Returns `None` when the state is not found in the template.
546fn 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; // reached the next state, done
555            }
556            // Start collecting a candidate block.
557            block.clear();
558            block.push(line);
559            // in_block stays false until we confirm the id.
560        } 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(); // wrong state
568                    }
569                }
570            }
571        }
572    }
573
574    if !in_block || block.is_empty() {
575        return None;
576    }
577
578    // Strip trailing blank lines.
579    while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
580        block.pop();
581    }
582
583    Some(block.join("\n"))
584}
585
586/// Parse `key = "value"` (with optional whitespace) from a trimmed TOML line.
587fn 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}