Skip to main content

hara_native/
project.rs

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