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