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/// The external **package** dependencies visible in a head's imports: the
63/// first path segment of every import reference that is neither a stdlib
64/// import (`std.*`) nor a local/relative one (`./`, `../`, `/`). Note that a
65/// package published with its deps *installed* has them inlined into a
66/// self-contained op-log, so this is often empty — the release request's
67/// declared dependencies (from `lex.toml`) are the authoritative source, and
68/// this catches only deps left as unresolved imports. Deduped, sorted.
69pub fn external_dependencies_at_op(store: &Store, op_id: &str) -> Result<Vec<String>, StoreError> {
70    let head = crate::render::package_head_at_op(store, op_id)?;
71    let mut deps = std::collections::BTreeSet::new();
72    for imports in head.file_imports.values() {
73        for module in imports.keys() {
74            if module.starts_with("std.")
75                || module.starts_with("./")
76                || module.starts_with("../")
77                || module.starts_with('/')
78            {
79                continue;
80            }
81            // `lex-nt/lib` → `lex-nt`; a bare `lex-nt` → `lex-nt`.
82            let pkg = module.split('/').next().unwrap_or(module);
83            if !pkg.is_empty() {
84                deps.insert(pkg.to_string());
85            }
86        }
87    }
88    Ok(deps.into_iter().collect())
89}
90
91/// Bare name for a message (strip the path-derived mangle prefix).
92fn bare(name: &str) -> &str {
93    name.split_once('.').map(|(_, n)| n).unwrap_or(name)
94}
95
96/// Classify the change from `prev` to `new`. Breaking dominates additive:
97/// a release that both removes one name and adds another is Breaking.
98pub fn classify_api_change(prev: &PublicApi, new: &PublicApi) -> ApiChange {
99    for (name, sig) in prev {
100        match new.get(name) {
101            None => return ApiChange::Breaking(format!("`{}` was removed", bare(name))),
102            Some(new_sig) if new_sig != sig => {
103                return ApiChange::Breaking(format!("signature of `{}` changed", bare(name)))
104            }
105            _ => {}
106        }
107    }
108    if let Some(added) = new.keys().find(|k| !prev.contains_key(*k)) {
109        return ApiChange::Additive(format!("`{}` was added", bare(added)));
110    }
111    ApiChange::None
112}
113
114/// A detected rename: a public name that disappeared and reappeared under a
115/// new name with the **same signature** — a drop-in rename that can propagate
116/// mechanically (`nt.gcd` → `nt.euclidean_gcd`). Names are bare (mangle prefix
117/// stripped) so they feed `lex propagate --rename old=new` directly.
118#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
119pub struct Rename {
120    pub old: String,
121    pub new: String,
122}
123
124/// Detect renames between two public APIs: pair each removed declaration with
125/// an added one that has an identical signature. A signature shared by several
126/// removed/added names is ambiguous and left unpaired (a rename can't be
127/// inferred safely), so only unambiguous 1:1 matches are returned.
128pub fn detect_renames(prev: &PublicApi, new: &PublicApi) -> Vec<Rename> {
129    // Candidates: names present on exactly one side.
130    let removed: Vec<(&String, &String)> =
131        prev.iter().filter(|(k, _)| !new.contains_key(*k)).collect();
132    let added: Vec<(&String, &String)> =
133        new.iter().filter(|(k, _)| !prev.contains_key(*k)).collect();
134
135    let mut renames = Vec::new();
136    let mut used_added = std::collections::BTreeSet::new();
137    for (old_name, old_sig) in &removed {
138        // Unambiguous only: exactly one removed and one added with this sig.
139        let removed_same = removed.iter().filter(|(_, s)| s == old_sig).count();
140        let matches: Vec<&(&String, &String)> = added
141            .iter()
142            .filter(|(n, s)| s == old_sig && !used_added.contains(*n))
143            .collect();
144        if removed_same == 1 && matches.len() == 1 {
145            let (new_name, _) = matches[0];
146            used_added.insert((*new_name).clone());
147            renames.push(Rename {
148                old: bare(old_name).to_string(),
149                new: bare(new_name).to_string(),
150            });
151        }
152    }
153    renames
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn api(pairs: &[(&str, &str)]) -> PublicApi {
161        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
162    }
163
164    #[test]
165    fn removal_is_breaking() {
166        let prev = api(&[("foo", "fn:A"), ("bar", "fn:B")]);
167        let new = api(&[("foo", "fn:A")]);
168        assert!(matches!(classify_api_change(&prev, &new), ApiChange::Breaking(_)));
169    }
170
171    #[test]
172    fn signature_change_is_breaking() {
173        let prev = api(&[("foo", "fn:A")]);
174        let new = api(&[("foo", "fn:B")]);
175        assert!(matches!(classify_api_change(&prev, &new), ApiChange::Breaking(_)));
176    }
177
178    #[test]
179    fn pure_addition_is_additive() {
180        let prev = api(&[("foo", "fn:A")]);
181        let new = api(&[("foo", "fn:A"), ("bar", "fn:B")]);
182        assert!(matches!(classify_api_change(&prev, &new), ApiChange::Additive(_)));
183    }
184
185    #[test]
186    fn no_signature_change_is_none() {
187        // Same signatures (a body-only change never reaches this map).
188        let prev = api(&[("foo", "fn:A"), ("bar", "type:T")]);
189        let new = api(&[("foo", "fn:A"), ("bar", "type:T")]);
190        assert_eq!(classify_api_change(&prev, &new), ApiChange::None);
191    }
192
193    #[test]
194    fn removal_plus_addition_is_breaking() {
195        let prev = api(&[("foo", "fn:A")]);
196        let new = api(&[("bar", "fn:B")]);
197        assert!(matches!(classify_api_change(&prev, &new), ApiChange::Breaking(_)));
198    }
199
200    #[test]
201    fn detects_a_same_signature_rename() {
202        // gcd removed, euclidean_gcd added with the identical signature.
203        let prev = api(&[("m_a1.gcd", "fn:SIG"), ("m_a1.other", "fn:X")]);
204        let new = api(&[("m_a1.euclidean_gcd", "fn:SIG"), ("m_a1.other", "fn:X")]);
205        let renames = detect_renames(&prev, &new);
206        assert_eq!(renames, vec![Rename { old: "gcd".into(), new: "euclidean_gcd".into() }]);
207    }
208
209    #[test]
210    fn does_not_infer_rename_when_signature_differs() {
211        // Removed + added but different signatures → not a rename (breaking).
212        let prev = api(&[("m.foo", "fn:A")]);
213        let new = api(&[("m.bar", "fn:B")]);
214        assert!(detect_renames(&prev, &new).is_empty());
215    }
216
217    #[test]
218    fn does_not_infer_rename_when_ambiguous() {
219        // Two removed and two added share one signature → ambiguous, skip both.
220        let prev = api(&[("m.a", "fn:S"), ("m.b", "fn:S")]);
221        let new = api(&[("m.c", "fn:S"), ("m.d", "fn:S")]);
222        assert!(detect_renames(&prev, &new).is_empty());
223    }
224}