Skip to main content

hara_native/project/
resources.rs

1use super::{declared_namespace, files_in, Project};
2use sha2::{Digest, Sha256};
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::UNIX_EPOCH;
7
8#[path = "resources/installed.rs"]
9mod installed;
10
11/// A source-only namespace catalog used by native runtimes.
12///
13/// The catalog deliberately stores paths rather than source text. Project
14/// startup scans each file for its top-level namespace declaration (some
15/// legacy library paths do not mirror their namespace); source is retained and
16/// fully parsed/evaluated only when a namespace is actually required.
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
18pub struct SourceCatalog {
19    entries: BTreeMap<String, PathBuf>,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23struct FileStamp {
24    length: u64,
25    modified_seconds: u64,
26    modified_nanos: u32,
27}
28
29#[derive(Clone, Debug)]
30struct IndexedSource {
31    stamp: FileStamp,
32    namespace: String,
33}
34
35const SOURCE_INDEX_HEADER: &str = "hara-source-index-v1";
36
37fn source_index_path(project_root: &Path) -> Option<PathBuf> {
38    let installed = project_root
39        .parent()
40        .and_then(Path::parent)
41        .is_some_and(|parent| parent.file_name().is_some_and(|name| name == "roots"));
42    (!installed).then(|| project_root.join("target/hara/source-catalog-v1.index"))
43}
44
45fn file_stamp(path: &Path) -> Option<FileStamp> {
46    let metadata = fs::metadata(path).ok()?;
47    let modified = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
48    Some(FileStamp {
49        length: metadata.len(),
50        modified_seconds: modified.as_secs(),
51        modified_nanos: modified.subsec_nanos(),
52    })
53}
54
55fn load_source_index(project_root: &Path) -> BTreeMap<String, IndexedSource> {
56    let Some(path) = source_index_path(project_root) else {
57        return BTreeMap::new();
58    };
59    let Ok(source) = fs::read_to_string(path) else {
60        return BTreeMap::new();
61    };
62    let mut lines = source.lines();
63    if lines.next() != Some(SOURCE_INDEX_HEADER) {
64        return BTreeMap::new();
65    }
66    let mut entries = BTreeMap::new();
67    for line in lines {
68        let mut fields = line.splitn(5, '\t');
69        let Some(path) = fields
70            .next()
71            .and_then(|value| serde_json::from_str::<String>(value).ok())
72        else {
73            return BTreeMap::new();
74        };
75        let Some(length) = fields.next().and_then(|value| value.parse().ok()) else {
76            return BTreeMap::new();
77        };
78        let Some(modified_seconds) = fields.next().and_then(|value| value.parse().ok()) else {
79            return BTreeMap::new();
80        };
81        let Some(modified_nanos) = fields.next().and_then(|value| value.parse().ok()) else {
82            return BTreeMap::new();
83        };
84        let Some(namespace) = fields
85            .next()
86            .and_then(|value| serde_json::from_str(value).ok())
87        else {
88            return BTreeMap::new();
89        };
90        entries.insert(
91            path,
92            IndexedSource {
93                stamp: FileStamp {
94                    length,
95                    modified_seconds,
96                    modified_nanos,
97                },
98                namespace,
99            },
100        );
101    }
102    entries
103}
104
105fn write_source_index(project_root: &Path, entries: &BTreeMap<String, IndexedSource>) {
106    let Some(path) = source_index_path(project_root) else {
107        return;
108    };
109    let mut output = String::from(SOURCE_INDEX_HEADER);
110    output.push('\n');
111    for (path, entry) in entries {
112        let Ok(path) = serde_json::to_string(path) else {
113            return;
114        };
115        let Ok(namespace) = serde_json::to_string(&entry.namespace) else {
116            return;
117        };
118        output.push_str(&format!(
119            "{path}\t{}\t{}\t{}\t{namespace}\n",
120            entry.stamp.length, entry.stamp.modified_seconds, entry.stamp.modified_nanos,
121        ));
122    }
123    if fs::create_dir_all(path.parent().expect("source index has a parent")).is_ok() {
124        let _ = fs::write(path, output);
125    }
126}
127
128impl SourceCatalog {
129    pub(crate) fn entries(&self) -> &BTreeMap<String, PathBuf> {
130        &self.entries
131    }
132
133    pub fn path(&self, namespace: &str) -> Option<&Path> {
134        self.entries.get(namespace).map(PathBuf::as_path)
135    }
136
137    pub fn namespaces(&self) -> impl Iterator<Item = &str> {
138        self.entries.keys().map(String::as_str)
139    }
140
141    /// Returns a stable fingerprint of the indexed source set. The source
142    /// cache keys individual programs by source bytes as well; this broader
143    /// index fingerprint invalidates programs whose compilation depends on a
144    /// changed sibling namespace configuration without rereading every source
145    /// body during startup.
146    pub fn fingerprint(&self) -> Result<[u8; 32], String> {
147        let mut digest = Sha256::new();
148        digest.update(b"hara-source-index-v1\0");
149        for (namespace, path) in &self.entries {
150            let stamp = file_stamp(path)
151                .ok_or_else(|| format!("cannot stat source file {}", path.display()))?;
152            digest.update(namespace.as_bytes());
153            digest.update([0]);
154            digest.update(path.to_string_lossy().as_bytes());
155            digest.update([0]);
156            digest.update(stamp.length.to_le_bytes());
157            digest.update(stamp.modified_seconds.to_le_bytes());
158            digest.update(stamp.modified_nanos.to_le_bytes());
159        }
160        Ok(digest.finalize().into())
161    }
162
163    fn add_project(&mut self, project: &Project, owner: &str) -> Result<(), String> {
164        let project_root = project
165            .root
166            .canonicalize()
167            .map_err(|error| format!("cannot resolve {}: {error}", project.root.display()))?;
168        let mut files = Vec::new();
169        for source_root in &project.source_paths {
170            let source_root = project.root.join(source_root);
171            if !source_root.exists() {
172                continue;
173            }
174            let source_root = source_root.canonicalize().map_err(|error| {
175                format!(
176                    "cannot resolve source root {}: {error}",
177                    source_root.display()
178                )
179            })?;
180            if !source_root.starts_with(&project_root) {
181                return Err(format!(
182                    "source root escapes project root: {}",
183                    source_root.display()
184                ));
185            }
186            files.extend(files_in(
187                &project.root,
188                &[source_root_for_project(&project_root, &source_root)?],
189            )?);
190        }
191        files.sort();
192        let cached = load_source_index(&project_root);
193        let mut refreshed = BTreeMap::new();
194        let mut owned = BTreeMap::<String, PathBuf>::new();
195        for path in files {
196            let path = path
197                .canonicalize()
198                .map_err(|error| format!("cannot resolve {}: {error}", path.display()))?;
199            if !path.starts_with(&project_root) {
200                return Err(format!(
201                    "source file escapes project root: {}",
202                    path.display()
203                ));
204            }
205            let stamp = file_stamp(&path)
206                .ok_or_else(|| format!("cannot stat source file {}", path.display()))?;
207            let key = path.to_string_lossy().into_owned();
208            let namespace =
209                if let Some(entry) = cached.get(&key).filter(|entry| entry.stamp == stamp) {
210                    entry.namespace.clone()
211                } else {
212                    let source = fs::read_to_string(&path)
213                        .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
214                    declared_namespace_header(&source)
215                        .map_err(|error| format!("{}: {error}", path.display()))?
216                        .ok_or_else(|| {
217                            format!("{} does not declare an ns or ns+ namespace", path.display())
218                        })?
219                };
220            if let Some(previous) = owned.insert(namespace.clone(), path.clone()) {
221                if previous != path {
222                    return Err(format!(
223                        "duplicate namespace {namespace} in {owner}: {} and {}",
224                        previous.display(),
225                        path.display()
226                    ));
227                }
228            }
229            refreshed.insert(key, IndexedSource { stamp, namespace });
230        }
231        write_source_index(&project_root, &refreshed);
232        // Later project layers intentionally overlay earlier layers.  This
233        // preserves the existing lite-project-then-application ordering while
234        // keeping duplicate files within one project an error.
235        self.entries.extend(owned);
236        Ok(())
237    }
238}
239
240fn source_root_for_project(project_root: &Path, source_root: &Path) -> Result<PathBuf, String> {
241    source_root
242        .strip_prefix(project_root)
243        .map(Path::to_path_buf)
244        .map_err(|_| {
245            format!(
246                "source root {} is outside project root {}",
247                source_root.display(),
248                project_root.display()
249            )
250        })
251}
252
253fn declared_namespace_header(source: &str) -> Result<Option<String>, String> {
254    let mut depth = 0;
255    let mut form_start = None;
256    let mut in_comment = false;
257    let mut in_string = false;
258    let mut escaped = false;
259    let mut skip_character = false;
260    for (index, character) in source.char_indices() {
261        if skip_character {
262            skip_character = false;
263            continue;
264        }
265        if in_comment {
266            if character == '\n' {
267                in_comment = false;
268            }
269            continue;
270        }
271        if in_string {
272            if escaped {
273                escaped = false;
274            } else if character == '\\' {
275                escaped = true;
276            } else if character == '"' {
277                in_string = false;
278            }
279            continue;
280        }
281        match character {
282            ';' => in_comment = true,
283            '"' => in_string = true,
284            '\\' => skip_character = true,
285            '(' | '[' | '{' => {
286                if depth == 0 && character == '(' {
287                    form_start = Some(index);
288                }
289                depth += 1;
290            }
291            ')' | ']' | '}' if depth > 0 => {
292                depth -= 1;
293                if depth == 0 {
294                    if let Some(start) = form_start.take() {
295                        let end = index + character.len_utf8();
296                        if let Some(namespace) = declared_namespace(&source[start..end])? {
297                            return Ok(Some(namespace));
298                        }
299                    }
300                }
301            }
302            _ => {}
303        }
304    }
305    Ok(None)
306}
307
308/// Builds a path-backed source catalog for one project and its installed Hara
309/// dependencies.
310pub fn source_catalog(project: &Project) -> Result<SourceCatalog, String> {
311    source_catalogs(&[project])
312}
313
314/// Builds a path-backed source catalog for several ordered project layers.
315/// Each project contributes its verified installed dependencies first,
316/// followed by its own source paths; later project layers take precedence.
317pub fn source_catalogs(projects: &[&Project]) -> Result<SourceCatalog, String> {
318    let distribution_root = dist_root();
319    let mut catalog = SourceCatalog::default();
320    for project in projects {
321        for dependency in installed::resolve(project, &distribution_root)? {
322            catalog.add_project(
323                &dependency.project,
324                &format!("{}@{}", dependency.coordinate, dependency.version),
325            )?;
326        }
327        catalog.add_project(project, &format!("{}@{}", project.id, project.version))?;
328    }
329    Ok(catalog)
330}
331
332/// Returns namespace resources from installed dependencies followed by the
333/// automatically selected native Rust profile of the consuming project.
334pub fn source_resources(project: &Project) -> Result<Vec<(String, String)>, String> {
335    source_resources_at(project, &dist_root())
336}
337
338pub(crate) fn source_resources_at(
339    project: &Project,
340    distribution_root: &Path,
341) -> Result<Vec<(String, String)>, String> {
342    let mut resources = Vec::new();
343    let mut declarations = BTreeMap::<String, (String, PathBuf)>::new();
344    for dependency in installed::resolve(project, distribution_root)? {
345        collect_project(
346            &dependency.project,
347            &format!("{}@{}", dependency.coordinate, dependency.version),
348            &mut declarations,
349            &mut resources,
350        )?;
351    }
352    collect_project(
353        project,
354        &format!("{}@{}", project.id, project.version),
355        &mut declarations,
356        &mut resources,
357    )?;
358    Ok(resources)
359}
360
361fn collect_project(
362    project: &Project,
363    owner: &str,
364    declarations: &mut BTreeMap<String, (String, PathBuf)>,
365    resources: &mut Vec<(String, String)>,
366) -> Result<(), String> {
367    for path in files_in(&project.root, &project.source_paths)? {
368        let source = fs::read_to_string(&path)
369            .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
370        let namespace = declared_namespace(&source)
371            .map_err(|error| format!("{}: {error}", path.display()))?
372            .ok_or_else(|| format!("{} does not declare an ns or ns+ namespace", path.display()))?;
373        if let Some((previous_owner, previous_path)) =
374            declarations.insert(namespace.clone(), (owner.to_owned(), path.clone()))
375        {
376            return Err(format!(
377                "duplicate namespace {namespace}: {previous_owner} ({}) and {owner} ({})",
378                previous_path.display(),
379                path.display()
380            ));
381        }
382        resources.push((namespace, source));
383    }
384    Ok(())
385}
386
387fn dist_root() -> PathBuf {
388    if let Some(root) = std::env::var_os("HARA_DIST_HOME") {
389        return PathBuf::from(root);
390    }
391    std::env::var_os("HOME")
392        .map(PathBuf::from)
393        .unwrap_or_else(|| PathBuf::from("."))
394        .join(".hara/dist")
395}