Skip to main content

oxirs_core/
api_surface.rs

1//! API surface tracking for oxirs-core.
2//!
3//! Parses the public API surface from `lib.rs` using `syn`, compares against a
4//! committed JSON baseline, and reports breaking changes.  Only top-level `pub`
5//! items (not `pub(crate)`) and non-`#[doc(hidden)]` items are included.
6//!
7//! # Workflow
8//!
9//! 1. **Generate baseline** (after an intentional API change):
10//!    ```text
11//!    cargo run -p oxirs-core --bin api_snapshot --quiet > core/oxirs-core/api_baseline.json
12//!    ```
13//! 2. **CI guard**: `tests/api_stability.rs` loads the baseline and asserts the
14//!    current surface matches it — any removed or changed item fails the test.
15
16use std::path::Path;
17
18use quote::{quote, ToTokens};
19use serde::{Deserialize, Serialize};
20use syn::{File, Item, Visibility};
21
22// ─── Data types ──────────────────────────────────────────────────────────────
23
24/// A public struct, enum, or type alias in the API surface.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct TypeSig {
27    /// Item name.
28    pub name: String,
29    /// One of `"struct"`, `"enum"`, or `"type"`.
30    pub kind: String,
31    /// Stringified generics clause, e.g. `"< T : Clone >"`.
32    pub generics: String,
33}
34
35/// A public free function in the API surface.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct FnSig {
38    /// Function name.
39    pub name: String,
40    /// Stringified function signature (no body), e.g. `"fn foo (x : u32) -> bool"`.
41    pub signature: String,
42}
43
44/// A public trait in the API surface.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct TraitSig {
47    /// Trait name.
48    pub name: String,
49    /// Stringified generics clause.
50    pub generics: String,
51    /// Stringified super-trait bounds.
52    pub supertraits: String,
53}
54
55/// The collected public API surface of a single Rust source file.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
57pub struct ApiSurface {
58    /// Public types (structs, enums, type aliases).
59    pub types: Vec<TypeSig>,
60    /// Public free functions.
61    pub fns: Vec<FnSig>,
62    /// Public traits.
63    pub traits: Vec<TraitSig>,
64    /// Public modules (`pub mod`).
65    pub modules: Vec<String>,
66    /// Public re-exports (`pub use`).
67    pub uses: Vec<String>,
68    /// Public constants and statics.
69    pub constants: Vec<String>,
70}
71
72// ─── Error type ──────────────────────────────────────────────────────────────
73
74/// Errors that can arise during API surface operations.
75#[derive(Debug, thiserror::Error)]
76pub enum ApiSurfaceError {
77    /// File I/O error.
78    #[error("failed to read source file: {0}")]
79    Io(#[from] std::io::Error),
80
81    /// `syn` parse failure.
82    #[error("failed to parse Rust source: {0}")]
83    Parse(String),
84
85    /// Baseline JSON file missing.
86    #[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    /// Baseline JSON malformed.
93    #[error("failed to parse baseline JSON: {0}")]
94    BaselineJson(String),
95}
96
97// ─── Parsing ─────────────────────────────────────────────────────────────────
98
99/// Parse the public API surface from a Rust source file.
100///
101/// Only top-level items with `pub` (not `pub(crate)`, `pub(super)`, etc.)
102/// visibility are included.  Items annotated with `#[doc(hidden)]` are
103/// excluded.
104pub 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
112/// Returns `true` if the visibility is exactly `pub` (not `pub(crate)` etc.).
113fn is_public(vis: &Visibility) -> bool {
114    matches!(vis, Visibility::Public(_))
115}
116
117/// Returns `true` if any attribute is `#[doc(hidden)]`.
118fn is_doc_hidden(attrs: &[syn::Attribute]) -> bool {
119    attrs.iter().any(|a| {
120        // Stringify the whole attribute token stream and look for "hidden".
121        // This correctly handles `#[doc(hidden)]` regardless of spacing.
122        a.path().is_ident("doc") && a.to_token_stream().to_string().contains("hidden")
123    })
124}
125
126/// Collect public, non-hidden top-level items into `surface`.
127fn 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
188// ─── Diffing ─────────────────────────────────────────────────────────────────
189
190/// Compute the diff between a `baseline` surface and the `current` surface.
191///
192/// Breaking changes are: removed items, changed signatures, relocated types.
193/// Non-breaking changes are: added items (allowed).
194pub fn diff_surfaces(baseline: &ApiSurface, current: &ApiSurface) -> SurfaceDiff {
195    let mut diff = SurfaceDiff::default();
196
197    // Check for removed or changed types.
198    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    // Check for removed or changed functions.
207    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    // Check for removed traits.
216    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    // Check for removed modules.
223    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/// The result of comparing two API surfaces.
240#[derive(Debug, Default)]
241pub struct SurfaceDiff {
242    /// Names of types that were present in the baseline but are now missing.
243    pub removed_types: Vec<String>,
244    /// Types whose signature changed between baseline and current.
245    pub changed_types: Vec<(TypeSig, TypeSig)>,
246    /// Names of functions that were removed.
247    pub removed_fns: Vec<String>,
248    /// Functions whose signature changed.
249    pub changed_fns: Vec<(FnSig, FnSig)>,
250    /// Names of traits that were removed.
251    pub removed_traits: Vec<String>,
252    /// Names of modules that were removed.
253    pub removed_modules: Vec<String>,
254    /// `true` if any breaking change was detected.
255    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}