1use std::collections::BTreeMap;
14
15use crate::store::{Store, StoreError};
16
17pub type PublicApi = BTreeMap<String, String>;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum ApiChange {
23 Breaking(String),
26 Additive(String),
29 None,
31}
32
33pub 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 let param_types: Vec<&lex_ast::TypeExpr> = fd.params.iter().map(|p| &p.ty).collect();
48 let sig = serde_json::to_string(&(¶m_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
62pub 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 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
91fn bare(name: &str) -> &str {
93 name.split_once('.').map(|(_, n)| n).unwrap_or(name)
94}
95
96pub 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
119pub struct Rename {
120 pub old: String,
121 pub new: String,
122}
123
124pub fn detect_renames(prev: &PublicApi, new: &PublicApi) -> Vec<Rename> {
129 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 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 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 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 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 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}