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