1use std::path::Path;
17
18use quote::{quote, ToTokens};
19use serde::{Deserialize, Serialize};
20use syn::{File, Item, Visibility};
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct TypeSig {
27 pub name: String,
29 pub kind: String,
31 pub generics: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct FnSig {
38 pub name: String,
40 pub signature: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct TraitSig {
47 pub name: String,
49 pub generics: String,
51 pub supertraits: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
57pub struct ApiSurface {
58 pub types: Vec<TypeSig>,
60 pub fns: Vec<FnSig>,
62 pub traits: Vec<TraitSig>,
64 pub modules: Vec<String>,
66 pub uses: Vec<String>,
68 pub constants: Vec<String>,
70}
71
72#[derive(Debug, thiserror::Error)]
76pub enum ApiSurfaceError {
77 #[error("failed to read source file: {0}")]
79 Io(#[from] std::io::Error),
80
81 #[error("failed to parse Rust source: {0}")]
83 Parse(String),
84
85 #[error(
87 "baseline file not found at {0} — run \
88 `cargo run --bin api_snapshot --quiet > api_baseline.json` to generate it"
89 )]
90 BaselineNotFound(String),
91
92 #[error("failed to parse baseline JSON: {0}")]
94 BaselineJson(String),
95}
96
97pub fn parse_lib(path: &Path) -> Result<ApiSurface, ApiSurfaceError> {
105 let src = std::fs::read_to_string(path)?;
106 let file: File = syn::parse_str(&src).map_err(|e| ApiSurfaceError::Parse(e.to_string()))?;
107 let mut surface = ApiSurface::default();
108 collect_items(&file.items, &mut surface);
109 Ok(surface)
110}
111
112fn is_public(vis: &Visibility) -> bool {
114 matches!(vis, Visibility::Public(_))
115}
116
117fn is_doc_hidden(attrs: &[syn::Attribute]) -> bool {
119 attrs.iter().any(|a| {
120 a.path().is_ident("doc") && a.to_token_stream().to_string().contains("hidden")
123 })
124}
125
126fn collect_items(items: &[Item], surface: &mut ApiSurface) {
128 for item in items {
129 match item {
130 Item::Fn(f) if is_public(&f.vis) && !is_doc_hidden(&f.attrs) => {
131 let sig = &f.sig;
132 surface.fns.push(FnSig {
133 name: f.sig.ident.to_string(),
134 signature: quote!(#sig).to_string(),
135 });
136 }
137 Item::Struct(s) if is_public(&s.vis) && !is_doc_hidden(&s.attrs) => {
138 let generics = &s.generics;
139 surface.types.push(TypeSig {
140 name: s.ident.to_string(),
141 kind: "struct".into(),
142 generics: quote!(#generics).to_string(),
143 });
144 }
145 Item::Enum(e) if is_public(&e.vis) && !is_doc_hidden(&e.attrs) => {
146 let generics = &e.generics;
147 surface.types.push(TypeSig {
148 name: e.ident.to_string(),
149 kind: "enum".into(),
150 generics: quote!(#generics).to_string(),
151 });
152 }
153 Item::Trait(t) if is_public(&t.vis) && !is_doc_hidden(&t.attrs) => {
154 let generics = &t.generics;
155 let supertraits = &t.supertraits;
156 surface.traits.push(TraitSig {
157 name: t.ident.to_string(),
158 generics: quote!(#generics).to_string(),
159 supertraits: quote!(#supertraits).to_string(),
160 });
161 }
162 Item::Type(ty) if is_public(&ty.vis) && !is_doc_hidden(&ty.attrs) => {
163 let generics = &ty.generics;
164 surface.types.push(TypeSig {
165 name: ty.ident.to_string(),
166 kind: "type".into(),
167 generics: quote!(#generics).to_string(),
168 });
169 }
170 Item::Mod(m) if is_public(&m.vis) && !is_doc_hidden(&m.attrs) => {
171 surface.modules.push(m.ident.to_string());
172 }
173 Item::Use(u) if is_public(&u.vis) => {
174 let tree = &u.tree;
175 surface.uses.push(quote!(#tree).to_string());
176 }
177 Item::Const(c) if is_public(&c.vis) && !is_doc_hidden(&c.attrs) => {
178 surface.constants.push(c.ident.to_string());
179 }
180 Item::Static(s) if is_public(&s.vis) && !is_doc_hidden(&s.attrs) => {
181 surface.constants.push(s.ident.to_string());
182 }
183 _ => {}
184 }
185 }
186}
187
188pub fn diff_surfaces(baseline: &ApiSurface, current: &ApiSurface) -> SurfaceDiff {
195 let mut diff = SurfaceDiff::default();
196
197 for t in &baseline.types {
199 match current.types.iter().find(|x| x.name == t.name) {
200 None => diff.removed_types.push(t.name.clone()),
201 Some(cur) if cur != t => diff.changed_types.push((t.clone(), cur.clone())),
202 _ => {}
203 }
204 }
205
206 for f in &baseline.fns {
208 match current.fns.iter().find(|x| x.name == f.name) {
209 None => diff.removed_fns.push(f.name.clone()),
210 Some(cur) if cur != f => diff.changed_fns.push((f.clone(), cur.clone())),
211 _ => {}
212 }
213 }
214
215 for t in &baseline.traits {
217 if !current.traits.iter().any(|x| x.name == t.name) {
218 diff.removed_traits.push(t.name.clone());
219 }
220 }
221
222 for m in &baseline.modules {
224 if !current.modules.contains(m) {
225 diff.removed_modules.push(m.clone());
226 }
227 }
228
229 diff.is_breaking = !diff.removed_types.is_empty()
230 || !diff.removed_fns.is_empty()
231 || !diff.removed_traits.is_empty()
232 || !diff.removed_modules.is_empty()
233 || !diff.changed_types.is_empty()
234 || !diff.changed_fns.is_empty();
235
236 diff
237}
238
239#[derive(Debug, Default)]
241pub struct SurfaceDiff {
242 pub removed_types: Vec<String>,
244 pub changed_types: Vec<(TypeSig, TypeSig)>,
246 pub removed_fns: Vec<String>,
248 pub changed_fns: Vec<(FnSig, FnSig)>,
250 pub removed_traits: Vec<String>,
252 pub removed_modules: Vec<String>,
254 pub is_breaking: bool,
256}
257
258impl std::fmt::Display for SurfaceDiff {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 if !self.is_breaking {
261 return write!(f, "No breaking changes detected.");
262 }
263
264 writeln!(f, "BREAKING CHANGES DETECTED:")?;
265 for name in &self.removed_types {
266 writeln!(f, " REMOVED type: {name}")?;
267 }
268 for name in &self.removed_fns {
269 writeln!(f, " REMOVED fn: {name}")?;
270 }
271 for name in &self.removed_traits {
272 writeln!(f, " REMOVED trait: {name}")?;
273 }
274 for name in &self.removed_modules {
275 writeln!(f, " REMOVED module: {name}")?;
276 }
277 for (old, new) in &self.changed_types {
278 writeln!(f, " CHANGED type {}: {:?} -> {:?}", old.name, old, new)?;
279 }
280 for (old, new) in &self.changed_fns {
281 writeln!(f, " CHANGED fn {}: {:?} -> {:?}", old.name, old, new)?;
282 }
283 writeln!(
284 f,
285 "\nTo update the baseline after intentional API changes, run:\
286 \n cargo run -p oxirs-core --bin api_snapshot --quiet \
287 > core/oxirs-core/api_baseline.json"
288 )
289 }
290}