use std::path::Path;
use quote::{quote, ToTokens};
use serde::{Deserialize, Serialize};
use syn::{File, Item, Visibility};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TypeSig {
pub name: String,
pub kind: String,
pub generics: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FnSig {
pub name: String,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TraitSig {
pub name: String,
pub generics: String,
pub supertraits: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ApiSurface {
pub types: Vec<TypeSig>,
pub fns: Vec<FnSig>,
pub traits: Vec<TraitSig>,
pub modules: Vec<String>,
pub uses: Vec<String>,
pub constants: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ApiSurfaceError {
#[error("failed to read source file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse Rust source: {0}")]
Parse(String),
#[error(
"baseline file not found at {0} — run \
`cargo run --bin api_snapshot --quiet > api_baseline.json` to generate it"
)]
BaselineNotFound(String),
#[error("failed to parse baseline JSON: {0}")]
BaselineJson(String),
}
pub fn parse_lib(path: &Path) -> Result<ApiSurface, ApiSurfaceError> {
let src = std::fs::read_to_string(path)?;
let file: File = syn::parse_str(&src).map_err(|e| ApiSurfaceError::Parse(e.to_string()))?;
let mut surface = ApiSurface::default();
collect_items(&file.items, &mut surface);
Ok(surface)
}
fn is_public(vis: &Visibility) -> bool {
matches!(vis, Visibility::Public(_))
}
fn is_doc_hidden(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|a| {
a.path().is_ident("doc") && a.to_token_stream().to_string().contains("hidden")
})
}
fn collect_items(items: &[Item], surface: &mut ApiSurface) {
for item in items {
match item {
Item::Fn(f) if is_public(&f.vis) && !is_doc_hidden(&f.attrs) => {
let sig = &f.sig;
surface.fns.push(FnSig {
name: f.sig.ident.to_string(),
signature: quote!(#sig).to_string(),
});
}
Item::Struct(s) if is_public(&s.vis) && !is_doc_hidden(&s.attrs) => {
let generics = &s.generics;
surface.types.push(TypeSig {
name: s.ident.to_string(),
kind: "struct".into(),
generics: quote!(#generics).to_string(),
});
}
Item::Enum(e) if is_public(&e.vis) && !is_doc_hidden(&e.attrs) => {
let generics = &e.generics;
surface.types.push(TypeSig {
name: e.ident.to_string(),
kind: "enum".into(),
generics: quote!(#generics).to_string(),
});
}
Item::Trait(t) if is_public(&t.vis) && !is_doc_hidden(&t.attrs) => {
let generics = &t.generics;
let supertraits = &t.supertraits;
surface.traits.push(TraitSig {
name: t.ident.to_string(),
generics: quote!(#generics).to_string(),
supertraits: quote!(#supertraits).to_string(),
});
}
Item::Type(ty) if is_public(&ty.vis) && !is_doc_hidden(&ty.attrs) => {
let generics = &ty.generics;
surface.types.push(TypeSig {
name: ty.ident.to_string(),
kind: "type".into(),
generics: quote!(#generics).to_string(),
});
}
Item::Mod(m) if is_public(&m.vis) && !is_doc_hidden(&m.attrs) => {
surface.modules.push(m.ident.to_string());
}
Item::Use(u) if is_public(&u.vis) => {
let tree = &u.tree;
surface.uses.push(quote!(#tree).to_string());
}
Item::Const(c) if is_public(&c.vis) && !is_doc_hidden(&c.attrs) => {
surface.constants.push(c.ident.to_string());
}
Item::Static(s) if is_public(&s.vis) && !is_doc_hidden(&s.attrs) => {
surface.constants.push(s.ident.to_string());
}
_ => {}
}
}
}
pub fn diff_surfaces(baseline: &ApiSurface, current: &ApiSurface) -> SurfaceDiff {
let mut diff = SurfaceDiff::default();
for t in &baseline.types {
match current.types.iter().find(|x| x.name == t.name) {
None => diff.removed_types.push(t.name.clone()),
Some(cur) if cur != t => diff.changed_types.push((t.clone(), cur.clone())),
_ => {}
}
}
for f in &baseline.fns {
match current.fns.iter().find(|x| x.name == f.name) {
None => diff.removed_fns.push(f.name.clone()),
Some(cur) if cur != f => diff.changed_fns.push((f.clone(), cur.clone())),
_ => {}
}
}
for t in &baseline.traits {
if !current.traits.iter().any(|x| x.name == t.name) {
diff.removed_traits.push(t.name.clone());
}
}
for m in &baseline.modules {
if !current.modules.contains(m) {
diff.removed_modules.push(m.clone());
}
}
diff.is_breaking = !diff.removed_types.is_empty()
|| !diff.removed_fns.is_empty()
|| !diff.removed_traits.is_empty()
|| !diff.removed_modules.is_empty()
|| !diff.changed_types.is_empty()
|| !diff.changed_fns.is_empty();
diff
}
#[derive(Debug, Default)]
pub struct SurfaceDiff {
pub removed_types: Vec<String>,
pub changed_types: Vec<(TypeSig, TypeSig)>,
pub removed_fns: Vec<String>,
pub changed_fns: Vec<(FnSig, FnSig)>,
pub removed_traits: Vec<String>,
pub removed_modules: Vec<String>,
pub is_breaking: bool,
}
impl std::fmt::Display for SurfaceDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if !self.is_breaking {
return write!(f, "No breaking changes detected.");
}
writeln!(f, "BREAKING CHANGES DETECTED:")?;
for name in &self.removed_types {
writeln!(f, " REMOVED type: {name}")?;
}
for name in &self.removed_fns {
writeln!(f, " REMOVED fn: {name}")?;
}
for name in &self.removed_traits {
writeln!(f, " REMOVED trait: {name}")?;
}
for name in &self.removed_modules {
writeln!(f, " REMOVED module: {name}")?;
}
for (old, new) in &self.changed_types {
writeln!(f, " CHANGED type {}: {:?} -> {:?}", old.name, old, new)?;
}
for (old, new) in &self.changed_fns {
writeln!(f, " CHANGED fn {}: {:?} -> {:?}", old.name, old, new)?;
}
writeln!(
f,
"\nTo update the baseline after intentional API changes, run:\
\n cargo run -p oxirs-core --bin api_snapshot --quiet \
> core/oxirs-core/api_baseline.json"
)
}
}