Skip to main content

lex_store/
api.rs

1//! A package's **public API** at an op, and how it changed between two ops —
2//! the event source for the version-bump gate (#893) and, later, dependency
3//! change-propagation.
4//!
5//! A package's public surface is its top-level functions and types (a Lex
6//! `import "<pkg>/mod"` exposes the module's top-level names). Two versions'
7//! surfaces are compared *structurally*: a function's signature is the JSON of
8//! its `(param types, return type, effects)` — never its body or examples, so
9//! a pure body change is a patch, not an API change. Names carry their
10//! path-derived mangle prefix (`schema_a1b2.validate`), which is stable across
11//! versions, so mangled names compare directly without de-mangling.
12
13use std::collections::BTreeMap;
14
15use crate::store::{Store, StoreError};
16
17/// The public API at a head: declaration name → a structural signature string.
18pub type PublicApi = BTreeMap<String, String>;
19
20/// How a package's public API changed between two releases.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum ApiChange {
23    /// A public declaration was removed, or its signature changed — a
24    /// consumer pinned to the old version can break. Requires a **major** bump.
25    Breaking(String),
26    /// Only additions (new public declarations); existing ones unchanged.
27    /// Requires at least a **minor** bump.
28    Additive(String),
29    /// No public-API change (bodies may still differ). A **patch** suffices.
30    None,
31}
32
33/// Extract the public API at `op_id`: every top-level fn/type declaration
34/// keyed by name, valued by a structural signature that ignores body and
35/// examples.
36pub fn public_api_at_op(store: &Store, op_id: &str) -> Result<PublicApi, StoreError> {
37    let head = crate::render::package_head_at_op(store, op_id)?;
38    let pairs: Vec<(String, String)> =
39        head.map.iter().map(|(s, st)| (s.clone(), st.clone())).collect();
40    let mut api = PublicApi::new();
41    for ast in store.get_asts_for_sigs_bulk(&pairs) {
42        match ast? {
43            lex_ast::Stage::FnDecl(fd) => {
44                // Signature = param types + return type + effects (JSON is a
45                // stable, canonical structural key). Body and examples are
46                // deliberately excluded: they don't affect type compatibility.
47                let param_types: Vec<&lex_ast::TypeExpr> = fd.params.iter().map(|p| &p.ty).collect();
48                let sig = serde_json::to_string(&(&param_types, &fd.return_type, &fd.effects))
49                    .unwrap_or_default();
50                api.insert(fd.name.clone(), format!("fn:{sig}"));
51            }
52            lex_ast::Stage::TypeDecl(td) => {
53                let sig = serde_json::to_string(&td.definition).unwrap_or_default();
54                api.insert(td.name.clone(), format!("type:{sig}"));
55            }
56            lex_ast::Stage::Import(_) => {}
57        }
58    }
59    Ok(api)
60}
61
62/// Bare name for a message (strip the path-derived mangle prefix).
63fn bare(name: &str) -> &str {
64    name.split_once('.').map(|(_, n)| n).unwrap_or(name)
65}
66
67/// Classify the change from `prev` to `new`. Breaking dominates additive:
68/// a release that both removes one name and adds another is Breaking.
69pub fn classify_api_change(prev: &PublicApi, new: &PublicApi) -> ApiChange {
70    for (name, sig) in prev {
71        match new.get(name) {
72            None => return ApiChange::Breaking(format!("`{}` was removed", bare(name))),
73            Some(new_sig) if new_sig != sig => {
74                return ApiChange::Breaking(format!("signature of `{}` changed", bare(name)))
75            }
76            _ => {}
77        }
78    }
79    if let Some(added) = new.keys().find(|k| !prev.contains_key(*k)) {
80        return ApiChange::Additive(format!("`{}` was added", bare(added)));
81    }
82    ApiChange::None
83}
84
85/// A detected rename: a public name that disappeared and reappeared under a
86/// new name with the **same signature** — a drop-in rename that can propagate
87/// mechanically (`nt.gcd` → `nt.euclidean_gcd`). Names are bare (mangle prefix
88/// stripped) so they feed `lex propagate --rename old=new` directly.
89#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
90pub struct Rename {
91    pub old: String,
92    pub new: String,
93}
94
95/// Detect renames between two public APIs: pair each removed declaration with
96/// an added one that has an identical signature. A signature shared by several
97/// removed/added names is ambiguous and left unpaired (a rename can't be
98/// inferred safely), so only unambiguous 1:1 matches are returned.
99pub fn detect_renames(prev: &PublicApi, new: &PublicApi) -> Vec<Rename> {
100    // Candidates: names present on exactly one side.
101    let removed: Vec<(&String, &String)> =
102        prev.iter().filter(|(k, _)| !new.contains_key(*k)).collect();
103    let added: Vec<(&String, &String)> =
104        new.iter().filter(|(k, _)| !prev.contains_key(*k)).collect();
105
106    let mut renames = Vec::new();
107    let mut used_added = std::collections::BTreeSet::new();
108    for (old_name, old_sig) in &removed {
109        // Unambiguous only: exactly one removed and one added with this sig.
110        let removed_same = removed.iter().filter(|(_, s)| s == old_sig).count();
111        let matches: Vec<&(&String, &String)> = added
112            .iter()
113            .filter(|(n, s)| s == old_sig && !used_added.contains(*n))
114            .collect();
115        if removed_same == 1 && matches.len() == 1 {
116            let (new_name, _) = matches[0];
117            used_added.insert((*new_name).clone());
118            renames.push(Rename {
119                old: bare(old_name).to_string(),
120                new: bare(new_name).to_string(),
121            });
122        }
123    }
124    renames
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn api(pairs: &[(&str, &str)]) -> PublicApi {
132        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
133    }
134
135    #[test]
136    fn removal_is_breaking() {
137        let prev = api(&[("foo", "fn:A"), ("bar", "fn:B")]);
138        let new = api(&[("foo", "fn:A")]);
139        assert!(matches!(classify_api_change(&prev, &new), ApiChange::Breaking(_)));
140    }
141
142    #[test]
143    fn signature_change_is_breaking() {
144        let prev = api(&[("foo", "fn:A")]);
145        let new = api(&[("foo", "fn:B")]);
146        assert!(matches!(classify_api_change(&prev, &new), ApiChange::Breaking(_)));
147    }
148
149    #[test]
150    fn pure_addition_is_additive() {
151        let prev = api(&[("foo", "fn:A")]);
152        let new = api(&[("foo", "fn:A"), ("bar", "fn:B")]);
153        assert!(matches!(classify_api_change(&prev, &new), ApiChange::Additive(_)));
154    }
155
156    #[test]
157    fn no_signature_change_is_none() {
158        // Same signatures (a body-only change never reaches this map).
159        let prev = api(&[("foo", "fn:A"), ("bar", "type:T")]);
160        let new = api(&[("foo", "fn:A"), ("bar", "type:T")]);
161        assert_eq!(classify_api_change(&prev, &new), ApiChange::None);
162    }
163
164    #[test]
165    fn removal_plus_addition_is_breaking() {
166        let prev = api(&[("foo", "fn:A")]);
167        let new = api(&[("bar", "fn:B")]);
168        assert!(matches!(classify_api_change(&prev, &new), ApiChange::Breaking(_)));
169    }
170
171    #[test]
172    fn detects_a_same_signature_rename() {
173        // gcd removed, euclidean_gcd added with the identical signature.
174        let prev = api(&[("m_a1.gcd", "fn:SIG"), ("m_a1.other", "fn:X")]);
175        let new = api(&[("m_a1.euclidean_gcd", "fn:SIG"), ("m_a1.other", "fn:X")]);
176        let renames = detect_renames(&prev, &new);
177        assert_eq!(renames, vec![Rename { old: "gcd".into(), new: "euclidean_gcd".into() }]);
178    }
179
180    #[test]
181    fn does_not_infer_rename_when_signature_differs() {
182        // Removed + added but different signatures → not a rename (breaking).
183        let prev = api(&[("m.foo", "fn:A")]);
184        let new = api(&[("m.bar", "fn:B")]);
185        assert!(detect_renames(&prev, &new).is_empty());
186    }
187
188    #[test]
189    fn does_not_infer_rename_when_ambiguous() {
190        // Two removed and two added share one signature → ambiguous, skip both.
191        let prev = api(&[("m.a", "fn:S"), ("m.b", "fn:S")]);
192        let new = api(&[("m.c", "fn:S"), ("m.d", "fn:S")]);
193        assert!(detect_renames(&prev, &new).is_empty());
194    }
195}