1use std::path::Path;
25use std::sync::Arc;
26
27use crate::env::env::{Env, GlobalEnv};
28use crate::env::error::{EvalError, EvalResult};
29use cljrs_value::Value;
30
31pub fn base_ns_name(ns: &str) -> &str {
35 cljrs_value::symbol::split_version(ns).0
36}
37
38pub fn resolve_versioned_value(
52 globals: &Arc<GlobalEnv>,
53 defining_ns: &str,
54 ns_part: Option<&str>,
55 name: &str,
56 commit: &str,
57) -> EvalResult {
58 let base_ns: Arc<str> = match ns_part {
62 Some(p) => {
63 let resolved = globals
64 .resolve_alias(defining_ns, p)
65 .unwrap_or_else(|| Arc::from(p));
66 Arc::from(base_ns_name(&resolved))
67 }
68 None => Arc::from(base_ns_name(defining_ns)),
69 };
70 let versioned_ns: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));
71
72 if let Some(val) = globals.lookup_in_ns(&versioned_ns, name) {
76 return Ok(val);
77 }
78
79 if let Some(cached) = globals.get_cached_versioned(&base_ns, name, commit) {
81 return Ok(cached);
82 }
83
84 if !globals.is_loaded(&versioned_ns) {
89 if !versioned_source_available(globals, &base_ns, &versioned_ns) {
90 return pinned_native_or_head_fallback(globals, &base_ns, &versioned_ns, name, commit)
94 .map_err(|e| {
95 if globals.versioned_offline() {
96 EvalError::Runtime(format!(
97 "versioned namespace {versioned_ns} was not embedded at compile \
98 time; AOT binaries cannot fetch from git at runtime ({e})"
99 ))
100 } else {
101 e
102 }
103 });
104 }
105 ensure_versioned_ns_loaded(globals, &base_ns, commit)?;
106 }
107
108 if let Some(val) = globals.lookup_in_ns(&versioned_ns, name) {
109 return Ok(val);
110 }
111
112 pinned_native_or_head_fallback(globals, &base_ns, &versioned_ns, name, commit)
115}
116
117fn pinned_native_or_head_fallback(
126 globals: &Arc<GlobalEnv>,
127 base_ns: &str,
128 versioned_ns: &str,
129 name: &str,
130 commit: &str,
131) -> EvalResult {
132 let loader = globals.pinned_native_loader.read().unwrap().clone();
133 if let Some(loader) = loader
134 && loader(globals, base_ns, commit)?
135 {
136 return globals
137 .lookup_in_ns(versioned_ns, name)
138 .ok_or_else(|| EvalError::UnboundSymbol(format!("{versioned_ns}/{name}")));
139 }
140 native_head_fallback(globals, base_ns, name, commit)
141}
142
143pub fn pin_if_available(globals: &Arc<GlobalEnv>, base_ns: &str, commit: &str) -> EvalResult<bool> {
154 let versioned_ns = format!("{base_ns}@{commit}");
155 if !versioned_source_available(globals, base_ns, &versioned_ns) {
156 return Ok(false);
157 }
158 ensure_versioned_ns_loaded(globals, base_ns, commit)?;
159 Ok(true)
160}
161
162fn versioned_source_available(globals: &GlobalEnv, base_ns: &str, versioned_ns: &str) -> bool {
166 if globals.builtin_source(versioned_ns).is_some() {
167 return true;
168 }
169 if globals.versioned_offline() {
171 return false;
172 }
173 let rel_path = base_ns.replace('.', "/").replace('-', "_");
174 let src_paths = globals.source_paths.read().unwrap().clone();
175 match crate::env::loader::find_source_file(&rel_path, &src_paths) {
176 Some((_, file_path)) => globals
177 .vcs()
178 .and_then(|vcs| vcs.find_repo_root(Path::new(&file_path)))
179 .is_some(),
180 None => false,
181 }
182}
183
184pub fn ensure_versioned_ns_loaded(
193 globals: &Arc<GlobalEnv>,
194 base_ns: &str,
195 commit: &str,
196) -> EvalResult<Arc<str>> {
197 let versioned_ns_name: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));
198
199 if globals.is_loaded(&versioned_ns_name) {
200 return Ok(versioned_ns_name);
201 }
202
203 let should_load = crate::env::loader::claim_or_wait(globals, &versioned_ns_name)?;
204 if !should_load {
205 return Ok(versioned_ns_name);
206 }
207
208 let result = do_versioned_load(globals, base_ns, commit, &versioned_ns_name);
209
210 globals
211 .loading
212 .lock()
213 .unwrap()
214 .remove(versioned_ns_name.as_ref());
215 if result.is_ok() {
216 globals.mark_loaded(&versioned_ns_name);
217 }
218 globals.loading_done.notify_all();
219
220 result?;
221 Ok(versioned_ns_name)
222}
223
224fn do_versioned_load(
227 globals: &Arc<GlobalEnv>,
228 base_ns: &str,
229 commit: &str,
230 versioned_ns_name: &Arc<str>,
231) -> EvalResult<()> {
232 let (src, git_location): (String, Option<(String, String)>) =
237 if let Some(builtin) = globals.builtin_source(versioned_ns_name) {
238 (builtin.to_owned(), None)
239 } else if globals.versioned_offline() {
240 return Err(EvalError::Runtime(format!(
241 "versioned namespace {versioned_ns_name} was not embedded at compile time; \
242 AOT binaries cannot fetch from git at runtime"
243 )));
244 } else {
245 let (src, location) = fetch_versioned_source(globals, base_ns, commit)?;
246 (src, Some(location))
247 };
248
249 {
251 use cljrs_value::Namespace;
252 let ns = cljrs_gc::GcPtr::new(Namespace::new_versioned(versioned_ns_name.as_ref()));
253 if let Some((ref file_path, ref repo_root)) = git_location {
254 ns.get().set_source_location(file_path, Some(repo_root));
255 }
256 let mut map = globals.namespaces.write().unwrap();
257 map.entry(versioned_ns_name.clone()).or_insert(ns);
258 }
259
260 globals.refer_all(versioned_ns_name, "clojure.core");
262
263 let saved_ns = globals
267 .lookup_var("clojure.core", "*ns*")
268 .and_then(|v| crate::env::dynamics::deref_var(&v));
269 {
270 let mut env = Env::new_versioned(globals.clone(), versioned_ns_name, commit);
271 let file_label = format!("<{base_ns}@{commit}>");
272 let mut parser = cljrs_reader::Parser::new(src, file_label);
273 let forms = parser.parse_all().map_err(EvalError::Read)?;
274 for form in forms {
275 let _alloc_frame = cljrs_gc::push_alloc_frame();
276 globals
277 .eval(&form, &mut env)
278 .map_err(|e| crate::env::loader::annotate(e, versioned_ns_name))?;
279 }
280 }
281 if let Some(saved) = saved_ns
282 && let Some(var) = globals.lookup_var("clojure.core", "*ns*")
283 {
284 var.get().bind(saved);
285 }
286
287 Ok(())
288}
289
290fn fetch_versioned_source(
294 globals: &Arc<GlobalEnv>,
295 base_ns: &str,
296 commit: &str,
297) -> EvalResult<(String, (String, String))> {
298 let rel_path = base_ns.replace('.', "/").replace('-', "_");
300 let src_paths = globals.source_paths.read().unwrap().clone();
301 let (_, file_path) =
302 crate::env::loader::find_source_file(&rel_path, &src_paths).ok_or_else(|| {
303 EvalError::Runtime(format!(
304 "Cannot find source for namespace {base_ns} (needed for {base_ns}@{commit})"
305 ))
306 })?;
307
308 let not_in_repo = || {
312 EvalError::Runtime(format!(
313 "Namespace {base_ns} (file {file_path}) is not in a git repository; \
314 cannot resolve {base_ns}@{commit}"
315 ))
316 };
317 let vcs = globals.vcs().ok_or_else(not_in_repo)?;
318 let repo_root = vcs
319 .find_repo_root(Path::new(&file_path))
320 .ok_or_else(not_in_repo)?;
321
322 globals.check_commit_signature(&repo_root.to_string_lossy(), commit)?;
324
325 let abs_file = Path::new(&file_path);
327 let rel_file = abs_file.strip_prefix(&repo_root).map_err(|_| {
328 EvalError::Runtime(format!(
329 "Cannot compute relative path for {file_path} within {}",
330 repo_root.display()
331 ))
332 })?;
333 let rel_file_str = rel_file.to_string_lossy();
334
335 let src = vcs
337 .file_at_commit(&repo_root, &rel_file_str, commit)
338 .map_err(EvalError::Runtime)?;
339
340 globals.record_versioned_source(&format!("{base_ns}@{commit}"), &src);
342
343 Ok((src, (file_path, repo_root.display().to_string())))
344}
345
346fn native_head_fallback(
356 globals: &GlobalEnv,
357 base_ns: &str,
358 name: &str,
359 commit: &str,
360) -> EvalResult {
361 match globals.lookup_in_ns(base_ns, name) {
362 Some(val) if matches!(val, Value::NativeFunction(_)) => {
363 check_native_provenance(globals, base_ns, commit)?;
364 globals.cache_versioned(base_ns, name, commit, val.clone());
365 Ok(val)
366 }
367 Some(_) => Err(EvalError::Runtime(format!(
368 "Cannot find definition of `{name}` in `{base_ns}@{commit}`"
369 ))),
370 None => Err(EvalError::UnboundSymbol(format!("{base_ns}/{name}"))),
371 }
372}
373
374fn check_native_provenance(globals: &GlobalEnv, base_ns: &str, commit: &str) -> EvalResult<()> {
384 let recorded = globals.native_provenance_for(base_ns);
385 if let Some(ref rec) = recorded {
386 let matches = rec.starts_with(commit) || commit.starts_with(rec.as_ref());
387 if matches {
388 return Ok(());
389 }
390 }
391
392 let described = match &recorded {
393 Some(rec) => format!("is built from commit {rec}"),
394 None => "has no recorded provenance".to_string(),
395 };
396 if globals.enforce_native_versions() {
397 return Err(EvalError::Runtime(format!(
398 "native package `{base_ns}` {described}; cannot satisfy pinned \
399 `{base_ns}@{commit}` (native functions always come from the current binary)"
400 )));
401 }
402
403 let warn_key: Arc<str> = Arc::from(format!("{base_ns}@{commit}"));
404 if globals.provenance_warned.lock().unwrap().insert(warn_key) {
405 eprintln!(
406 "cljrs: warning: native package `{base_ns}` {described}; pinned \
407 `{base_ns}@{commit}` resolves to the current binary's implementation \
408 (use --enforce-native-versions to make this an error)"
409 );
410 }
411 Ok(())
412}
413
414#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
424 fn offline_load_without_embedded_source_errors() {
425 let _mutator = cljrs_gc::register_mutator();
426 let globals = GlobalEnv::new(crate::ExecutionMode::TreeWalk);
427 globals.set_versioned_offline(true);
428
429 let err = ensure_versioned_ns_loaded(&globals, "mylib", "abc1234abcdef")
430 .expect_err("offline load must fail without an embedded source");
431 let msg = format!("{err:?}");
432 assert!(
433 msg.contains("was not embedded at compile time"),
434 "unexpected error: {msg}"
435 );
436 }
437
438 #[test]
440 fn offline_load_with_embedded_source_succeeds() {
441 let _mutator = cljrs_gc::register_mutator();
442 let globals = GlobalEnv::new(crate::ExecutionMode::TreeWalk);
443 globals.set_versioned_offline(true);
444 globals.register_builtin_source("mylib@abc1234abcdef", "(def x 1)");
445
446 let ns = ensure_versioned_ns_loaded(&globals, "mylib", "abc1234abcdef")
447 .expect("embedded source must load offline");
448 assert_eq!(ns.as_ref(), "mylib@abc1234abcdef");
449 assert!(globals.is_loaded("mylib@abc1234abcdef"));
450 }
451}