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