use soothfast_spec::compat::Severity;
use crate::invoke::{self, CommonArgs};
use crate::spec_config;
use crate::spec_gen;
pub fn run(args: &[String]) -> i32 {
let mut common = CommonArgs::default();
let mut base = "origin/master".to_string();
let mut allow_breaking = false;
let mut from_committed = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--base" => match it.next() {
Some(b) => base = b.clone(),
None => {
eprintln!("soothfast: --base needs a git ref");
return 2;
}
},
"--allow-breaking" => allow_breaking = true,
"--from-committed" => from_committed = true,
_ => {
if !common.try_parse(a, &mut it) {
eprintln!("soothfast: unknown spec gate arg {a:?}");
return 2;
}
}
}
}
let Some(pkg) = common.pkg.clone() else {
eprintln!("soothfast: spec gate requires -p PKG");
return 2;
};
match gate(&pkg, &common, &base, allow_breaking, from_committed) {
Ok(code) => code,
Err(e) => {
eprintln!("soothfast: {e}");
1
}
}
}
fn gate(
pkg: &str,
common: &CommonArgs,
base: &str,
allow_breaking: bool,
from_committed: bool,
) -> Result<i32, String> {
let meta = invoke::pkg_meta(pkg).map_err(|e| e.to_string())?;
let cfg = spec_config::load(&meta.dir)?;
let head = spec_gen::build(pkg, common, None, &cfg, &meta)?;
if head.docs.is_empty() {
println!("spec gate: no generated spec files — nothing to gate");
return Ok(0);
}
let base_docs = if from_committed {
committed_base_docs(&head, &cfg, &meta, base)?
} else {
invoke::with_merge_base_worktree(base, |wt| {
spec_gen::build(pkg, common, Some(wt), &cfg, &meta)
})?
.docs
};
let mut breaking = 0u32;
let mut additive = 0u32;
for (spec_file, new_doc) in &head.docs {
let kind = cfg.dialect_of(spec_file);
let empty = kind.empty();
let old_doc = base_docs.get(spec_file).unwrap_or(&empty);
let changes = kind.diff(old_doc, new_doc);
if changes.is_empty() {
println!(
"spec gate: {spec_file} [{}] — no API changes vs {base}",
kind.name()
);
continue;
}
println!("spec gate: {spec_file} [{}] — vs {base}", kind.name());
for c in &changes {
let tag = match c.severity {
Severity::Breaking => {
breaking += 1;
"BREAKING"
}
Severity::Additive => {
additive += 1;
"additive"
}
};
println!(" {tag} {}: {}", c.at, c.detail);
}
}
println!("spec gate: {breaking} breaking, {additive} additive");
if breaking > 0 && !allow_breaking {
println!(
"spec gate: FAILED — pass --allow-breaking to release these \
deliberately, after coordinating with consumers"
);
return Ok(1);
}
if breaking > 0 {
println!("spec gate: breaking changes allowed by --allow-breaking");
}
Ok(0)
}
fn committed_base_docs(
head: &spec_gen::Built,
cfg: &spec_config::SpecConfig,
meta: &invoke::PkgMeta,
base: &str,
) -> Result<std::collections::BTreeMap<String, serde_json::Value>, String> {
let sha = invoke::git(&["merge-base", "HEAD", base]).map_err(|e| e.to_string())?;
println!(
"spec gate: base from committed specs at {}",
&sha[..sha.len().min(12)]
);
let mut docs = std::collections::BTreeMap::new();
for spec_file in head.docs.keys() {
let rev = format!("{sha}:./{spec_file}");
let Ok(text) = invoke::git_in(&meta.dir, &["show", &rev]) else {
continue;
};
let value = cfg.dialect_of(spec_file).parse(spec_file, &text)?;
docs.insert(spec_file.clone(), value);
}
Ok(docs)
}