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) -> 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    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
398/// Returns `true` when `workflow.toml` was modified.
399/// Repairs in a single write pass:
400/// (a) inserts a missing `on_failure` field after each `completion` line
401///     for Merge/PrOrEpicMerge transitions, porting the value from the
402///     default template's matching transition.
403/// (b) appends any state block referenced by `on_failure` that is absent
404///     from the project's workflow.
405fn 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    // Collect (from_state, to) pairs where on_failure is absent and we know the default value.
419    let mut needs_field_patch: Vec<(String, String)> = Vec::new();
420    // Collect state names that are referenced by on_failure but not declared.
421    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    // 5a: Insert missing on_failure fields.
456    if !needs_field_patch.is_empty() {
457        result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
458    }
459
460    // 5b: Append missing state blocks.
461    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
483/// Insert `on_failure = "..."` after each `completion = "..."` line for the
484/// transitions listed in `needs_patch`.
485fn 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                            (&current_state_id, &current_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
559/// Scan the default workflow template and return the full TOML block for the
560/// state with `id = state_id`, including its transition sub-tables.
561/// Returns `None` when the state is not found in the template.
562fn 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; // reached the next state, done
571            }
572            // Start collecting a candidate block.
573            block.clear();
574            block.push(line);
575            // in_block stays false until we confirm the id.
576        } 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(); // wrong state
584                    }
585                }
586            }
587        }
588    }
589
590    if !in_block || block.is_empty() {
591        return None;
592    }
593
594    // Strip trailing blank lines.
595    while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
596        block.pop();
597    }
598
599    Some(block.join("\n"))
600}
601
602/// Parse `key = "value"` (with optional whitespace) from a trimmed TOML line.
603fn 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}