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