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::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(crate) fn cached_namespaces(&self) -> Vec<String> {
108 self.entries
109 .lock()
110 .expect("source catalog cache poisoned")
111 .keys()
112 .cloned()
113 .collect()
114 }
115
116 fn add_project(&mut self, project: &Project) -> Result<(), String> {
117 let project_root = project
118 .root
119 .canonicalize()
120 .map_err(|error| format!("cannot resolve {}: {error}", project.root.display()))?;
121 for excluded_root in &project.source_excludes {
122 let excluded_root = project.root.join(excluded_root);
123 if !excluded_root.exists() {
124 continue;
125 }
126 let excluded_root = excluded_root.canonicalize().map_err(|error| {
127 format!(
128 "cannot resolve excluded source root {}: {error}",
129 excluded_root.display()
130 )
131 })?;
132 if !excluded_root.starts_with(&project_root) {
133 return Err(format!(
134 "excluded source root escapes project root: {}",
135 excluded_root.display()
136 ));
137 }
138 self.excluded_roots.push(excluded_root);
139 }
140 for source_root in &project.source_paths {
141 let source_root = project.root.join(source_root);
142 if !source_root.exists() {
143 continue;
144 }
145 let source_root = source_root.canonicalize().map_err(|error| {
146 format!(
147 "cannot resolve source root {}: {error}",
148 source_root.display()
149 )
150 })?;
151 if !source_root.starts_with(&project_root) {
152 return Err(format!(
153 "source root escapes project root: {}",
154 source_root.display()
155 ));
156 }
157 self.roots.push(source_root);
158 }
159 Ok(())
160 }
161
162 fn excluded(&self, path: &Path) -> bool {
163 self.excluded_roots
164 .iter()
165 .any(|excluded_root| path.starts_with(excluded_root))
166 }
167
168 fn conventional_path(&self, namespace: &str) -> Option<PathBuf> {
169 let segments = namespace.split('.').collect::<Vec<_>>();
170 if segments.is_empty()
171 || segments.iter().any(|segment| {
172 segment.is_empty()
173 || *segment == ".."
174 || segment.contains('/')
175 || segment.contains('\\')
176 })
177 {
178 return None;
179 }
180 for root in self.roots.iter().rev() {
181 let mut candidate = root.to_path_buf();
182 for segment in &segments[..segments.len().saturating_sub(1)] {
183 candidate.push(segment);
184 }
185 candidate.push(format!(
186 "{}.hal",
187 segments.last().expect("non-empty segments")
188 ));
189 let path = candidate.canonicalize().ok()?;
190 if path.starts_with(root) && path.is_file() && !self.excluded(&path) {
191 return Some(path);
192 }
193 }
194 None
195 }
196
197 fn discover_legacy_paths(&self) {
198 let mut discovered = BTreeMap::new();
199 for root in &self.roots {
200 let Ok(paths) = files_in(root, &[PathBuf::from(".")]) else {
201 continue;
202 };
203 for path in paths {
204 let Ok(path) = path.canonicalize() else {
205 continue;
206 };
207 if !path.starts_with(root) || self.excluded(&path) {
208 continue;
209 }
210 let Ok(source) = fs::read_to_string(&path) else {
211 continue;
212 };
213 let Ok(Some(namespace)) = declared_namespace_header(&source) else {
214 continue;
215 };
216 discovered.insert(namespace, path);
219 }
220 }
221 self.entries
222 .lock()
223 .expect("source catalog cache poisoned")
224 .extend(discovered);
225 }
226}
227
228fn declared_namespace_header(source: &str) -> Result<Option<String>, String> {
229 let mut depth = 0;
230 let mut form_start = None;
231 let mut in_comment = false;
232 let mut in_string = false;
233 let mut escaped = false;
234 let mut skip_character = false;
235 for (index, character) in source.char_indices() {
236 if skip_character {
237 skip_character = false;
238 continue;
239 }
240 if in_comment {
241 if character == '\n' {
242 in_comment = false;
243 }
244 continue;
245 }
246 if in_string {
247 if escaped {
248 escaped = false;
249 } else if character == '\\' {
250 escaped = true;
251 } else if character == '"' {
252 in_string = false;
253 }
254 continue;
255 }
256 match character {
257 ';' => in_comment = true,
258 '"' => in_string = true,
259 '\\' => skip_character = true,
260 '(' | '[' | '{' => {
261 if depth == 0 && character == '(' {
262 form_start = Some(index);
263 }
264 depth += 1;
265 }
266 ')' | ']' | '}' if depth > 0 => {
267 depth -= 1;
268 if depth == 0 {
269 if let Some(start) = form_start.take() {
270 let end = index + character.len_utf8();
271 if let Some(namespace) = declared_namespace(&source[start..end])? {
272 return Ok(Some(namespace));
273 }
274 }
275 }
276 }
277 _ => {}
278 }
279 }
280 Ok(None)
281}
282
283pub fn source_catalog(project: &Project) -> Result<SourceCatalog, String> {
286 source_catalog_at(project, &dist_root())
287}
288
289pub fn source_catalog_at(
293 project: &Project,
294 distribution_root: &Path,
295) -> Result<SourceCatalog, String> {
296 source_catalogs_at(&[project], distribution_root)
297}
298
299pub fn source_catalogs(projects: &[&Project]) -> Result<SourceCatalog, String> {
303 source_catalogs_at(projects, &dist_root())
304}
305
306pub(crate) fn source_catalogs_at(
310 projects: &[&Project],
311 distribution_root: &Path,
312) -> Result<SourceCatalog, String> {
313 let mut catalog = SourceCatalog::default();
314 for project in projects {
315 for dependency in installed::resolve(project, distribution_root)? {
316 catalog.add_project(&dependency.project)?;
317 }
318 catalog.add_project(project)?;
319 }
320 Ok(catalog)
321}
322
323pub 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 if project
360 .source_excludes
361 .iter()
362 .any(|excluded_root| path.starts_with(project.root.join(excluded_root)))
363 {
364 continue;
365 }
366 let source = fs::read_to_string(&path)
367 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
368 let namespace = declared_namespace(&source)
369 .map_err(|error| format!("{}: {error}", path.display()))?
370 .ok_or_else(|| format!("{} does not declare an ns or ns+ namespace", path.display()))?;
371 if let Some((previous_owner, previous_path)) =
372 declarations.insert(namespace.clone(), (owner.to_owned(), path.clone()))
373 {
374 return Err(format!(
375 "duplicate namespace {namespace}: {previous_owner} ({}) and {owner} ({})",
376 previous_path.display(),
377 path.display()
378 ));
379 }
380 resources.push((namespace, source));
381 }
382 Ok(())
383}
384
385fn dist_root() -> PathBuf {
386 if let Some(root) = std::env::var_os("HARA_DIST_HOME") {
387 return PathBuf::from(root);
388 }
389 std::env::var_os("HOME")
390 .map(PathBuf::from)
391 .unwrap_or_else(|| PathBuf::from("."))
392 .join(".hara/dist")
393}