1use super::{declared_namespace, files_in, Project};
2use sha2::{Digest, Sha256};
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7use std::time::UNIX_EPOCH;
8
9#[path = "resources/installed.rs"]
10mod installed;
11
12#[derive(Debug, Clone, Default)]
19pub struct SourceCatalog {
20 entries: Arc<Mutex<BTreeMap<String, PathBuf>>>,
21 roots: Vec<PathBuf>,
22 excluded_roots: Vec<PathBuf>,
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26struct FileStamp {
27 length: u64,
28 modified_seconds: u64,
29 modified_nanos: u32,
30}
31
32fn file_stamp(path: &Path) -> Option<FileStamp> {
33 let metadata = fs::metadata(path).ok()?;
34 let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
35 Some(FileStamp {
36 length: metadata.len(),
37 modified_seconds: modified.as_secs(),
38 modified_nanos: modified.subsec_nanos(),
39 })
40}
41
42impl SourceCatalog {
43 pub(crate) fn entries(&self) -> BTreeMap<String, PathBuf> {
46 self.discover_legacy_paths();
47 self.entries
48 .lock()
49 .expect("source catalog cache poisoned")
50 .clone()
51 }
52
53 pub fn path(&self, namespace: &str) -> Option<PathBuf> {
57 if let Some(path) = self
58 .entries
59 .lock()
60 .expect("source catalog cache poisoned")
61 .get(namespace)
62 .cloned()
63 {
64 return Some(path);
65 }
66 if let Some(path) = self.conventional_path(namespace) {
67 self.entries
68 .lock()
69 .expect("source catalog cache poisoned")
70 .insert(namespace.to_owned(), path.clone());
71 return Some(path);
72 }
73 self.discover_legacy_paths();
74 self.entries
75 .lock()
76 .expect("source catalog cache poisoned")
77 .get(namespace)
78 .cloned()
79 }
80
81 pub fn namespaces(&self) -> Vec<String> {
82 self.entries().into_keys().collect()
83 }
84
85 pub fn fingerprint(&self) -> Result<[u8; 32], String> {
91 let mut digest = Sha256::new();
92 digest.update(b"hara-source-index-v1\0");
93 for (namespace, path) in self.entries() {
94 let stamp = file_stamp(&path)
95 .ok_or_else(|| format!("cannot stat source file {}", path.display()))?;
96 digest.update(namespace.as_bytes());
97 digest.update([0]);
98 digest.update(path.to_string_lossy().as_bytes());
99 digest.update([0]);
100 digest.update(stamp.length.to_le_bytes());
101 digest.update(stamp.modified_seconds.to_le_bytes());
102 digest.update(stamp.modified_nanos.to_le_bytes());
103 }
104 Ok(digest.finalize().into())
105 }
106
107 pub fn content_fingerprint_prefixes(
111 &self,
112 prefixes: &[&str],
113 ) -> Result<[u8; 32], String> {
114 let mut selected = BTreeSet::new();
115 for (namespace, path) in self.entries() {
116 if prefixes.iter().any(|prefix| {
117 namespace == *prefix
118 || namespace
119 .strip_prefix(prefix)
120 .is_some_and(|suffix| suffix.starts_with('.'))
121 }) {
122 selected.insert((namespace, path));
123 }
124 }
125
126 let mut digest = Sha256::new();
127 digest.update(b"hara-source-content-family-v1\0");
128 for (namespace, path) in selected {
129 let source = fs::read(&path)
130 .map_err(|error| format!("cannot read source file {}: {error}", path.display()))?;
131 digest.update(namespace.as_bytes());
132 digest.update([0]);
133 digest.update(source.len().to_le_bytes());
134 digest.update(source);
135 }
136 Ok(digest.finalize().into())
137 }
138
139 pub fn content_fingerprint_dependencies(
144 &self,
145 roots: &[&str],
146 ) -> Result<[u8; 32], String> {
147 let requested = roots
148 .iter()
149 .map(|namespace| (*namespace).to_owned())
150 .collect::<BTreeSet<_>>();
151 let mut pending = requested.clone();
152 let mut selected = BTreeMap::new();
153
154 while let Some(namespace) = pending.iter().next().cloned() {
155 pending.remove(&namespace);
156 if selected.contains_key(&namespace) {
157 continue;
158 }
159 let Some(path) = self.path(&namespace) else {
160 if requested.contains(&namespace) {
161 return Err(format!("cannot resolve source namespace {namespace}"));
162 }
163 continue;
164 };
165 let source = fs::read(&path)
166 .map_err(|error| format!("cannot read source file {}: {error}", path.display()))?;
167 for dependency in source_namespace_dependencies(&source, &path)? {
168 if !selected.contains_key(&dependency) {
169 pending.insert(dependency);
170 }
171 }
172 selected.insert(namespace, source);
173 }
174
175 let mut digest = Sha256::new();
176 digest.update(b"hara-source-content-closure-v1\0");
177 for (namespace, source) in selected {
178 digest.update(namespace.as_bytes());
179 digest.update([0]);
180 digest.update(source.len().to_le_bytes());
181 digest.update(source);
182 }
183 Ok(digest.finalize().into())
184 }
185
186 pub(crate) fn cached_namespaces(&self) -> Vec<String> {
187 self.entries
188 .lock()
189 .expect("source catalog cache poisoned")
190 .keys()
191 .cloned()
192 .collect()
193 }
194
195 fn add_project(&mut self, project: &Project) -> Result<(), String> {
196 let project_root = project
197 .root
198 .canonicalize()
199 .map_err(|error| format!("cannot resolve {}: {error}", project.root.display()))?;
200 for excluded_root in &project.source_excludes {
201 let excluded_root = project.root.join(excluded_root);
202 if !excluded_root.exists() {
203 continue;
204 }
205 let excluded_root = excluded_root.canonicalize().map_err(|error| {
206 format!(
207 "cannot resolve excluded source root {}: {error}",
208 excluded_root.display()
209 )
210 })?;
211 if !excluded_root.starts_with(&project_root) {
212 return Err(format!(
213 "excluded source root escapes project root: {}",
214 excluded_root.display()
215 ));
216 }
217 self.excluded_roots.push(excluded_root);
218 }
219 for source_root in &project.source_paths {
220 let source_root = project.root.join(source_root);
221 if !source_root.exists() {
222 continue;
223 }
224 let source_root = source_root.canonicalize().map_err(|error| {
225 format!(
226 "cannot resolve source root {}: {error}",
227 source_root.display()
228 )
229 })?;
230 if !source_root.starts_with(&project_root) {
231 return Err(format!(
232 "source root escapes project root: {}",
233 source_root.display()
234 ));
235 }
236 self.roots.push(source_root);
237 }
238 Ok(())
239 }
240
241 fn excluded(&self, path: &Path) -> bool {
242 self.excluded_roots
243 .iter()
244 .any(|excluded_root| path.starts_with(excluded_root))
245 }
246
247 fn conventional_path(&self, namespace: &str) -> Option<PathBuf> {
248 let segments = namespace.split('.').collect::<Vec<_>>();
249 if segments.is_empty()
250 || segments.iter().any(|segment| {
251 segment.is_empty()
252 || *segment == ".."
253 || segment.contains('/')
254 || segment.contains('\\')
255 })
256 {
257 return None;
258 }
259 for root in self.roots.iter().rev() {
260 for underscores in [false, true] {
261 let mut candidate = root.to_path_buf();
262 for segment in &segments[..segments.len().saturating_sub(1)] {
263 candidate.push(if underscores {
264 segment.replace('-', "_")
265 } else {
266 (*segment).to_owned()
267 });
268 }
269 let leaf = segments.last().expect("non-empty segments");
270 candidate.push(format!(
271 "{}.hal",
272 if underscores {
273 leaf.replace('-', "_")
274 } else {
275 (*leaf).to_owned()
276 }
277 ));
278 let Ok(path) = candidate.canonicalize() else {
279 continue;
280 };
281 if path.starts_with(root) && path.is_file() && !self.excluded(&path) {
282 return Some(path);
283 }
284 }
285 }
286 None
287 }
288
289 fn discover_legacy_paths(&self) {
290 let mut discovered = BTreeMap::new();
291 for root in &self.roots {
292 let Ok(paths) = files_in(root, &[PathBuf::from(".")]) else {
293 continue;
294 };
295 for path in paths {
296 let Ok(path) = path.canonicalize() else {
297 continue;
298 };
299 if !path.starts_with(root) || self.excluded(&path) {
300 continue;
301 }
302 let Ok(source) = fs::read_to_string(&path) else {
303 continue;
304 };
305 let Ok(Some(namespace)) = declared_namespace_header(&source) else {
306 continue;
307 };
308 discovered.insert(namespace, path);
311 }
312 }
313 self.entries
314 .lock()
315 .expect("source catalog cache poisoned")
316 .extend(discovered);
317 }
318}
319
320fn source_namespace_dependencies(source: &[u8], path: &Path) -> Result<Vec<String>, String> {
321 let source = std::str::from_utf8(source)
322 .map_err(|error| format!("cannot decode source file {}: {error}", path.display()))?;
323 let forms = crate::kernel::read_forms(source)
324 .map_err(|error| format!("cannot parse source file {}: {error}", path.display()))?;
325 for form in forms {
326 let crate::kernel::Form::List(values) = resource_without_metadata(&form.form) else {
327 continue;
328 };
329 if !matches!(values.first(), Some(crate::kernel::Form::Symbol(head)) if head == "ns" || head == "ns+") {
330 continue;
331 }
332 let config = crate::kernel::GeneratedNamespaceConfig::configure_with(&values[2..], |_| true)
333 .map_err(|error| format!("cannot read namespace dependencies from {}: {error}", path.display()))?;
334 let mut dependencies = config.required_namespaces().to_vec();
335 dependencies.extend(config.used_namespaces().iter().cloned());
336 dependencies.sort();
337 dependencies.dedup();
338 return Ok(dependencies);
339 }
340 Ok(Vec::new())
341}
342
343fn resource_without_metadata(form: &crate::kernel::Form) -> &crate::kernel::Form {
344 match form {
345 crate::kernel::Form::Metadata(_, value) => resource_without_metadata(value),
346 value => value,
347 }
348}
349
350fn declared_namespace_header(source: &str) -> Result<Option<String>, String> {
351 let mut depth = 0;
352 let mut form_start = None;
353 let mut in_comment = false;
354 let mut in_string = false;
355 let mut escaped = false;
356 let mut skip_character = false;
357 for (index, character) in source.char_indices() {
358 if skip_character {
359 skip_character = false;
360 continue;
361 }
362 if in_comment {
363 if character == '\n' {
364 in_comment = false;
365 }
366 continue;
367 }
368 if in_string {
369 if escaped {
370 escaped = false;
371 } else if character == '\\' {
372 escaped = true;
373 } else if character == '"' {
374 in_string = false;
375 }
376 continue;
377 }
378 match character {
379 ';' => in_comment = true,
380 '"' => in_string = true,
381 '\\' => skip_character = true,
382 '(' | '[' | '{' => {
383 if depth == 0 && character == '(' {
384 form_start = Some(index);
385 }
386 depth += 1;
387 }
388 ')' | ']' | '}' if depth > 0 => {
389 depth -= 1;
390 if depth == 0 {
391 if let Some(start) = form_start.take() {
392 let end = index + character.len_utf8();
393 if let Some(namespace) = declared_namespace(&source[start..end])? {
394 return Ok(Some(namespace));
395 }
396 }
397 }
398 }
399 _ => {}
400 }
401 }
402 Ok(None)
403}
404
405pub fn source_catalog(project: &Project) -> Result<SourceCatalog, String> {
408 source_catalog_at(project, &dist_root())
409}
410
411pub fn source_catalog_at(
415 project: &Project,
416 distribution_root: &Path,
417) -> Result<SourceCatalog, String> {
418 source_catalogs_at(&[project], distribution_root)
419}
420
421pub fn source_catalogs(projects: &[&Project]) -> Result<SourceCatalog, String> {
425 source_catalogs_at(projects, &dist_root())
426}
427
428pub(crate) fn source_catalogs_at(
432 projects: &[&Project],
433 distribution_root: &Path,
434) -> Result<SourceCatalog, String> {
435 let mut catalog = SourceCatalog::default();
436 for project in projects {
437 for dependency in installed::resolve(project, distribution_root)? {
438 catalog.add_project(&dependency.project)?;
439 }
440 catalog.add_project(project)?;
441 }
442 Ok(catalog)
443}
444
445pub fn source_resources(project: &Project) -> Result<Vec<(String, String)>, String> {
448 source_resources_at(project, &dist_root())
449}
450
451pub(crate) fn source_resources_at(
452 project: &Project,
453 distribution_root: &Path,
454) -> Result<Vec<(String, String)>, String> {
455 let mut resources = Vec::new();
456 let mut declarations = BTreeMap::<String, (String, PathBuf)>::new();
457 for dependency in installed::resolve(project, distribution_root)? {
458 collect_project(
459 &dependency.project,
460 &format!("{}@{}", dependency.coordinate, dependency.version),
461 &mut declarations,
462 &mut resources,
463 )?;
464 }
465 collect_project(
466 project,
467 &format!("{}@{}", project.id, project.version),
468 &mut declarations,
469 &mut resources,
470 )?;
471 Ok(resources)
472}
473
474fn collect_project(
475 project: &Project,
476 owner: &str,
477 declarations: &mut BTreeMap<String, (String, PathBuf)>,
478 resources: &mut Vec<(String, String)>,
479) -> Result<(), String> {
480 for path in files_in(&project.root, &project.source_paths)? {
481 if project
482 .source_excludes
483 .iter()
484 .any(|excluded_root| path.starts_with(project.root.join(excluded_root)))
485 {
486 continue;
487 }
488 let source = fs::read_to_string(&path)
489 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
490 let namespace = declared_namespace(&source)
491 .map_err(|error| format!("{}: {error}", path.display()))?
492 .ok_or_else(|| format!("{} does not declare an ns or ns+ namespace", path.display()))?;
493 if let Some((previous_owner, previous_path)) =
494 declarations.insert(namespace.clone(), (owner.to_owned(), path.clone()))
495 {
496 return Err(format!(
497 "duplicate namespace {namespace}: {previous_owner} ({}) and {owner} ({})",
498 previous_path.display(),
499 path.display()
500 ));
501 }
502 resources.push((namespace, source));
503 }
504 Ok(())
505}
506
507fn dist_root() -> PathBuf {
508 if let Some(root) = std::env::var_os("HARA_DIST_HOME") {
509 return PathBuf::from(root);
510 }
511 std::env::var_os("HOME")
512 .map(PathBuf::from)
513 .unwrap_or_else(|| PathBuf::from("."))
514 .join(".hara/dist")
515}
516
517#[cfg(test)]
518mod tests {
519 use super::SourceCatalog;
520 use std::fs;
521
522 #[test]
523 fn dependency_fingerprint_excludes_unrelated_source() {
524 let root = std::env::temp_dir().join(format!(
525 "hara-source-catalog-dependency-fingerprint-{}",
526 std::process::id()
527 ));
528 let _ = fs::remove_dir_all(&root);
529 fs::create_dir_all(root.join("fixture")).unwrap();
530 fs::create_dir_all(root.join("unrelated")).unwrap();
531 fs::write(
532 root.join("fixture/entry_point.hal"),
533 "(ns fixture.entry-point (:require [fixture.helper-value :as helper]))\n(def value helper/value)\n",
534 )
535 .unwrap();
536 fs::write(
537 root.join("fixture/helper_value.hal"),
538 "(ns fixture.helper-value)\n(def value 1)\n",
539 )
540 .unwrap();
541 fs::write(root.join("unrelated/value.hal"), "(ns unrelated.value)\n(def value 1)\n")
542 .unwrap();
543 let catalog = SourceCatalog {
544 entries: Default::default(),
545 roots: vec![root.canonicalize().unwrap()],
546 excluded_roots: Vec::new(),
547 };
548
549 let initial = catalog
550 .content_fingerprint_dependencies(&["fixture.entry-point"])
551 .unwrap();
552 fs::write(root.join("unrelated/value.hal"), "(ns unrelated.value)\n(def value 2)\n")
553 .unwrap();
554 assert_eq!(
555 catalog
556 .content_fingerprint_dependencies(&["fixture.entry-point"])
557 .unwrap(),
558 initial
559 );
560 fs::write(
561 root.join("fixture/helper_value.hal"),
562 "(ns fixture.helper-value)\n(def value 2)\n",
563 )
564 .unwrap();
565 assert_ne!(
566 catalog
567 .content_fingerprint_dependencies(&["fixture.entry-point"])
568 .unwrap(),
569 initial
570 );
571
572 fs::remove_dir_all(root).unwrap();
573 }
574}