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#[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 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 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
308pub fn source_catalog(project: &Project) -> Result<SourceCatalog, String> {
311 source_catalogs(&[project])
312}
313
314pub 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
332pub 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}