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) -> 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 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 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
299pub fn source_catalog(project: &Project) -> Result<SourceCatalog, String> {
302 source_catalogs(&[project])
303}
304
305pub 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
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 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}