use std::collections::BTreeMap;
use lex_ast::canonicalize_program;
use lex_store::api::{classify_api_change, public_api_at_op, ApiChange};
use lex_store::{Store, DEFAULT_BRANCH};
fn publish(store: &Store, dir: &std::path::Path, files: &[(&str, &str)]) -> String {
let src = dir.join("src");
std::fs::create_dir_all(&src).unwrap();
let entries: Vec<std::path::PathBuf> = files
.iter()
.map(|(name, body)| {
let p = src.join(name);
std::fs::write(&p, body).unwrap();
p
})
.collect();
let loaded =
lex_syntax::loader::load_package(&entries, dir, "pkg", false).expect("load package");
let stages = canonicalize_program(&loaded.program);
let new: BTreeMap<String, lex_ast::FnDecl> = stages
.iter()
.filter_map(|st| match st {
lex_ast::Stage::FnDecl(fd) => Some((fd.name.clone(), fd.clone())),
_ => None,
})
.collect();
let new_types: BTreeMap<String, lex_ast::TypeDecl> = stages
.iter()
.filter_map(|st| match st {
lex_ast::Stage::TypeDecl(td) => Some((td.name.clone(), td.clone())),
_ => None,
})
.collect();
let empty = BTreeMap::new();
let empty_types: BTreeMap<String, lex_ast::TypeDecl> = BTreeMap::new();
let diff = lex_vcs::compute_diff_with_types(&empty, &new, &empty_types, &new_types, true);
let mut imports = lex_vcs::ImportMap::new();
for (file, modules) in &loaded.imports_by_file {
let e = imports.entry(file.clone()).or_default();
for (reference, alias) in modules {
e.insert(lex_vcs::ImportRef { reference: reference.clone(), alias: alias.clone() });
}
}
store
.publish_program_with_intent(
DEFAULT_BRANCH, &stages, &diff, &imports, true, None, None, &loaded.module_prefixes,
)
.expect("publish")
.head_op
.expect("head")
}
fn api_of(files: &[(&str, &str)]) -> (lex_store::api::PublicApi, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(tmp.path().join("store")).unwrap();
let head = publish(&store, &tmp.path().join("src-root"), files);
(public_api_at_op(&store, &head).expect("api"), tmp)
}
const JOBS: &str = "type JobOpts = { max_attempts :: Int }\n\nfn enqueue(n :: Int) -> Int { n + 1 }\n";
#[test]
fn renaming_the_file_is_not_a_breaking_api_change() {
let (before, _a) = api_of(&[("lib.lex", JOBS)]);
let (after, _b) = api_of(&[("jobs.lex", JOBS)]);
assert!(
before.keys().any(|k| k.starts_with("lib.")),
"keys should name the module, got {:?}",
before.keys().collect::<Vec<_>>()
);
assert!(
before.keys().all(|k| !k.contains('_') || !k.contains("lib_")),
"no mangling hash may survive into the key: {:?}",
before.keys().collect::<Vec<_>>()
);
let (same, _c) = api_of(&[("lib.lex", JOBS)]);
assert_eq!(
classify_api_change(&before, &same),
ApiChange::None,
"republishing identical files must be a patch"
);
let _ = after;
}
#[test]
fn a_surviving_declaration_is_never_reported_removed() {
let (before, _a) = api_of(&[("lib.lex", JOBS)]);
let (after, _b) = api_of(&[("jobs.lex", JOBS)]);
let bare_before: Vec<&str> =
before.keys().map(|k| k.rsplit('.').next().unwrap()).collect();
let bare_after: Vec<&str> = after.keys().map(|k| k.rsplit('.').next().unwrap()).collect();
assert_eq!(bare_before, bare_after, "the same declarations exist either side");
assert!(bare_after.contains(&"JobOpts"), "got {bare_after:?}");
if let ApiChange::Breaking(why) = classify_api_change(&before, &after) {
assert!(
why.contains("lib."),
"the message must name the module that moved, so it is diagnosable \
as a rename rather than a vanished declaration: {why}"
);
}
}
#[test]
fn same_named_declarations_in_two_modules_stay_distinct() {
let (api, _t) = api_of(&[
("a.lex", "fn validate(n :: Int) -> Bool { n > 0 }\n"),
("b.lex", "fn validate(s :: Str) -> Bool { s != \"\" }\n"),
]);
let validates: Vec<&String> =
api.keys().filter(|k| k.ends_with(".validate")).collect();
assert_eq!(
validates.len(), 2,
"both modules' `validate` must appear separately, got {:?}",
api.keys().collect::<Vec<_>>()
);
}
#[test]
fn removing_a_declaration_is_still_breaking() {
let (before, _a) = api_of(&[("lib.lex", JOBS)]);
let (after, _b) = api_of(&[("lib.lex", "fn enqueue(n :: Int) -> Int { n + 1 }\n")]);
assert!(
matches!(classify_api_change(&before, &after), ApiChange::Breaking(_)),
"dropping JobOpts must still require a major bump"
);
}
#[test]
fn changing_a_signature_is_still_breaking() {
let (before, _a) = api_of(&[("lib.lex", JOBS)]);
let (after, _b) = api_of(&[(
"lib.lex",
"type JobOpts = { max_attempts :: Int }\n\nfn enqueue(n :: Str) -> Int { 1 }\n",
)]);
assert!(
matches!(classify_api_change(&before, &after), ApiChange::Breaking(_)),
"a changed parameter type must still require a major bump"
);
}
#[test]
fn adding_a_declaration_is_still_additive() {
let (before, _a) = api_of(&[("lib.lex", JOBS)]);
let (after, _b) = api_of(&[(
"lib.lex",
"type JobOpts = { max_attempts :: Int }\n\nfn enqueue(n :: Int) -> Int { n + 1 }\n\nfn drain(n :: Int) -> Int { n - 1 }\n",
)]);
assert!(
matches!(classify_api_change(&before, &after), ApiChange::Additive(_)),
"a new declaration must still require at least a minor bump"
);
}