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#[derive(Debug, Serialize)]
13struct Issue {
14    kind: String,
15    subject: String,
16    message: String,
17}
18
19pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: bool) -> Result<()> {
20    let config_errors;
21    let config_warnings;
22    let mut ticket_issues: Vec<Issue> = Vec::new();
23    let mut tickets_checked = 0usize;
24    let config: Config;
25
26    if config_only {
27        config = CmdContext::load_config_only(root)?;
28        config_errors = validate_config(&config, root);
29        config_warnings = validate_warnings(&config);
30    } else {
31        let ctx = CmdContext::load(root, no_aggressive)?;
32        config = ctx.config;
33        config_errors = validate_config(&config, root);
34        config_warnings = validate_warnings(&config);
35        tickets_checked = ctx.tickets.len();
36
37        let tickets = ctx.tickets;
38
39        let merged = apm_core::git::merged_into_main(root, &config.project.default_branch).unwrap_or_default();
40        let merged_set: HashSet<String> = merged.into_iter().collect();
41
42        let state_ids: HashSet<&str> = config.workflow.states.iter()
43            .map(|s| s.id.as_str())
44            .collect();
45
46        let mut branch_fixes: Vec<(ticket::Ticket, String, String)> = Vec::new();
47
48        for t in &tickets {
49            let fm = &t.frontmatter;
50            let ticket_subject = format!("#{}", fm.id);
51
52            if !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str()) {
53                ticket_issues.push(Issue {
54                    kind: "ticket".into(),
55                    subject: ticket_subject.clone(),
56                    message: format!(
57                        "ticket #{} has unknown state '{}'",
58                        fm.id, fm.state
59                    ),
60                });
61            }
62
63            if let Some(branch) = &fm.branch {
64                let canonical = ticket_fmt::branch_name_from_path(&t.path);
65                if let Some(expected) = canonical {
66                    if branch != &expected {
67                        ticket_issues.push(Issue {
68                            kind: "ticket".into(),
69                            subject: ticket_subject.clone(),
70                            message: format!(
71                                "ticket #{} branch field '{}' does not match expected '{}'",
72                                fm.id, branch, expected
73                            ),
74                        });
75                        if fix {
76                            branch_fixes.push((t.clone(), expected, branch.clone()));
77                        }
78                    }
79                }
80            }
81        }
82
83        for (subject, message) in validate_depends_on(&config, &tickets) {
84            ticket_issues.push(Issue {
85                kind: "depends_on".into(),
86                subject,
87                message,
88            });
89        }
90
91        for issue in verify_tickets(root, &config, &tickets, &merged_set) {
92            ticket_issues.push(Issue {
93                kind: "integrity".into(),
94                subject: String::new(),
95                message: issue,
96            });
97        }
98
99        if fix {
100            apply_branch_fixes(root, &config, branch_fixes)?;
101            let merged_refs: HashSet<&str> = merged_set.iter().map(|s| s.as_str()).collect();
102            apply_merged_fixes(root, &config, &tickets, &merged_refs)?;
103        }
104    }
105
106    if fix {
107        apply_on_failure_fixes(root, &config)?;
108        let pattern = apm_core::init::worktree_gitignore_pattern(&config.worktrees.dir);
109        if let Some(p) = pattern {
110            let mut msgs = Vec::new();
111            apm_core::init::ensure_gitignore(&root.join(".gitignore"), Some(&p), &mut msgs)?;
112            for m in &msgs {
113                println!("  fixed: {m}");
114            }
115        }
116    }
117
118    let has_errors = !config_errors.is_empty() || !ticket_issues.is_empty();
119
120    if json {
121        let out = serde_json::json!({
122            "tickets_checked": tickets_checked,
123            "config_errors": config_errors,
124            "warnings": config_warnings,
125            "errors": ticket_issues,
126        });
127        println!("{}", serde_json::to_string_pretty(&out)?);
128    } else {
129        for e in &config_errors {
130            eprintln!("{e}");
131        }
132        for w in &config_warnings {
133            eprintln!("warning: {w}");
134        }
135        for e in &ticket_issues {
136            println!("error [{}] {}: {}", e.kind, e.subject, e.message);
137        }
138        println!(
139            "{} tickets checked, {} config errors, {} warnings, {} ticket errors",
140            tickets_checked,
141            config_errors.len(),
142            config_warnings.len(),
143            ticket_issues.len(),
144        );
145    }
146
147    if config_errors.is_empty() && ticket_issues.is_empty() {
148        if let Ok(hash) = apm_core::hash_stamp::config_hash(root) {
149            let _ = apm_core::hash_stamp::write_stamp(root, &hash);
150        }
151    }
152
153    if has_errors {
154        anyhow::bail!(
155            "{} config errors, {} ticket errors",
156            config_errors.len(),
157            ticket_issues.len()
158        );
159    }
160
161    Ok(())
162}
163
164fn apply_branch_fixes(
165    root: &Path,
166    config: &Config,
167    fixes: Vec<(ticket::Ticket, String, String)>,
168) -> Result<()> {
169    for (mut t, expected_branch, _old_branch) in fixes {
170        let id = t.frontmatter.id.clone();
171        t.frontmatter.branch = Some(expected_branch.clone());
172        let content = t.serialize()?;
173        let filename = t.path.file_name().unwrap().to_string_lossy().to_string();
174        let rel_path = format!("{}/{filename}", config.tickets.dir.to_string_lossy());
175        match git::commit_to_branch(
176            root,
177            &expected_branch,
178            &rel_path,
179            &content,
180            &format!("ticket({id}): fix branch field (validate --fix)"),
181        ) {
182            Ok(_) => println!("  fixed {id}: branch -> {expected_branch}"),
183            Err(e) => eprintln!("  warning: could not fix {id}: {e:#}"),
184        }
185    }
186    Ok(())
187}
188
189/// Returns `true` when `workflow.toml` was modified.
190/// Repairs in a single write pass:
191/// (a) inserts a missing `on_failure` field after each `completion` line
192///     for Merge/PrOrEpicMerge transitions, porting the value from the
193///     default template's matching transition.
194/// (b) appends any state block referenced by `on_failure` that is absent
195///     from the project's workflow.
196fn apply_on_failure_fixes(root: &Path, config: &Config) -> Result<bool> {
197    let workflow_path = root.join(".apm").join("workflow.toml");
198    if !workflow_path.exists() {
199        return Ok(false);
200    }
201
202    let default_on_failure = apm_core::init::default_on_failure_map();
203    let default_toml = apm_core::init::default_workflow_toml();
204
205    let declared_states: std::collections::HashSet<&str> = config.workflow.states.iter()
206        .map(|s| s.id.as_str())
207        .collect();
208
209    // Collect (from_state, to) pairs where on_failure is absent and we know the default value.
210    let mut needs_field_patch: Vec<(String, String)> = Vec::new();
211    // Collect state names that are referenced by on_failure but not declared.
212    let mut needs_state_append: std::collections::HashSet<String> = std::collections::HashSet::new();
213
214    for state in &config.workflow.states {
215        for tr in &state.transitions {
216            if matches!(
217                tr.completion,
218                apm_core::config::CompletionStrategy::Merge
219                    | apm_core::config::CompletionStrategy::PrOrEpicMerge
220            ) {
221                if tr.on_failure.is_none() {
222                    if default_on_failure.contains_key(&tr.to) {
223                        needs_field_patch.push((state.id.clone(), tr.to.clone()));
224                        let of_name = &default_on_failure[&tr.to];
225                        if !declared_states.contains(of_name.as_str()) {
226                            needs_state_append.insert(of_name.clone());
227                        }
228                    }
229                } else if let Some(ref name) = tr.on_failure {
230                    if !declared_states.contains(name.as_str()) {
231                        needs_state_append.insert(name.clone());
232                    }
233                }
234            }
235        }
236    }
237
238    if needs_field_patch.is_empty() && needs_state_append.is_empty() {
239        return Ok(false);
240    }
241
242    let raw = std::fs::read_to_string(&workflow_path)
243        .context("reading .apm/workflow.toml")?;
244    let mut result = raw.clone();
245
246    // 5a: Insert missing on_failure fields.
247    if !needs_field_patch.is_empty() {
248        result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
249    }
250
251    // 5b: Append missing state blocks.
252    for name in &needs_state_append {
253        if let Some(block) = extract_state_block_from_default(default_toml, name) {
254            if !result.ends_with('\n') {
255                result.push('\n');
256            }
257            result.push('\n');
258            result.push_str(&block);
259            result.push('\n');
260            println!("  fixed: appended state '{name}' from default template");
261        } else {
262            eprintln!("  warning: state '{name}' not found in default template — add it manually");
263        }
264    }
265
266    if result == raw {
267        return Ok(false);
268    }
269
270    std::fs::write(&workflow_path, &result).context("writing .apm/workflow.toml")?;
271    Ok(true)
272}
273
274/// Insert `on_failure = "..."` after each `completion = "..."` line for the
275/// transitions listed in `needs_patch`.
276fn patch_on_failure_fields(
277    raw: &str,
278    needs_patch: &[(String, String)],
279    default_on_failure: &std::collections::HashMap<String, String>,
280) -> String {
281    enum Scope { TopLevel, InState, InTransition }
282
283    let mut scope = Scope::TopLevel;
284    let mut current_state_id: Option<String> = None;
285    let mut current_to: Option<String> = None;
286    let mut out: Vec<String> = Vec::new();
287
288    for line in raw.lines() {
289        let trimmed = line.trim();
290        if trimmed == "[[workflow.states]]" {
291            scope = Scope::InState;
292            current_state_id = None;
293            current_to = None;
294            out.push(line.to_string());
295            continue;
296        }
297        if trimmed == "[[workflow.states.transitions]]" {
298            scope = Scope::InTransition;
299            current_to = None;
300            out.push(line.to_string());
301            continue;
302        }
303        match scope {
304            Scope::InState => {
305                if let Some(v) = toml_str_val(trimmed, "id") {
306                    current_state_id = Some(v);
307                }
308            }
309            Scope::InTransition => {
310                if let Some(v) = toml_str_val(trimmed, "to") {
311                    current_to = Some(v);
312                }
313                if let Some(comp) = toml_str_val(trimmed, "completion") {
314                    if comp == "merge" || comp == "pr_or_epic_merge" {
315                        if let (Some(ref from), Some(ref to)) =
316                            (&current_state_id, &current_to)
317                        {
318                            let want = needs_patch.iter().any(|(f, t)| f == from && t == to);
319                            if want {
320                                if let Some(of_val) = default_on_failure.get(to) {
321                                    let indent: String = line
322                                        .chars()
323                                        .take_while(|c| c.is_whitespace())
324                                        .collect();
325                                    out.push(line.to_string());
326                                    out.push(format!("{indent}on_failure = \"{of_val}\""));
327                                    println!(
328                                        "  fixed: added on_failure = \"{of_val}\" to \
329                                         transition '{from}' → '{to}'"
330                                    );
331                                    continue;
332                                }
333                            }
334                        }
335                    }
336                }
337            }
338            Scope::TopLevel => {}
339        }
340        out.push(line.to_string());
341    }
342
343    let mut s = out.join("\n");
344    if raw.ends_with('\n') && !s.ends_with('\n') {
345        s.push('\n');
346    }
347    s
348}
349
350/// Scan the default workflow template and return the full TOML block for the
351/// state with `id = state_id`, including its transition sub-tables.
352/// Returns `None` when the state is not found in the template.
353fn extract_state_block_from_default(default_toml: &str, state_id: &str) -> Option<String> {
354    let mut in_block = false;
355    let mut block: Vec<&str> = Vec::new();
356
357    for line in default_toml.lines() {
358        let trimmed = line.trim();
359        if trimmed == "[[workflow.states]]" {
360            if in_block {
361                break; // reached the next state, done
362            }
363            // Start collecting a candidate block.
364            block.clear();
365            block.push(line);
366            // in_block stays false until we confirm the id.
367        } else if !block.is_empty() || in_block {
368            block.push(line);
369            if !in_block {
370                if let Some(v) = toml_str_val(trimmed, "id") {
371                    if v == state_id {
372                        in_block = true;
373                    } else {
374                        block.clear(); // wrong state
375                    }
376                }
377            }
378        }
379    }
380
381    if !in_block || block.is_empty() {
382        return None;
383    }
384
385    // Strip trailing blank lines.
386    while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
387        block.pop();
388    }
389
390    Some(block.join("\n"))
391}
392
393/// Parse `key = "value"` (with optional whitespace) from a trimmed TOML line.
394fn toml_str_val(line: &str, key: &str) -> Option<String> {
395    if !line.starts_with(key) {
396        return None;
397    }
398    let rest = line[key.len()..].trim_start();
399    if !rest.starts_with('=') {
400        return None;
401    }
402    let after_eq = rest[1..].trim_start();
403    if !after_eq.starts_with('"') {
404        return None;
405    }
406    let inner = &after_eq[1..];
407    let end = inner.find('"')?;
408    Some(inner[..end].to_string())
409}
410
411fn apply_merged_fixes(
412    root: &Path,
413    config: &Config,
414    tickets: &[ticket::Ticket],
415    merged_set: &HashSet<&str>,
416) -> Result<()> {
417    for t in tickets {
418        let fm = &t.frontmatter;
419        let Some(branch) = &fm.branch else { continue };
420        if (fm.state == "in_progress" || fm.state == "implemented")
421            && merged_set.contains(branch.as_str())
422        {
423            let id = fm.id.clone();
424            let old_state = fm.state.clone();
425            match apm_core::ticket::close(root, config, &id, None, "validate --fix", false) {
426                Ok(msgs) => {
427                    for msg in &msgs {
428                        println!("{msg}");
429                    }
430                    println!("  fixed {id}: {old_state} → closed");
431                }
432                Err(e) => eprintln!("  warning: could not fix {id}: {e:#}"),
433            }
434        }
435    }
436    Ok(())
437}