1use crate::kernel::{parse, parse_forms, Form};
7use crate::Runtime;
8use semver::{Version, VersionReq};
9use std::collections::BTreeMap;
10use std::fs;
11use std::path::{Component, Path, PathBuf};
12
13#[path = "project/npm.rs"]
14mod npm;
15
16const REQUIRED: &[&str] = &[
17 "hara/type",
18 "hara/version",
19 "project/id",
20 "project/version",
21 "project/source-paths",
22 "project/test-paths",
23 "project/extension-paths",
24 "project/capabilities",
25];
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct Project {
29 pub root: PathBuf,
30 pub manifest_path: PathBuf,
31 pub id: String,
32 pub version: Version,
33 pub native: Option<NativeRequirement>,
37 pub release_tag: String,
40 pub source_paths: Vec<PathBuf>,
42 pub test_paths: Vec<PathBuf>,
43 pub extension_paths: Vec<PathBuf>,
44 pub shared_source_paths: Vec<PathBuf>,
45 pub shared_test_paths: Vec<PathBuf>,
46 pub shared_extension_paths: Vec<PathBuf>,
47 pub runtime_profiles: BTreeMap<String, RuntimeProfile>,
48 pub active_runtime: String,
49 pub native_source_paths: Vec<PathBuf>,
50 pub runtime_target_path: Option<PathBuf>,
51 pub maven_dependencies: BTreeMap<String, String>,
52 pub npm_dependencies: BTreeMap<String, NpmWasmDependency>,
53 pub native_imports: BTreeMap<String, WasmNativeImport>,
54 pub capabilities: Vec<String>,
55 pub artifact_paths: Vec<PathBuf>,
56 pub archive_root: Option<PathBuf>,
57 pub package_workspace: bool,
60 pub package_name: Option<String>,
63 pub package_profile: Option<PathBuf>,
66 pub source_files: Option<Vec<PathBuf>>,
71 pub main: Option<String>,
72 pub default_profile: Option<String>,
73 pub profiles: BTreeMap<String, ProjectProfile>,
74 pub dependencies: BTreeMap<String, String>,
76 pub source_excludes: Vec<PathBuf>,
78 pub shared_dependencies: BTreeMap<String, String>,
79 pub extensions: BTreeMap<String, Form>,
80 pub aliases: BTreeMap<String, Vec<String>>,
83 pub distribution: Option<Distribution>,
85 pub recipe: Option<PathBuf>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct NativeRequirement {
90 pub version: Version,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Distribution {
95 pub launcher: String,
97 pub entry: String,
99}
100
101#[derive(Debug, Clone, PartialEq)]
102pub struct ProjectProfile {
103 pub language: String,
104 pub main: Option<String>,
105 pub options: Form,
106}
107
108#[derive(Debug, Clone, PartialEq, Default)]
109pub struct RuntimeProfile {
110 pub source_paths: Vec<PathBuf>,
111 pub source_excludes: Vec<PathBuf>,
112 pub test_paths: Vec<PathBuf>,
113 pub extension_paths: Vec<PathBuf>,
114 pub native_source_paths: Vec<PathBuf>,
115 pub target_path: Option<PathBuf>,
116 pub hara_dependencies: BTreeMap<String, String>,
117 pub maven_dependencies: BTreeMap<String, String>,
118 pub npm_dependencies: BTreeMap<String, NpmWasmDependency>,
119 pub native_imports: BTreeMap<String, WasmNativeImport>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct NpmWasmDependency {
124 pub version: Version,
125 pub integrity: String,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct WasmNativeImport {
130 pub package: String,
131 pub module: PathBuf,
132 pub abi: String,
133}
134
135#[derive(Debug, Clone, PartialEq)]
136pub struct ResolvedRuntimeProfile {
137 pub runtime: String,
138 pub source_paths: Vec<PathBuf>,
139 pub source_excludes: Vec<PathBuf>,
140 pub test_paths: Vec<PathBuf>,
141 pub extension_paths: Vec<PathBuf>,
142 pub native_source_paths: Vec<PathBuf>,
143 pub target_path: Option<PathBuf>,
144 pub hara_dependencies: BTreeMap<String, String>,
145 pub maven_dependencies: BTreeMap<String, String>,
146 pub npm_dependencies: BTreeMap<String, NpmWasmDependency>,
147 pub native_imports: BTreeMap<String, WasmNativeImport>,
148}
149
150#[derive(Debug, Clone, PartialEq)]
151pub struct ResolvedProfile {
152 pub name: String,
153 pub language: String,
154 pub main: String,
155 pub options: Form,
156}
157
158impl Project {
159 pub fn resolve_runtime_profile(&self, runtime: &str) -> Result<ResolvedRuntimeProfile, String> {
161 resolve_runtime_profile_values(
162 runtime,
163 &self.shared_source_paths,
164 &self.shared_test_paths,
165 &self.shared_extension_paths,
166 &self.shared_dependencies,
167 &self.runtime_profiles,
168 )
169 }
170
171 pub fn resolve_profile(
174 &self,
175 requested: Option<&str>,
176 ) -> Result<Option<ResolvedProfile>, String> {
177 if self.profiles.is_empty() {
178 if requested.is_some() {
179 return Err("project.edn does not declare :project/profiles".into());
180 }
181 return Ok(None);
182 }
183 let name = requested
184 .map(str::to_owned)
185 .or_else(|| self.default_profile.clone())
186 .ok_or("project.edn requires :project/default-profile or an explicit profile")?;
187 let profile = self
188 .profiles
189 .get(&name)
190 .ok_or_else(|| format!("project.edn has no profile {name:?}"))?;
191 let main = profile
192 .main
193 .clone()
194 .or_else(|| self.main.clone())
195 .ok_or_else(|| format!("project profile {name:?} has no main value"))?;
196 Ok(Some(ResolvedProfile {
197 name,
198 language: profile.language.clone(),
199 main,
200 options: profile.options.clone(),
201 }))
202 }
203}
204
205pub fn discover(start: &Path) -> Result<Project, String> {
206 let initial = if start.is_file() {
207 start
208 .parent()
209 .ok_or_else(|| format!("cannot determine project root for {}", start.display()))?
210 } else {
211 start
212 };
213 let mut current = initial
214 .canonicalize()
215 .unwrap_or_else(|_| initial.to_path_buf());
216 loop {
217 let manifest = current.join("project.edn");
218 if manifest.is_file() {
219 return read(&manifest);
220 }
221 match current.parent() {
222 Some(parent) => current = parent.to_path_buf(),
223 None => return Err(format!("no project.edn found above {}", initial.display())),
224 }
225 }
226}
227
228pub fn checkout_projects(project: &Project) -> Result<Vec<Project>, String> {
237 let directory = project.root.join("checkouts");
238 if !directory.is_dir() {
239 return Ok(Vec::new());
240 }
241 let mut paths = fs::read_dir(&directory)
242 .map_err(|error| {
243 format!(
244 "cannot read checkout directory {}: {error}",
245 directory.display()
246 )
247 })?
248 .map(|entry| entry.map(|value| value.path()).map_err(io))
249 .collect::<Result<Vec<_>, _>>()?;
250 paths.sort();
251
252 let mut projects = Vec::new();
253 let mut coordinates = BTreeMap::<String, PathBuf>::new();
254 for path in paths {
255 if !path.is_dir() {
256 continue;
257 }
258 let manifest = path.join("project.edn");
259 if !manifest.is_file() {
260 continue;
261 }
262 let checkout =
263 read(&manifest).map_err(|error| format!("checkout {}: {error}", path.display()))?;
264 let coordinate = normalize_coordinate(&checkout.id).map_err(|error| {
265 format!(
266 "checkout {} has an invalid project id: {error}",
267 path.display()
268 )
269 })?;
270 if let Some(previous) = coordinates.insert(coordinate.clone(), path.clone()) {
271 return Err(format!(
272 "multiple checkouts provide {coordinate}: {} and {}",
273 previous.display(),
274 path.display()
275 ));
276 }
277 projects.push(checkout);
278 }
279 Ok(projects)
280}
281
282pub fn read(input: &Path) -> Result<Project, String> {
283 let manifest_path = if input.is_dir() {
284 input.join("project.edn")
285 } else {
286 input.to_path_buf()
287 };
288 let root = manifest_path
289 .parent()
290 .ok_or_else(|| {
291 format!(
292 "cannot determine project root for {}",
293 manifest_path.display()
294 )
295 })?
296 .to_path_buf();
297 let source = fs::read_to_string(&manifest_path)
298 .map_err(|error| format!("cannot read {}: {error}", manifest_path.display()))?;
299 let form = parse(&source).map_err(|error| format!("{}: {error}", manifest_path.display()))?;
300 let entries = map(&form, "project.edn must be an EDN map")?;
301 reject_legacy_runtime_keys(entries)?;
302 for key in REQUIRED {
303 if lookup(entries, key).is_none() {
304 return Err(format!("project.edn missing required key :{key}"));
305 }
306 }
307 if !matches!(lookup(entries, "hara/type"), Some(Form::Keyword(value)) if value == "project") {
308 return Err("project.edn :hara/type must be :project".into());
309 }
310 let id = scalar(
311 lookup(entries, "project/id").unwrap(),
312 "project.edn :project/id",
313 )?;
314 let version_text = string(
315 lookup(entries, "project/version").unwrap(),
316 "project.edn :project/version",
317 )?;
318 let version = Version::parse(&version_text)
319 .map_err(|error| format!("project.edn :project/version is not SemVer: {error}"))?;
320 let native = lookup(entries, "project/native")
321 .map(native_requirement)
322 .transpose()?;
323 if let Some(requirement) = &native {
324 validate_native_requirement(requirement)?;
325 }
326 let release_tag = lookup(entries, "project/release-tag")
327 .map(|value| string(value, "project.edn :project/release-tag"))
328 .transpose()?
329 .unwrap_or_else(|| version.to_string());
330 validate_release_tag(&release_tag)?;
331 let shared_source_paths = paths(
332 lookup(entries, "project/source-paths").unwrap(),
333 "project/source-paths",
334 )?;
335 let shared_test_paths = paths(
336 lookup(entries, "project/test-paths").unwrap(),
337 "project/test-paths",
338 )?;
339 let shared_extension_paths = paths(
340 lookup(entries, "project/extension-paths").unwrap(),
341 "project/extension-paths",
342 )?;
343 let capabilities = capability_set(
344 lookup(entries, "project/capabilities").unwrap(),
345 "project.edn :project/capabilities",
346 )?;
347 let artifact_paths = lookup(entries, "project/artifact-paths")
348 .map(|value| paths(value, "project/artifact-paths"))
349 .transpose()?
350 .unwrap_or_default();
351 let archive_root = lookup(entries, "project/archive-root")
352 .map(|value| {
353 relative_path(
354 &string(value, "project/archive-root")?,
355 "project/archive-root",
356 )
357 })
358 .transpose()?;
359 let package_config = lookup(entries, "project/package")
360 .map(package_config)
361 .transpose()?
362 .unwrap_or_default();
363 let source_files = lookup(entries, "project/source-files")
364 .map(|value| paths(value, "project/source-files"))
365 .transpose()?;
366 let main = lookup(entries, "project/main")
367 .map(|value| scalar(value, "project.edn :project/main"))
368 .transpose()?;
369 let default_profile = lookup(entries, "project/default-profile")
370 .map(|value| identifier(value, "project.edn :project/default-profile"))
371 .transpose()?;
372 let profiles = lookup(entries, "project/profiles")
373 .map(project_profiles)
374 .transpose()?
375 .unwrap_or_default();
376 if let Some(default) = &default_profile {
377 if !profiles.contains_key(default) {
378 return Err(format!(
379 "project.edn :project/default-profile {default:?} is not declared in :project/profiles"
380 ));
381 }
382 }
383 let shared_dependencies = lookup(entries, "project/dependencies")
384 .map(dependencies)
385 .transpose()?
386 .unwrap_or_default();
387 let runtime_profiles = lookup(entries, "project/runtime-profiles")
388 .map(runtime_profiles)
389 .transpose()?
390 .unwrap_or_default();
391 let active = resolve_runtime_profile_values(
392 "rust",
393 &shared_source_paths,
394 &shared_test_paths,
395 &shared_extension_paths,
396 &shared_dependencies,
397 &runtime_profiles,
398 )?;
399 let source_paths = active.source_paths.clone();
400 let mut source_excludes = active.source_excludes.clone();
401 if let Some(deploy) = lookup(entries, "project/deploy") {
407 let deploy = map(deploy, "project.edn :project/deploy must be an EDN map")?;
408 if let Some(excludes) = lookup(deploy, "source-excludes") {
409 for path in paths(excludes, "project/deploy :source-excludes")? {
410 if !source_excludes.contains(&path) {
411 source_excludes.push(path);
412 }
413 }
414 }
415 }
416 let test_paths = active.test_paths.clone();
417 let extension_paths = active.extension_paths.clone();
418 let dependencies = active.hara_dependencies.clone();
419 let native_source_paths = active.native_source_paths.clone();
420 let runtime_target_path = active.target_path.clone();
421 let maven_dependencies = active.maven_dependencies.clone();
422 let npm_dependencies = active.npm_dependencies.clone();
423 let native_imports = active.native_imports.clone();
424 let extensions = lookup(entries, "project/extensions")
425 .map(extension_declarations)
426 .transpose()?
427 .unwrap_or_default();
428 let aliases = lookup(entries, "project/aliases")
429 .map(project_aliases)
430 .transpose()?
431 .unwrap_or_default();
432 let distribution = lookup(entries, "project/distribution")
433 .map(project_distribution)
434 .transpose()?;
435 let recipe = lookup(entries, "project/recipe")
436 .map(|value| relative_path(&string(value, "project/recipe")?, "project/recipe"))
437 .transpose()?;
438 if let Some(path) = &recipe {
439 if !root.join(path).is_file() {
440 return Err(format!(
441 "project.edn :project/recipe does not exist: {}",
442 path.display()
443 ));
444 }
445 }
446 Ok(Project {
447 root,
448 manifest_path,
449 id,
450 version,
451 native,
452 release_tag,
453 source_paths,
454 test_paths,
455 extension_paths,
456 shared_source_paths,
457 shared_test_paths,
458 shared_extension_paths,
459 runtime_profiles,
460 active_runtime: "rust".into(),
461 native_source_paths,
462 runtime_target_path,
463 maven_dependencies,
464 npm_dependencies,
465 native_imports,
466 capabilities,
467 artifact_paths,
468 archive_root,
469 package_workspace: package_config.workspace,
470 package_name: package_config.name,
471 package_profile: package_config.profile,
472 source_files,
473 main,
474 default_profile,
475 profiles,
476 dependencies,
477 source_excludes,
478 shared_dependencies,
479 extensions,
480 aliases,
481 distribution,
482 recipe,
483 })
484}
485
486fn native_requirement(form: &Form) -> Result<NativeRequirement, String> {
487 let entries = map(form, "project.edn :project/native must be an EDN map")?;
488 if entries.len() != 1 || lookup(entries, "version").is_none() {
489 return Err("project.edn :project/native requires exactly :version".into());
490 }
491 let version = string(
492 lookup(entries, "version").expect("validated required :version"),
493 "project.edn :project/native :version",
494 )?;
495 let version = Version::parse(&version).map_err(|error| {
496 format!("project.edn :project/native :version must be exact SemVer: {error}")
497 })?;
498 Ok(NativeRequirement { version })
499}
500
501fn validate_native_requirement(requirement: &NativeRequirement) -> Result<(), String> {
502 let host = Version::parse(env!("CARGO_PKG_VERSION"))
503 .expect("the hara-native Cargo package version must be valid SemVer");
504 if requirement.version == host {
505 Ok(())
506 } else {
507 Err(format!(
508 "project.edn requires hara-native {}, but this host is {}",
509 requirement.version, host
510 ))
511 }
512}
513
514fn validate_release_tag(tag: &str) -> Result<(), String> {
515 if tag.is_empty()
516 || tag.starts_with('-')
517 || tag.ends_with('.')
518 || tag.contains("..")
519 || tag.bytes().any(|byte| {
520 byte.is_ascii_whitespace()
521 || byte.is_ascii_control()
522 || matches!(byte, b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b'\\')
523 })
524 {
525 return Err("project.edn :project/release-tag is not a valid Git tag name".into());
526 }
527 Ok(())
528}
529
530fn extension_declarations(form: &Form) -> Result<BTreeMap<String, Form>, String> {
531 let Form::Map(entries) = form else {
532 return Err("project.edn :project/extensions must be a map".into());
533 };
534 entries
535 .iter()
536 .map(|(namespace, declaration)| {
537 let namespace = scalar(namespace, "project extension namespace")?;
538 if !matches!(declaration, Form::Map(_)) {
539 return Err(format!(
540 "project extension {namespace} declaration must be a map"
541 ));
542 }
543 Ok((namespace, declaration.clone()))
544 })
545 .collect()
546}
547
548pub fn new_app(destination: &Path, name: &str) -> Result<Project, String> {
549 if !valid_name(name) {
550 return Err(
551 "project name must contain only lowercase letters, numbers, and hyphens".into(),
552 );
553 }
554 if destination.exists() {
555 return Err(format!(
556 "destination already exists: {}",
557 destination.display()
558 ));
559 }
560 let namespace = name.replace('-', "_");
561 fs::create_dir_all(destination.join("src").join(&namespace)).map_err(io)?;
562 fs::create_dir_all(destination.join("test").join(&namespace)).map_err(io)?;
563 fs::create_dir_all(destination.join("extensions")).map_err(io)?;
564 fs::write(destination.join("project.edn"), format!(
565 "{{:hara/type :project\n :hara/version \"1.0.0\"\n :project/id {name}\n :project/version \"0.1.0\"\n :project/source-paths [\"src\"]\n :project/test-paths [\"test\"]\n :project/extension-paths [\"extensions\"]\n :project/main {namespace}.main\n :project/capabilities #{{}}\n :project/dependencies {{}}}}\n"
566 )).map_err(io)?;
567 fs::write(
568 destination.join("workspace.edn"),
569 "{:hara/type :workspace :hara/version \"1.0.0\"}\n",
570 )
571 .map_err(io)?;
572 fs::write(
573 destination.join("src").join(&namespace).join("main.hal"),
574 format!("(ns {namespace}.main)\n\n(defn main []\n \"Hello from {name}\")\n\n(main)\n"),
575 )
576 .map_err(io)?;
577 fs::write(
578 destination
579 .join("test")
580 .join(&namespace)
581 .join("main_test.hal"),
582 format!(
583 "(ns {namespace}.main-test)\n\n[(test-check \"starter project runs\" true true)]\n"
584 ),
585 )
586 .map_err(io)?;
587 read(&destination.join("project.edn"))
588}
589
590pub fn set_dependency(
591 project: &Project,
592 coordinate: &str,
593 version: Option<&str>,
594) -> Result<(), String> {
595 validate_coordinate(coordinate)?;
596 if let Some(version) = version {
597 VersionReq::parse(version)
598 .map_err(|error| format!("invalid dependency range {version}: {error}"))?;
599 }
600 let source = fs::read_to_string(&project.manifest_path).map_err(io)?;
601 let mut form =
602 parse(&source).map_err(|error| format!("{}: {error}", project.manifest_path.display()))?;
603 let entries = map_mut(&mut form, "project.edn must be an EDN map")?;
604 let dependency_index = entries
605 .iter()
606 .position(|(key, _)| key_name(key).as_deref() == Some("project/dependencies"));
607 let dependency_form = dependency_index.map(|index| &mut entries[index].1);
608 let deps = match dependency_form {
609 Some(Form::Map(entries)) => entries,
610 Some(_) => return Err("project.edn :project/dependencies must be an EDN map".into()),
611 None => {
612 entries.push((
613 Form::Keyword("project/dependencies".into()),
614 Form::Map(Vec::new()),
615 ));
616 match &mut entries.last_mut().unwrap().1 {
617 Form::Map(entries) => entries,
618 _ => unreachable!(),
619 }
620 }
621 };
622 if let Some(index) = deps.iter().position(|(key, _)| {
623 scalar(key, "dependency coordinate").ok().as_deref() == Some(coordinate)
624 }) {
625 if let Some(version) = version {
626 deps[index].1 = Form::Map(vec![(
627 Form::Keyword("version".into()),
628 Form::String(version.into()),
629 )]);
630 } else {
631 deps.remove(index);
632 }
633 } else if let Some(version) = version {
634 deps.push((
635 Form::String(coordinate.into()),
636 Form::Map(vec![(
637 Form::Keyword("version".into()),
638 Form::String(version.into()),
639 )]),
640 ));
641 }
642 deps.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string()));
643 fs::write(&project.manifest_path, format!("{form}\n")).map_err(io)
644}
645
646pub fn files_in(root: &Path, paths: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
647 let mut output = Vec::new();
648 for relative in paths {
649 collect_hal(&root.join(relative), &mut output)?;
650 }
651 output.sort();
652 Ok(output)
653}
654
655#[path = "project/resources.rs"]
656mod resources;
657pub use resources::source_resources;
658pub use resources::{source_catalog, source_catalog_at, source_catalogs, SourceCatalog};
659
660pub fn register_sources(project: &Project, runtime: &mut Runtime) -> Result<(), String> {
662 for (namespace, source) in source_resources(project)? {
663 runtime.register_resource(&namespace, &source);
664 }
665 Ok(())
666}
667
668#[cfg(not(target_arch = "wasm32"))]
671pub fn register_native_imports(project: &Project, runtime: &mut Runtime) -> Result<(), String> {
672 if project.native_imports.is_empty() {
673 Ok(())
674 } else {
675 npm::install(project, runtime)
676 }
677}
678
679pub(crate) fn native_archive_entries(project: &Project) -> Result<Vec<PathBuf>, String> {
680 if project.native_imports.is_empty() {
681 Ok(Vec::new())
682 } else {
683 npm::archive_entries(project)
684 }
685}
686
687pub fn main_file(project: &Project) -> Result<PathBuf, String> {
688 let namespace = project
689 .main
690 .as_ref()
691 .ok_or_else(|| "project.edn is missing :project/main".to_owned())?;
692 let relative = format!("{}.hal", namespace.replace('.', "/").replace('-', "_"));
693 for source in &project.source_paths {
694 let candidate = project.root.join(source).join(&relative);
695 if candidate.is_file() {
696 return Ok(candidate);
697 }
698 }
699 Err(format!(
700 "cannot find :project/main {namespace} in :project/source-paths"
701 ))
702}
703
704fn declared_namespace(source: &str) -> Result<Option<String>, String> {
705 Ok(parse_forms(source)?
706 .into_iter()
707 .find_map(declared_namespace_form))
708}
709
710fn declared_namespace_form(form: Form) -> Option<String> {
711 match form {
712 Form::Metadata(_, value) => declared_namespace_form(*value),
713 Form::List(values) if matches!(values.first(), Some(Form::Symbol(head)) if head == "ns" || head == "ns+") => {
714 match values.get(1) {
715 Some(Form::Symbol(namespace)) if !namespace.contains('/') => {
716 Some(namespace.clone())
717 }
718 _ => None,
719 }
720 }
721 _ => None,
722 }
723}
724
725pub fn sync_lock(project: &Project, mode: LockMode) -> Result<PathBuf, String> {
729 let lock = project.root.join("project.lock.edn");
730 if !project.dependencies.is_empty() {
731 return Err(format!(
732 "project sync requires the reviewed registry client to resolve {} declared dependencies",
733 project.dependencies.len()
734 ));
735 }
736 if !project.npm_dependencies.is_empty() || !project.native_imports.is_empty() {
737 return npm::sync(project, mode, &lock);
738 }
739 match mode {
740 LockMode::Locked | LockMode::Frozen if !lock.is_file() => {
741 return Err(format!(
742 "{} requires an existing project.lock.edn",
743 mode.flag()
744 ));
745 }
746 LockMode::Locked | LockMode::Frozen => validate_empty_lock(&lock)?,
747 LockMode::Default | LockMode::Offline => {
748 fs::write(&lock, "{:lock/format \"0.0.1\" :packages {}}\n")
749 .map_err(|error| format!("cannot write {}: {error}", lock.display()))?;
750 }
751 }
752 Ok(lock)
753}
754
755#[derive(Debug, Clone, Copy, PartialEq, Eq)]
756pub enum LockMode {
757 Default,
758 Offline,
759 Locked,
760 Frozen,
761}
762
763impl LockMode {
764 pub fn flag(self) -> &'static str {
765 match self {
766 Self::Default => "sync",
767 Self::Offline => "--offline",
768 Self::Locked => "--locked",
769 Self::Frozen => "--frozen",
770 }
771 }
772}
773
774fn collect_hal(directory: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
775 if !directory.exists() {
776 return Ok(());
777 }
778 for entry in fs::read_dir(directory).map_err(io)? {
779 let path = entry.map_err(io)?.path();
780 if editor_artifact(&path) {
781 continue;
782 }
783 if path.is_dir() {
784 collect_hal(&path, output)?;
785 } else if path.extension().and_then(|value| value.to_str()) == Some("hal") {
786 output.push(path);
787 }
788 }
789 Ok(())
790}
791
792fn editor_artifact(path: &Path) -> bool {
793 path.file_name()
794 .and_then(|value| value.to_str())
795 .is_some_and(|name| {
796 name.starts_with(".#") || (name.starts_with('#') && name.ends_with('#'))
797 })
798}
799
800fn validate_empty_lock(path: &Path) -> Result<(), String> {
801 let source = fs::read_to_string(path)
802 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
803 let form = parse(&source).map_err(|error| format!("{}: {error}", path.display()))?;
804 let entries = map(&form, "project.lock.edn must be an EDN map")?;
805 if matches!(lookup(entries, "lock/format"), Some(Form::String(version)) if version == "0.0.1")
806 && matches!(lookup(entries, "packages"), Some(Form::Map(entries)) if entries.is_empty())
807 {
808 Ok(())
809 } else {
810 Err(format!(
811 "{} is not a lockfile written by this CLI",
812 path.display()
813 ))
814 }
815}
816
817fn map<'a>(form: &'a Form, message: &str) -> Result<&'a Vec<(Form, Form)>, String> {
818 if let Form::Map(entries) = form {
819 Ok(entries)
820 } else {
821 Err(message.into())
822 }
823}
824fn map_mut<'a>(form: &'a mut Form, message: &str) -> Result<&'a mut Vec<(Form, Form)>, String> {
825 if let Form::Map(entries) = form {
826 Ok(entries)
827 } else {
828 Err(message.into())
829 }
830}
831fn key_name(key: &Form) -> Option<String> {
832 match key {
833 Form::Keyword(value) => Some(value.clone()),
834 _ => None,
835 }
836}
837fn lookup<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
838 entries
839 .iter()
840 .find(|(candidate, _)| key_name(candidate).as_deref() == Some(key))
841 .map(|(_, value)| value)
842}
843fn scalar(form: &Form, label: &str) -> Result<String, String> {
844 match form {
845 Form::String(value) | Form::Symbol(value) => Ok(value.clone()),
846 _ => Err(format!("{label} must be a string or symbol")),
847 }
848}
849fn identifier(form: &Form, label: &str) -> Result<String, String> {
850 match form {
851 Form::Keyword(value) | Form::String(value) | Form::Symbol(value) => Ok(value.clone()),
852 _ => Err(format!("{label} must be a keyword, string, or symbol")),
853 }
854}
855
856fn capability_set(form: &Form, label: &str) -> Result<Vec<String>, String> {
857 let Form::Set(values) = form else {
858 return Err(format!("{label} must be an EDN set"));
859 };
860 let mut output = values
861 .iter()
862 .map(|value| identifier(value, label))
863 .collect::<Result<Vec<_>, _>>()?;
864 output.sort();
865 output.dedup();
866 Ok(output)
867}
868
869fn reject_legacy_runtime_keys(entries: &[(Form, Form)]) -> Result<(), String> {
870 for (key, replacement) in [
871 (
872 "jvm/source-paths",
873 ":project/runtime-profiles :jvm :runtime/native-source-paths",
874 ),
875 (
876 "jvm/dependencies",
877 ":project/runtime-profiles :jvm :runtime/dependencies :maven",
878 ),
879 (
880 "jvm/target-path",
881 ":project/runtime-profiles :jvm :runtime/target-path",
882 ),
883 ] {
884 if lookup(entries, key).is_some() {
885 return Err(format!(
886 "project.edn :{key} is no longer supported; use {replacement}"
887 ));
888 }
889 }
890 Ok(())
891}
892
893fn runtime_profiles(form: &Form) -> Result<BTreeMap<String, RuntimeProfile>, String> {
894 let mut output = BTreeMap::new();
895 for (key, value) in map(
896 form,
897 "project.edn :project/runtime-profiles must be an EDN map",
898 )? {
899 let runtime = identifier(key, "runtime profile name")?;
900 if runtime != "jvm" && runtime != "rust" {
901 return Err(format!("unsupported project runtime profile {runtime:?}"));
902 }
903 let entries = map(value, "runtime profile must be an EDN map")?;
904 let source_paths = lookup(entries, "runtime/source-paths")
905 .map(|value| paths(value, "runtime/source-paths"))
906 .transpose()?
907 .unwrap_or_default();
908 let source_excludes = lookup(entries, "runtime/source-excludes")
909 .map(|value| paths(value, "runtime/source-excludes"))
910 .transpose()?
911 .unwrap_or_default();
912 let test_paths = lookup(entries, "runtime/test-paths")
913 .map(|value| paths(value, "runtime/test-paths"))
914 .transpose()?
915 .unwrap_or_default();
916 let extension_paths = lookup(entries, "runtime/extension-paths")
917 .map(|value| paths(value, "runtime/extension-paths"))
918 .transpose()?
919 .unwrap_or_default();
920 let native_source_paths = lookup(entries, "runtime/native-source-paths")
921 .map(|value| paths(value, "runtime/native-source-paths"))
922 .transpose()?
923 .unwrap_or_default();
924 let target_path = lookup(entries, "runtime/target-path")
925 .map(|value| {
926 relative_path(
927 &string(value, "runtime/target-path")?,
928 "runtime/target-path",
929 )
930 })
931 .transpose()?;
932 let (hara_dependencies, maven_dependencies, npm_dependencies) =
933 match lookup(entries, "runtime/dependencies") {
934 None => (BTreeMap::new(), BTreeMap::new(), BTreeMap::new()),
935 Some(value) => {
936 let groups = map(value, "runtime :runtime/dependencies must be an EDN map")?;
937 let hara = lookup(groups, "hara")
938 .map(dependencies)
939 .transpose()?
940 .unwrap_or_default();
941 let maven = lookup(groups, "maven")
942 .map(maven_dependencies)
943 .transpose()?
944 .unwrap_or_default();
945 let npm = lookup(groups, "npm")
946 .map(npm_wasm_dependencies)
947 .transpose()?
948 .unwrap_or_default();
949 (hara, maven, npm)
950 }
951 };
952 let native_imports = lookup(entries, "runtime/imports")
953 .map(|value| wasm_native_imports(value, &npm_dependencies))
954 .transpose()?
955 .unwrap_or_default();
956 let profile = RuntimeProfile {
957 source_paths,
958 source_excludes,
959 test_paths,
960 extension_paths,
961 native_source_paths,
962 target_path,
963 hara_dependencies,
964 maven_dependencies,
965 npm_dependencies,
966 native_imports,
967 };
968 if output.insert(runtime.clone(), profile).is_some() {
969 return Err(format!("duplicate project runtime profile {runtime:?}"));
970 }
971 }
972 Ok(output)
973}
974
975fn resolve_runtime_profile_values(
976 runtime: &str,
977 shared_source_paths: &[PathBuf],
978 shared_test_paths: &[PathBuf],
979 shared_extension_paths: &[PathBuf],
980 shared_dependencies: &BTreeMap<String, String>,
981 runtime_profiles: &BTreeMap<String, RuntimeProfile>,
982) -> Result<ResolvedRuntimeProfile, String> {
983 if runtime != "jvm" && runtime != "rust" {
984 return Err(format!("unsupported project runtime profile {runtime:?}"));
985 }
986 let profile = runtime_profiles.get(runtime).cloned().unwrap_or_default();
987 let mut hara_dependencies = shared_dependencies.clone();
988 for (coordinate, requirement) in &profile.hara_dependencies {
989 if let Some(shared) = hara_dependencies.get(coordinate) {
990 if shared != requirement {
991 return Err(format!(
992 "conflicting Hara dependency requirements for {coordinate} in :{runtime}: {shared:?} and {requirement:?}"
993 ));
994 }
995 }
996 hara_dependencies.insert(coordinate.clone(), requirement.clone());
997 }
998 let mut source_paths = shared_source_paths.to_vec();
999 source_paths.extend(profile.source_paths.iter().cloned());
1000 let mut test_paths = shared_test_paths.to_vec();
1001 test_paths.extend(profile.test_paths.iter().cloned());
1002 let mut extension_paths = shared_extension_paths.to_vec();
1003 extension_paths.extend(profile.extension_paths.iter().cloned());
1004 Ok(ResolvedRuntimeProfile {
1005 runtime: runtime.into(),
1006 source_paths,
1007 source_excludes: profile.source_excludes,
1008 test_paths,
1009 extension_paths,
1010 native_source_paths: profile.native_source_paths,
1011 target_path: profile.target_path,
1012 hara_dependencies,
1013 maven_dependencies: profile.maven_dependencies,
1014 npm_dependencies: profile.npm_dependencies,
1015 native_imports: profile.native_imports,
1016 })
1017}
1018
1019fn npm_wasm_dependencies(form: &Form) -> Result<BTreeMap<String, NpmWasmDependency>, String> {
1020 map(form, "runtime npm dependencies must be an EDN map")?
1021 .iter()
1022 .map(|(coordinate, declaration)| {
1023 let coordinate = string(coordinate, "npm package name")?;
1024 let entries = map(declaration, "npm dependency declaration must be an EDN map")?;
1025 for (key, _) in entries {
1026 let key = identifier(key, "npm dependency field")?;
1027 if key != "version" && key != "integrity" {
1028 return Err(format!("unsupported npm dependency field :{key}"));
1029 }
1030 }
1031 let version = string(
1032 lookup(entries, "version").ok_or("npm dependency requires :version")?,
1033 "npm dependency :version",
1034 )?;
1035 let version = Version::parse(&version)
1036 .map_err(|_| "npm dependency :version must be an exact SemVer")?;
1037 let integrity = string(
1038 lookup(entries, "integrity").ok_or("npm dependency requires :integrity")?,
1039 "npm dependency :integrity",
1040 )?;
1041 let payload = integrity
1042 .strip_prefix("sha512-")
1043 .ok_or("npm dependency :integrity must use sha512 SRI")?;
1044 if payload.len() < 16
1045 || !payload
1046 .bytes()
1047 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'='))
1048 {
1049 return Err("npm dependency :integrity contains invalid sha512 SRI data".into());
1050 }
1051 Ok((coordinate, NpmWasmDependency { version, integrity }))
1052 })
1053 .collect()
1054}
1055
1056fn wasm_native_imports(
1057 form: &Form,
1058 dependencies: &BTreeMap<String, NpmWasmDependency>,
1059) -> Result<BTreeMap<String, WasmNativeImport>, String> {
1060 map(form, "runtime imports must be an EDN map")?
1061 .iter()
1062 .map(|(logical, declaration)| {
1063 let logical = identifier(logical, "runtime import name")?;
1064 let entries = map(declaration, "runtime import declaration must be an EDN map")?;
1065 for (key, _) in entries {
1066 let key = identifier(key, "runtime import field")?;
1067 if !matches!(key.as_str(), "package" | "module" | "abi") {
1068 return Err(format!("unsupported runtime import field :{key}"));
1069 }
1070 }
1071 let package = string(
1072 lookup(entries, "package").ok_or("runtime import requires :package")?,
1073 "runtime import :package",
1074 )?;
1075 if !dependencies.contains_key(&package) {
1076 return Err(format!(
1077 "runtime import {logical:?} uses undeclared npm package {package:?}"
1078 ));
1079 }
1080 let module = relative_path(
1081 &string(
1082 lookup(entries, "module").ok_or("runtime import requires :module")?,
1083 "runtime import :module",
1084 )?,
1085 "runtime import :module",
1086 )?;
1087 if module.extension().and_then(|value| value.to_str()) != Some("wasm") {
1088 return Err("runtime import :module must select a .wasm file".into());
1089 }
1090 let abi = identifier(
1091 lookup(entries, "abi").ok_or("runtime import requires :abi")?,
1092 "runtime import :abi",
1093 )?;
1094 if abi != "core.v1" {
1095 return Err(format!("runtime import uses unsupported ABI :{abi}"));
1096 }
1097 Ok((
1098 logical,
1099 WasmNativeImport {
1100 package,
1101 module,
1102 abi,
1103 },
1104 ))
1105 })
1106 .collect()
1107}
1108
1109fn maven_dependencies(form: &Form) -> Result<BTreeMap<String, String>, String> {
1110 let mut output = BTreeMap::new();
1111 for (key, value) in map(form, "runtime Maven dependencies must be an EDN map")? {
1112 let coordinate = scalar(key, "Maven dependency coordinate")?;
1113 let mut parts = coordinate.split('/');
1114 if !matches!(
1115 (parts.next(), parts.next(), parts.next()),
1116 (Some(group), Some(artifact), None) if !group.is_empty() && !artifact.is_empty()
1117 ) {
1118 return Err(format!(
1119 "invalid Maven dependency coordinate {coordinate:?}"
1120 ));
1121 }
1122 let declaration = map(value, "Maven dependency declaration must be an EDN map")?;
1123 let version = lookup(declaration, "version")
1124 .ok_or_else(|| format!("Maven dependency {coordinate} is missing :version"))
1125 .and_then(|value| string(value, "Maven dependency :version"))?;
1126 if version.is_empty()
1127 || version
1128 .chars()
1129 .any(|value| matches!(value, '[' | ']' | '(' | ')' | ',' | '*'))
1130 {
1131 return Err(format!(
1132 "Maven dependency {coordinate} requires an exact version"
1133 ));
1134 }
1135 if output.insert(coordinate.clone(), version).is_some() {
1136 return Err(format!("duplicate Maven dependency {coordinate}"));
1137 }
1138 }
1139 Ok(output)
1140}
1141
1142fn project_profiles(form: &Form) -> Result<BTreeMap<String, ProjectProfile>, String> {
1143 let mut output = BTreeMap::new();
1144 for (key, value) in map(form, "project.edn :project/profiles must be an EDN map")? {
1145 let name = identifier(key, "project profile name")?;
1146 let entries = map(value, "project profile must be an EDN map")?;
1147 let language = lookup(entries, "profile/language")
1148 .ok_or_else(|| format!("project profile {name:?} is missing :profile/language"))
1149 .and_then(|value| identifier(value, "profile :profile/language"))?;
1150 let main = lookup(entries, "profile/main")
1151 .map(|value| scalar(value, "profile :profile/main"))
1152 .transpose()?;
1153 let options = lookup(entries, "profile/options")
1154 .cloned()
1155 .unwrap_or_else(|| Form::Map(Vec::new()));
1156 if !matches!(options, Form::Map(_)) {
1157 return Err(format!(
1158 "project profile {name:?} :profile/options must be an EDN map"
1159 ));
1160 }
1161 if output
1162 .insert(
1163 name.clone(),
1164 ProjectProfile {
1165 language,
1166 main,
1167 options,
1168 },
1169 )
1170 .is_some()
1171 {
1172 return Err(format!("duplicate project profile {name:?}"));
1173 }
1174 }
1175 Ok(output)
1176}
1177
1178fn project_aliases(form: &Form) -> Result<BTreeMap<String, Vec<String>>, String> {
1179 let mut output = BTreeMap::new();
1180 for (key, value) in map(form, "project.edn :project/aliases must be an EDN map")? {
1181 let name = identifier(key, "project alias name")?;
1182 if name.is_empty() || name.contains('/') || name.starts_with('-') {
1183 return Err(format!("invalid project alias {name:?}"));
1184 }
1185 let Form::Vector(values) = value else {
1186 return Err(format!(
1187 "project alias {name:?} must be a vector of strings"
1188 ));
1189 };
1190 let argv = values
1191 .iter()
1192 .map(|value| string(value, &format!("project alias {name:?}")))
1193 .collect::<Result<Vec<_>, _>>()?;
1194 if argv.is_empty() || argv.iter().any(|value| value.is_empty()) {
1195 return Err(format!(
1196 "project alias {name:?} must contain command tokens"
1197 ));
1198 }
1199 if output.insert(name.clone(), argv).is_some() {
1200 return Err(format!("duplicate project alias {name:?}"));
1201 }
1202 }
1203 Ok(output)
1204}
1205
1206fn project_distribution(form: &Form) -> Result<Distribution, String> {
1207 let entries = map(form, "project.edn :project/distribution must be an EDN map")?;
1208 let launcher = lookup(entries, "launcher")
1209 .ok_or_else(|| "project.edn :project/distribution requires :launcher".to_owned())
1210 .and_then(|value| string(value, "project.edn :project/distribution :launcher"))?;
1211 if !valid_name(&launcher) {
1212 return Err(
1213 "project.edn :project/distribution :launcher must contain lowercase letters, digits, or hyphens"
1214 .into(),
1215 );
1216 }
1217 let entry = lookup(entries, "entry")
1218 .ok_or("project.edn :project/distribution requires :entry")
1219 .and_then(|value| match value {
1220 Form::Symbol(value) => Ok(value.clone()),
1221 _ => Err("project.edn :project/distribution :entry must be a symbol".into()),
1222 })?;
1223 let valid_entry = entry
1224 .split_once('/')
1225 .is_some_and(|(namespace, symbol)| !namespace.is_empty() && !symbol.is_empty());
1226 if !valid_entry || entry.matches('/').count() != 1 {
1227 return Err("project.edn :project/distribution :entry must name namespace/symbol".into());
1228 }
1229 Ok(Distribution { launcher, entry })
1230}
1231
1232pub fn expand_aliases(project: &Project, argv: &[String]) -> Result<Vec<String>, String> {
1235 let mut output = argv.to_vec();
1236 let mut seen = BTreeMap::new();
1237 loop {
1238 let Some(name) = output.first().cloned() else {
1239 return Ok(output);
1240 };
1241 let Some(prefix) = project.aliases.get(&name) else {
1242 return Ok(output);
1243 };
1244 if seen.insert(name.clone(), true).is_some() {
1245 return Err(format!("project alias cycle detected at {name:?}"));
1246 }
1247 let mut expanded = prefix.clone();
1248 expanded.extend(output.into_iter().skip(1));
1249 output = expanded;
1250 }
1251}
1252fn string(form: &Form, label: &str) -> Result<String, String> {
1253 match form {
1254 Form::String(value) => Ok(value.clone()),
1255 _ => Err(format!("{label} must be a string")),
1256 }
1257}
1258fn relative_path(value: &str, label: &str) -> Result<PathBuf, String> {
1259 let path = PathBuf::from(value);
1260 if path.components().any(|component| {
1261 matches!(
1262 component,
1263 Component::ParentDir | Component::RootDir | Component::Prefix(_)
1264 )
1265 }) {
1266 Err(format!(
1267 "project.edn :{label} cannot escape the project root"
1268 ))
1269 } else {
1270 Ok(path)
1271 }
1272}
1273fn paths(form: &Form, label: &str) -> Result<Vec<PathBuf>, String> {
1274 match form {
1275 Form::Vector(values) => values
1276 .iter()
1277 .map(|value| relative_path(&string(value, &format!("project.edn :{label}"))?, label))
1278 .collect(),
1279 _ => Err(format!("project.edn :{label} must be a vector of strings")),
1280 }
1281}
1282#[derive(Default)]
1283struct PackageConfig {
1284 workspace: bool,
1285 name: Option<String>,
1286 profile: Option<PathBuf>,
1287}
1288
1289fn package_config(form: &Form) -> Result<PackageConfig, String> {
1290 let entries = map(form, "project.edn :project/package must be an EDN map")?;
1291 let workspace = match lookup(entries, "workspace") {
1292 None | Some(Form::Bool(false)) => false,
1293 Some(Form::Bool(true)) => true,
1294 Some(_) => return Err("project.edn :project/package :workspace must be a boolean".into()),
1295 };
1296 let name = lookup(entries, "name")
1297 .map(|value| identifier(value, "project.edn :project/package :name"))
1298 .transpose()?;
1299 if name.as_deref().is_some_and(str::is_empty) {
1300 return Err("project.edn :project/package :name must be non-empty".into());
1301 }
1302 let profile = lookup(entries, "profile")
1303 .map(|value| {
1304 relative_path(
1305 &string(value, "project.edn :project/package :profile")?,
1306 "project/package/profile",
1307 )
1308 })
1309 .transpose()?;
1310 Ok(PackageConfig {
1311 workspace,
1312 name,
1313 profile,
1314 })
1315}
1316fn dependencies(form: &Form) -> Result<BTreeMap<String, String>, String> {
1317 let mut output = BTreeMap::new();
1318 for (key, value) in map(form, "project.edn :project/dependencies must be an EDN map")? {
1319 let coordinate = normalize_coordinate(&scalar(key, "dependency coordinate")?)?;
1320 let version = lookup(
1321 map(value, "dependency declaration must be an EDN map")?,
1322 "version",
1323 )
1324 .ok_or_else(|| format!("dependency {coordinate} is missing :version"))?;
1325 let version = string(version, "dependency :version")?;
1326 VersionReq::parse(&version)
1327 .map_err(|error| format!("invalid dependency range {version}: {error}"))?;
1328 output.insert(coordinate, version);
1329 }
1330 Ok(output)
1331}
1332pub fn normalize_coordinate(value: &str) -> Result<String, String> {
1333 let qualified = if let Some(package) = value.strip_prefix("official:") {
1334 format!("hara:{package}")
1335 } else if value.contains(':') {
1336 value.to_owned()
1337 } else {
1338 format!("hara:{value}")
1339 };
1340 let (tap, package) = qualified
1341 .split_once(':')
1342 .ok_or_else(|| format!("invalid package coordinate: {value}"))?;
1343 let mut parts = package.split('/');
1344 let valid = !tap.is_empty()
1345 && tap.chars().all(valid_coordinate_char)
1346 && matches!((parts.next(), parts.next(), parts.next()), (Some(owner), Some(name), None) if !owner.is_empty() && !name.is_empty() && owner.chars().all(valid_coordinate_char) && name.chars().all(valid_coordinate_char));
1347 if valid {
1348 Ok(qualified)
1349 } else {
1350 Err(format!("invalid package coordinate: {value}"))
1351 }
1352}
1353fn validate_coordinate(value: &str) -> Result<(), String> {
1354 normalize_coordinate(value).map(|_| ())
1355}
1356fn valid_coordinate_char(value: char) -> bool {
1357 value.is_ascii_lowercase() || value.is_ascii_digit() || matches!(value, '-' | '_' | '.')
1358}
1359fn valid_name(value: &str) -> bool {
1360 !value.is_empty()
1361 && value
1362 .chars()
1363 .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-')
1364}
1365fn io(error: std::io::Error) -> String {
1366 error.to_string()
1367}
1368
1369#[cfg(test)]
1370#[path = "project/tests.rs"]
1371mod tests;