Skip to main content

hara_native/project/
resources.rs

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/// A source-only namespace catalog used by native runtimes.
13///
14/// The catalog resolves conventional namespace paths at the `require`
15/// boundary. It discovers the complete legacy path map only when a requested
16/// namespace cannot be derived from its name, so starting a project never
17/// walks unrelated source families such as `lang.*`.
18#[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    /// Completes the legacy map for callers that need package-wide metadata.
44    /// Runtime namespace loading should call `path` instead.
45    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    /// Resolves one namespace on demand. Conventional `foo.bar` namespaces
54    /// use `foo/bar.hal` directly; a one-time legacy scan is reserved for
55    /// paths such as `impl_base.hal` declaring `impl-base`.
56    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    /// Returns a stable fingerprint of the indexed source set. The source
86    /// cache keys individual programs by source bytes as well; this broader
87    /// index fingerprint invalidates programs whose compilation depends on a
88    /// changed sibling namespace configuration without rereading every source
89    /// body during ordinary namespace loading.
90    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    /// Returns a content address for source namespaces selected by a family
108    /// prefix.  Artifact owners use this instead of the whole-project index
109    /// when unrelated application source must not invalidate their cache.
110    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    /// Returns a content address for one namespace and its declarative source
140    /// dependency closure. Unlike family fingerprints, this resolves only the
141    /// namespaces that an artifact can load, so a JavaScript Book is not
142    /// invalidated by unrelated Python, Lua, or application source.
143    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                // Project layers are ordered from dependencies to the active
309                // project, so the later mapping is the intentional overlay.
310                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
405/// Builds a path-backed source catalog for one project and its installed Hara
406/// dependencies.
407pub fn source_catalog(project: &Project) -> Result<SourceCatalog, String> {
408    source_catalog_at(project, &dist_root())
409}
410
411/// Builds a source catalog for one project using packages installed beneath an
412/// explicit distribution root. Embedders use this to keep an isolated package
413/// store from falling back to the user's global installation.
414pub fn source_catalog_at(
415    project: &Project,
416    distribution_root: &Path,
417) -> Result<SourceCatalog, String> {
418    source_catalogs_at(&[project], distribution_root)
419}
420
421/// Builds a path-backed source catalog for several ordered project layers.
422/// Each project contributes its verified installed dependencies first,
423/// followed by its own source paths; later project layers take precedence.
424pub fn source_catalogs(projects: &[&Project]) -> Result<SourceCatalog, String> {
425    source_catalogs_at(projects, &dist_root())
426}
427
428/// Builds a multi-project source catalog using one explicit installed-package
429/// store. This keeps dependency resolution deterministic for isolated hosts,
430/// release verification, and native conformance tests.
431pub(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
445/// Returns namespace resources from installed dependencies followed by the
446/// automatically selected native Rust profile of the consuming project.
447pub 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}