use anyhow::{Context, Result};
pub use apm_core::validate::validate_config;
pub use apm_core::validate::validate_depends_on;
pub use apm_core::validate::validate_warnings;
pub use apm_core::validate::verify_tickets;
use apm_core::{config::Config, git, ticket, ticket_fmt};
use serde::Serialize;
use std::collections::HashSet;
use std::path::Path;
use crate::ctx::CmdContext;
pub fn apply_config_migration_fixes(root: &Path) -> Result<bool> {
use std::fs;
let config_path = {
let p = root.join(".apm").join("config.toml");
if p.exists() {
p
} else {
let p = root.join("apm.toml");
if p.exists() {
p
} else {
return Ok(false);
}
}
};
let content = fs::read_to_string(&config_path)
.with_context(|| format!("reading {}", config_path.display()))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("parsing {}", config_path.display()))?;
let has_v1_legacy = doc
.get("workers")
.and_then(|v| v.as_table())
.map_or(false, |t| t.contains_key("command") || t.contains_key("args"));
let has_v2_legacy = doc
.get("workers")
.and_then(|v| v.as_table())
.map_or(false, |t| t.contains_key("agent"));
let has_worker_profiles = doc.get("worker_profiles").is_some();
if !has_v1_legacy && !has_v2_legacy && !has_worker_profiles {
return Ok(false);
}
if let Some(cmd) = doc
.get("workers")
.and_then(|v| v.as_table())
.and_then(|t| t.get("command"))
.and_then(|v| v.as_str())
{
if cmd != "claude" {
#[allow(clippy::print_stderr)]
{
eprintln!(
"warning: [workers] command = {:?} is not \"claude\" \u{2014} cannot auto-migrate; choose a wrapper manually",
cmd
);
}
return Ok(false);
}
}
if has_v1_legacy {
let has_command;
let has_args;
let model_val: Option<String>;
{
let workers = doc.get("workers").and_then(|v| v.as_table())
.expect("workers is a table");
has_command = workers.contains_key("command");
has_args = workers.contains_key("args");
model_val = workers.get("model").and_then(|v| v.as_str()).map(|s| s.to_string());
}
let workers = doc.get_mut("workers").and_then(|v| v.as_table_mut()).expect("workers");
if has_command { workers.remove("command"); }
if has_args { workers.remove("args"); }
let _ = model_val;
}
let agent_val: Option<String>;
let options_model: Option<String>;
let has_model_at_top: bool;
{
let workers = doc.get("workers").and_then(|v| v.as_table());
agent_val = workers
.and_then(|t| t.get("agent"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
options_model = workers
.and_then(|t| t.get("options"))
.and_then(|v| v.as_table())
.and_then(|t| t.get("model"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
has_model_at_top = workers.map_or(false, |t| t.contains_key("model"));
}
if agent_val.is_some() || options_model.is_some() || has_model_at_top {
let agent = agent_val.as_deref().unwrap_or("claude");
let model = options_model.as_deref()
.or_else(|| doc.get("workers").and_then(|v| v.as_table())
.and_then(|t| t.get("model"))
.and_then(|v| v.as_str()));
let default_wp = format!("{agent}/worker");
let model_str = model.map(|s| s.to_string());
let workers = doc.get_mut("workers").and_then(|v| v.as_table_mut()).expect("workers");
workers.remove("agent");
workers.remove("model");
if workers.contains_key("options") { workers.remove("options"); }
workers.insert("default", toml_edit::value(default_wp.as_str()));
if let Some(ref m) = model_str {
workers.insert("model", toml_edit::value(m.as_str()));
}
}
if has_worker_profiles {
doc.remove("worker_profiles");
#[allow(clippy::print_stderr)]
{
eprintln!(
"warning: [worker_profiles] removed; add `worker_profile = \"<agent>/<role>\"` \
to spawn transitions in .apm/workflow.toml manually"
);
}
}
fs::write(&config_path, doc.to_string())
.with_context(|| format!("writing {}", config_path.display()))?;
let migrated_config = apm_core::config::Config::load(root)
.context("migration produced an unparseable config (this is a bug)")?;
let errors = apm_core::validate::validate_config(&migrated_config, root);
if !errors.is_empty() {
anyhow::bail!(
"migration produced an invalid config:\n{}",
errors.join("\n")
);
}
Ok(true)
}
#[derive(Debug, Serialize)]
struct Issue {
kind: String,
subject: String,
message: String,
}
pub fn run(root: &Path, fix: bool, json: bool, config_only: bool, no_aggressive: bool, verbose: bool) -> Result<()> {
if fix && apply_config_migration_fixes(root)? {
println!("migrated [workers] config to agent-driven shape; legacy command/args/model removed");
}
let config_errors;
let config_warnings;
let mut ticket_issues: Vec<Issue> = Vec::new();
let mut tickets_checked = 0usize;
let config: Config;
if config_only {
config = CmdContext::load_config_only(root)?;
let pair = apm_core::validate::validate_all(&config, root);
config_errors = pair.0;
config_warnings = pair.1;
} else {
let ctx = CmdContext::load(root, no_aggressive)?;
config = ctx.config;
let pair = apm_core::validate::validate_all(&config, root);
config_errors = pair.0;
config_warnings = pair.1;
tickets_checked = ctx.tickets.len();
let tickets = ctx.tickets;
let merged = apm_core::git::merged_into_main(root, &config.project.default_branch).unwrap_or_default();
let merged_set: HashSet<String> = merged.into_iter().collect();
let state_ids: HashSet<&str> = config.workflow.states.iter()
.map(|s| s.id.as_str())
.collect();
let mut branch_fixes: Vec<(ticket::Ticket, String, String)> = Vec::new();
for t in &tickets {
let fm = &t.frontmatter;
let ticket_subject = format!("#{}", fm.id);
if !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str()) {
ticket_issues.push(Issue {
kind: "ticket".into(),
subject: ticket_subject.clone(),
message: format!(
"ticket #{} has unknown state '{}'",
fm.id, fm.state
),
});
}
if let Some(branch) = &fm.branch {
let canonical = ticket_fmt::branch_name_from_path(&t.path);
if let Some(expected) = canonical {
if branch != &expected {
ticket_issues.push(Issue {
kind: "ticket".into(),
subject: ticket_subject.clone(),
message: format!(
"ticket #{} branch field '{}' does not match expected '{}'",
fm.id, branch, expected
),
});
if fix {
branch_fixes.push((t.clone(), expected, branch.clone()));
}
}
}
}
}
for (subject, message) in validate_depends_on(&config, &tickets) {
ticket_issues.push(Issue {
kind: "depends_on".into(),
subject,
message,
});
}
for issue in verify_tickets(root, &config, &tickets, &merged_set) {
ticket_issues.push(Issue {
kind: "integrity".into(),
subject: String::new(),
message: issue,
});
}
if fix {
apply_branch_fixes(root, &config, branch_fixes)?;
let merged_refs: HashSet<&str> = merged_set.iter().map(|s| s.as_str()).collect();
apply_merged_fixes(root, &config, &tickets, &merged_refs)?;
}
}
if fix {
apply_on_failure_fixes(root, &config)?;
let pattern = apm_core::init::worktree_gitignore_pattern(&config.worktrees.dir);
if let Some(p) = pattern {
let mut msgs = Vec::new();
apm_core::init::ensure_gitignore(&root.join(".gitignore"), Some(&p), &mut msgs)?;
for m in &msgs {
println!(" fixed: {m}");
}
}
}
let has_errors = !config_errors.is_empty() || !ticket_issues.is_empty();
let audit = if verbose {
Some(apm_core::validate::audit_agent_resolution(&config, root))
} else {
None
};
if json {
let mut out = serde_json::json!({
"tickets_checked": tickets_checked,
"config_errors": config_errors,
"warnings": config_warnings,
"errors": ticket_issues,
});
if let Some(ref ar) = audit {
out["agent_resolution"] = serde_json::to_value(ar)?;
}
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
for e in &config_errors {
eprintln!("{e}");
}
for w in &config_warnings {
eprintln!("warning: {w}");
}
for e in &ticket_issues {
println!("error [{}] {}: {}", e.kind, e.subject, e.message);
}
println!(
"{} tickets checked, {} config errors, {} warnings, {} ticket errors",
tickets_checked,
config_errors.len(),
config_warnings.len(),
ticket_issues.len(),
);
if let Some(ref ar) = audit {
print_agent_resolution_audit(ar);
}
}
if config_errors.is_empty() && ticket_issues.is_empty() {
if let Ok(hash) = apm_core::hash_stamp::config_hash(root) {
let _ = apm_core::hash_stamp::write_stamp(root, &hash);
}
}
if has_errors {
anyhow::bail!(
"{} config errors, {} ticket errors",
config_errors.len(),
ticket_issues.len()
);
}
Ok(())
}
fn print_agent_resolution_audit(audit: &[apm_core::validate::TransitionAudit]) {
let n = audit.len();
println!("\nAgent resolution audit -- {n} spawn transition{}:", if n == 1 { "" } else { "s" });
for ta in audit {
let wp_str = match &ta.worker_profile {
Some(p) => format!(" [worker_profile: {p}]"),
None => String::new(),
};
println!("\n {} -> {}{}", ta.from_state, ta.to_state, wp_str);
println!(" {:<14}{}", "agent:", ta.agent);
println!(" {:<14}{}", "role:", ta.role);
println!(" {:<14}{}", "wrapper:", ta.wrapper);
}
}
fn apply_branch_fixes(
root: &Path,
config: &Config,
fixes: Vec<(ticket::Ticket, String, String)>,
) -> Result<()> {
for (mut t, expected_branch, _old_branch) in fixes {
let id = t.frontmatter.id.clone();
t.frontmatter.branch = Some(expected_branch.clone());
let content = t.serialize()?;
let filename = t.path.file_name().unwrap().to_string_lossy().to_string();
let rel_path = format!("{}/{filename}", config.tickets.dir.to_string_lossy());
match git::commit_to_branch(
root,
&expected_branch,
&rel_path,
&content,
&format!("ticket({id}): fix branch field (validate --fix)"),
) {
Ok(_) => println!(" fixed {id}: branch -> {expected_branch}"),
Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
}
}
Ok(())
}
fn apply_on_failure_fixes(root: &Path, config: &Config) -> Result<bool> {
let workflow_path = root.join(".apm").join("workflow.toml");
if !workflow_path.exists() {
return Ok(false);
}
let default_on_failure = apm_core::init::default_on_failure_map();
let default_toml = apm_core::init::default_workflow_toml();
let declared_states: std::collections::HashSet<&str> = config.workflow.states.iter()
.map(|s| s.id.as_str())
.collect();
let mut needs_field_patch: Vec<(String, String)> = Vec::new();
let mut needs_state_append: std::collections::HashSet<String> = std::collections::HashSet::new();
for state in &config.workflow.states {
for tr in &state.transitions {
if matches!(
tr.completion,
apm_core::config::CompletionStrategy::Merge
| apm_core::config::CompletionStrategy::PrOrEpicMerge
) {
if tr.on_failure.is_none() {
if default_on_failure.contains_key(&tr.to) {
needs_field_patch.push((state.id.clone(), tr.to.clone()));
let of_name = &default_on_failure[&tr.to];
if !declared_states.contains(of_name.as_str()) {
needs_state_append.insert(of_name.clone());
}
}
} else if let Some(ref name) = tr.on_failure {
if !declared_states.contains(name.as_str()) {
needs_state_append.insert(name.clone());
}
}
}
}
}
if needs_field_patch.is_empty() && needs_state_append.is_empty() {
return Ok(false);
}
let raw = std::fs::read_to_string(&workflow_path)
.context("reading .apm/workflow.toml")?;
let mut result = raw.clone();
if !needs_field_patch.is_empty() {
result = patch_on_failure_fields(&result, &needs_field_patch, &default_on_failure);
}
for name in &needs_state_append {
if let Some(block) = extract_state_block_from_default(default_toml, name) {
if !result.ends_with('\n') {
result.push('\n');
}
result.push('\n');
result.push_str(&block);
result.push('\n');
println!(" fixed: appended state '{name}' from default template");
} else {
eprintln!(" warning: state '{name}' not found in default template — add it manually");
}
}
if result == raw {
return Ok(false);
}
std::fs::write(&workflow_path, &result).context("writing .apm/workflow.toml")?;
Ok(true)
}
fn patch_on_failure_fields(
raw: &str,
needs_patch: &[(String, String)],
default_on_failure: &std::collections::HashMap<String, String>,
) -> String {
enum Scope { TopLevel, InState, InTransition }
let mut scope = Scope::TopLevel;
let mut current_state_id: Option<String> = None;
let mut current_to: Option<String> = None;
let mut out: Vec<String> = Vec::new();
for line in raw.lines() {
let trimmed = line.trim();
if trimmed == "[[workflow.states]]" {
scope = Scope::InState;
current_state_id = None;
current_to = None;
out.push(line.to_string());
continue;
}
if trimmed == "[[workflow.states.transitions]]" {
scope = Scope::InTransition;
current_to = None;
out.push(line.to_string());
continue;
}
match scope {
Scope::InState => {
if let Some(v) = toml_str_val(trimmed, "id") {
current_state_id = Some(v);
}
}
Scope::InTransition => {
if let Some(v) = toml_str_val(trimmed, "to") {
current_to = Some(v);
}
if let Some(comp) = toml_str_val(trimmed, "completion") {
if comp == "merge" || comp == "pr_or_epic_merge" {
if let (Some(ref from), Some(ref to)) =
(¤t_state_id, ¤t_to)
{
let want = needs_patch.iter().any(|(f, t)| f == from && t == to);
if want {
if let Some(of_val) = default_on_failure.get(to) {
let indent: String = line
.chars()
.take_while(|c| c.is_whitespace())
.collect();
out.push(line.to_string());
out.push(format!("{indent}on_failure = \"{of_val}\""));
println!(
" fixed: added on_failure = \"{of_val}\" to \
transition '{from}' → '{to}'"
);
continue;
}
}
}
}
}
}
Scope::TopLevel => {}
}
out.push(line.to_string());
}
let mut s = out.join("\n");
if raw.ends_with('\n') && !s.ends_with('\n') {
s.push('\n');
}
s
}
fn extract_state_block_from_default(default_toml: &str, state_id: &str) -> Option<String> {
let mut in_block = false;
let mut block: Vec<&str> = Vec::new();
for line in default_toml.lines() {
let trimmed = line.trim();
if trimmed == "[[workflow.states]]" {
if in_block {
break; }
block.clear();
block.push(line);
} else if !block.is_empty() || in_block {
block.push(line);
if !in_block {
if let Some(v) = toml_str_val(trimmed, "id") {
if v == state_id {
in_block = true;
} else {
block.clear(); }
}
}
}
}
if !in_block || block.is_empty() {
return None;
}
while block.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
block.pop();
}
Some(block.join("\n"))
}
fn toml_str_val(line: &str, key: &str) -> Option<String> {
if !line.starts_with(key) {
return None;
}
let rest = line[key.len()..].trim_start();
if !rest.starts_with('=') {
return None;
}
let after_eq = rest[1..].trim_start();
if !after_eq.starts_with('"') {
return None;
}
let inner = &after_eq[1..];
let end = inner.find('"')?;
Some(inner[..end].to_string())
}
fn apply_merged_fixes(
root: &Path,
config: &Config,
tickets: &[ticket::Ticket],
merged_set: &HashSet<&str>,
) -> Result<()> {
for t in tickets {
let fm = &t.frontmatter;
let Some(branch) = &fm.branch else { continue };
if (fm.state == "in_progress" || fm.state == "implemented")
&& merged_set.contains(branch.as_str())
{
let id = fm.id.clone();
let old_state = fm.state.clone();
match apm_core::ticket::close(root, config, &id, None, "validate --fix", false) {
Ok(msgs) => {
for msg in &msgs {
println!("{msg}");
}
println!(" fixed {id}: {old_state} → closed");
}
Err(e) => eprintln!(" warning: could not fix {id}: {e:#}"),
}
}
}
Ok(())
}