Skip to main content

hara_native/
package_catalog.rs

1use crate::kernel::{parse, Form};
2
3/// A Foundation-compatible namespace selector. Package names are semantic
4/// coordinates; selectors only describe which namespaces the coordinate owns.
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum PackageSelectorMode {
7    Base,
8    Complete,
9    Exclude(Vec<String>),
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct PackageSelector {
14    pub namespace: String,
15    pub mode: PackageSelectorMode,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PackageBundle {
20    pub path: String,
21    pub include: Vec<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PackageDefinition {
26    pub name: String,
27    pub description: Option<String>,
28    pub selectors: Vec<PackageSelector>,
29    pub dependencies: Vec<String>,
30    pub optional: Vec<String>,
31    pub bundles: Vec<PackageBundle>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LockedPackage {
36    pub coordinate: String,
37    pub name: Option<String>,
38    pub version: String,
39    pub tap: String,
40    pub registry_commit: String,
41    pub identity_revision: String,
42    pub archive_sha256: String,
43    pub namespaces: Vec<String>,
44    pub dependencies: Vec<String>,
45}
46
47pub fn catalog_from_lock(source: &str) -> Result<Vec<LockedPackage>, String> {
48    let document = parse(source)?;
49    let root = map(&document, "project.lock.edn must be an EDN map")?;
50    if !matches!(lookup(root, "lock/format"), Some(Form::String(version)) if version == "0.0.0-alpha")
51    {
52        return Err("project.lock.edn requires :lock/format \"0.0.0-alpha\"".into());
53    }
54    let packages = match lookup(root, "packages") {
55        Some(value) => map(value, "project.lock.edn :packages must be a map")?,
56        None => return Ok(Vec::new()),
57    };
58    let mut output = Vec::with_capacity(packages.len());
59    for (coordinate, descriptor) in packages {
60        let coordinate = scalar(coordinate, "locked package coordinate")?;
61        let descriptor = map(descriptor, "locked package descriptor must be a map")?;
62        let name = lookup_any(descriptor, &["name", "package/name"])
63            .map(|value| scalar(value, "locked package :name"))
64            .transpose()?;
65        let version = string(required(descriptor, "version")?, "locked package :version")?;
66        semver::Version::parse(&version)
67            .map_err(|error| format!("locked package {coordinate} has invalid version: {error}"))?;
68        let archive_sha256 = string(
69            required(descriptor, "archive-sha256")?,
70            "locked package :archive-sha256",
71        )?;
72        validate_sha256(&archive_sha256)?;
73        let tap = string(required(descriptor, "tap")?, "locked package :tap")?;
74        let registry_commit = string(
75            required(descriptor, "registry-commit")?,
76            "locked package :registry-commit",
77        )?;
78        validate_commit(&registry_commit, "registry-commit")?;
79        let identity_revision = string(
80            required(descriptor, "identity-revision")?,
81            "locked package :identity-revision",
82        )?;
83        validate_commit(&identity_revision, "identity-revision")?;
84        let namespaces = symbols(
85            required(descriptor, "namespaces")?,
86            "locked package :namespaces",
87        )?;
88        if namespaces.is_empty() {
89            return Err(format!("locked package {coordinate} exports no namespaces"));
90        }
91        let dependencies = match lookup(descriptor, "dependencies") {
92            Some(value) => map_keys(value, "locked package :dependencies")?,
93            None => Vec::new(),
94        };
95        output.push(LockedPackage {
96            coordinate,
97            name,
98            version,
99            tap,
100            registry_commit,
101            identity_revision,
102            archive_sha256,
103            namespaces,
104            dependencies,
105        });
106    }
107    output.sort_by(|left, right| left.coordinate.cmp(&right.coordinate));
108    let mut owners = std::collections::BTreeMap::new();
109    let mut names = std::collections::BTreeMap::new();
110    for package in &output {
111        if let Some(name) = &package.name {
112            if let Some(previous) = names.insert(name, &package.coordinate) {
113                return Err(format!(
114                    "package/name-conflict: {name} is used by {previous} and {}",
115                    package.coordinate
116                ));
117            }
118        }
119        for namespace in &package.namespaces {
120            if let Some(previous) = owners.insert(namespace, &package.coordinate) {
121                return Err(format!(
122                    "package/namespace-conflict: {namespace} is exported by {previous} and {}",
123                    package.coordinate
124                ));
125            }
126        }
127    }
128    let coordinates = output
129        .iter()
130        .map(|package| package.coordinate.as_str())
131        .collect::<std::collections::BTreeSet<_>>();
132    for package in &output {
133        for dependency in &package.dependencies {
134            if dependency == &package.coordinate {
135                return Err(format!(
136                    "package/dependency-cycle: {} depends on itself",
137                    package.coordinate
138                ));
139            }
140            if !coordinates.contains(dependency.as_str()) {
141                return Err(format!(
142                    "package/dependency-not-locked: {} requires {dependency}",
143                    package.coordinate
144                ));
145            }
146        }
147    }
148    Ok(output)
149}
150
151/// Reads the same direct package map used by Foundation's
152/// `config/packages.edn`. A `:packages` wrapper is accepted as a convenience
153/// for project-local profiles, but the selector and dependency semantics stay
154/// identical.
155pub fn definitions_from_packages_edn(source: &str) -> Result<Vec<PackageDefinition>, String> {
156    let document = parse(source)?;
157    let root = map(&document, "packages.edn must be an EDN map")?;
158    let packages = match lookup(root, "packages") {
159        Some(value) => map(value, "packages.edn :packages must be a map")?,
160        None => root,
161    };
162    let mut definitions = Vec::with_capacity(packages.len());
163    for (name, descriptor) in packages {
164        let name = profile_scalar(name, "package coordinate")?;
165        let descriptor = map(descriptor, "package descriptor must be a map")?;
166        let description = lookup(descriptor, "description")
167            .map(|value| string(value, "package :description"))
168            .transpose()?;
169        let selectors = parse_selectors(required(descriptor, "include")?, &name)?;
170        let dependencies = optional_identifiers(descriptor, "dependencies")?;
171        let optional = optional_identifiers(descriptor, "optional")?;
172        let bundles = parse_bundles(descriptor)?;
173        definitions.push(PackageDefinition {
174            name,
175            description,
176            selectors,
177            dependencies,
178            optional,
179            bundles,
180        });
181    }
182    definitions.sort_by(|left, right| left.name.cmp(&right.name));
183    for pair in definitions.windows(2) {
184        if pair[0].name == pair[1].name {
185            return Err(format!("package/duplicate-definition: {}", pair[0].name));
186        }
187    }
188    Ok(definitions)
189}
190
191/// Expands one Foundation selector against the namespaces available in a
192/// source tree. The result is sorted and contains no duplicate namespaces.
193pub fn expand_selector(selector: &PackageSelector, available: &[String]) -> Vec<String> {
194    let mut selected = available
195        .iter()
196        .filter(|namespace| selector_matches(selector, namespace))
197        .cloned()
198        .collect::<Vec<_>>();
199    selected.sort();
200    selected.dedup();
201    selected
202}
203
204/// Returns the exact namespace ownership implied by one semantic package.
205pub fn package_namespaces(package: &PackageDefinition, available: &[String]) -> Vec<String> {
206    let mut selected = package
207        .selectors
208        .iter()
209        .flat_map(|selector| expand_selector(selector, available))
210        .collect::<Vec<_>>();
211    selected.sort();
212    selected.dedup();
213    selected
214}
215
216pub fn find_package_definition<'a>(
217    definitions: &'a [PackageDefinition],
218    target: &str,
219) -> Result<&'a PackageDefinition, String> {
220    let matches = definitions
221        .iter()
222        .filter(|definition| {
223            definition.name == target
224                || definition
225                    .name
226                    .rsplit_once('/')
227                    .is_some_and(|(_, name)| name == target)
228        })
229        .collect::<Vec<_>>();
230    match matches.as_slice() {
231        [definition] => Ok(definition),
232        [] => Err(format!("package/not-defined: {target}")),
233        _ => Err(format!("package/ambiguous-definition: {target}")),
234    }
235}
236
237/// Validates that package definitions have deterministic, non-overlapping
238/// namespace ownership and an acyclic internal dependency graph. Unselected
239/// namespaces are allowed so that a profile can intentionally leave
240/// host-specific families out; dependencies absent from the profile remain
241/// external package dependencies.
242pub fn validate_package_definitions(
243    definitions: &[PackageDefinition],
244    available: &[String],
245) -> Result<(), String> {
246    let mut owners = std::collections::BTreeMap::new();
247    for package in definitions {
248        for namespace in package_namespaces(package, available) {
249            if let Some(previous) = owners.insert(namespace.clone(), package.name.clone()) {
250                return Err(format!(
251                    "package/namespace-overlap: {namespace} belongs to {previous} and {}",
252                    package.name
253                ));
254            }
255        }
256    }
257    let targets = definitions
258        .iter()
259        .map(|definition| definition.name.clone())
260        .collect::<Vec<_>>();
261    package_dependency_order(definitions, &targets).map(|_| ())
262}
263
264/// Computes a dependency-first order for selected semantic packages. Names
265/// not present in the profile are treated as external dependencies, matching
266/// Foundation's separation of internal and external dependency sets.
267pub fn package_dependency_order(
268    definitions: &[PackageDefinition],
269    targets: &[String],
270) -> Result<Vec<String>, String> {
271    let definitions = definitions
272        .iter()
273        .map(|definition| (definition.name.clone(), definition))
274        .collect::<std::collections::BTreeMap<_, _>>();
275    let mut ordered = Vec::new();
276    let mut visiting = std::collections::BTreeSet::new();
277    let mut visited = std::collections::BTreeSet::new();
278    for target in targets {
279        let target = resolve_definition_name(&definitions, target)?;
280        visit_package_dependency(
281            &target,
282            &definitions,
283            &mut visiting,
284            &mut visited,
285            &mut ordered,
286        )?;
287    }
288    Ok(ordered)
289}
290
291fn visit_package_dependency(
292    name: &str,
293    definitions: &std::collections::BTreeMap<String, &PackageDefinition>,
294    visiting: &mut std::collections::BTreeSet<String>,
295    visited: &mut std::collections::BTreeSet<String>,
296    ordered: &mut Vec<String>,
297) -> Result<(), String> {
298    if visited.contains(name) {
299        return Ok(());
300    }
301    if !visiting.insert(name.to_owned()) {
302        return Err(format!("package/dependency-cycle: {name}"));
303    }
304    let package = definitions
305        .get(name)
306        .ok_or_else(|| format!("package/not-defined: {name}"))?;
307    let mut dependencies = package
308        .dependencies
309        .iter()
310        .filter_map(|dependency| {
311            if definitions.contains_key(dependency.as_str()) {
312                return Some(Ok(dependency.clone()));
313            }
314            let suffix = dependency
315                .rsplit_once('/')
316                .map(|(_, name)| name)
317                .unwrap_or(dependency);
318            let matches = definitions
319                .keys()
320                .filter(|name| {
321                    name.as_str() == suffix
322                        || name.rsplit_once('/').map(|(_, name)| name) == Some(suffix)
323                })
324                .cloned()
325                .collect::<Vec<_>>();
326            match matches.as_slice() {
327                [] => None,
328                [name] => Some(Ok(name.clone())),
329                _ => Some(Err(format!(
330                    "package/ambiguous-dependency: {dependency} ({})",
331                    matches.join(",")
332                ))),
333            }
334        })
335        .collect::<Result<Vec<_>, _>>()
336        ?;
337    dependencies.sort();
338    dependencies.dedup();
339    for dependency in dependencies {
340        visit_package_dependency(&dependency, definitions, visiting, visited, ordered)?;
341    }
342    visiting.remove(name);
343    visited.insert(name.to_owned());
344    ordered.push(name.to_owned());
345    Ok(())
346}
347
348fn resolve_definition_name(
349    definitions: &std::collections::BTreeMap<String, &PackageDefinition>,
350    target: &str,
351) -> Result<String, String> {
352    resolve_optional_definition_name(definitions, target)
353        .ok_or_else(|| format!("package/not-defined: {target}"))
354}
355
356fn resolve_optional_definition_name(
357    definitions: &std::collections::BTreeMap<String, &PackageDefinition>,
358    target: &str,
359) -> Option<String> {
360    if definitions.contains_key(target) {
361        return Some(target.to_owned());
362    }
363    let suffix = target
364        .rsplit_once('/')
365        .map(|(_, name)| name)
366        .unwrap_or(target);
367    let matches = definitions
368        .keys()
369        .filter(|name| {
370            name.as_str() == suffix || name.rsplit_once('/').map(|(_, name)| name) == Some(suffix)
371        })
372        .cloned()
373        .collect::<Vec<_>>();
374    (matches.len() == 1).then(|| matches[0].clone())
375}
376
377fn parse_selectors(form: &Form, package: &str) -> Result<Vec<PackageSelector>, String> {
378    let values = sequence(form, &format!("package {package} :include"))?;
379    values
380        .iter()
381        .enumerate()
382        .map(|(index, value)| {
383            let parts = sequence(value, &format!("package {package} selector {index}"))?;
384            let namespace = profile_scalar(
385                parts
386                    .first()
387                    .ok_or_else(|| format!("package {package} selector {index} is empty"))?,
388                "package selector namespace",
389            )?;
390            let mode = parts
391                .get(1)
392                .ok_or_else(|| format!("package {package} selector {index} is missing a mode"))?;
393            let mode = profile_scalar(mode, "package selector mode")?;
394            match mode.as_str() {
395                "base" if parts.len() == 2 => Ok(PackageSelector {
396                    namespace,
397                    mode: PackageSelectorMode::Base,
398                }),
399                "complete" if parts.len() == 2 => Ok(PackageSelector {
400                    namespace,
401                    mode: PackageSelectorMode::Complete,
402                }),
403                "exclude" if parts.len() == 3 => {
404                    let excluded = sequence(&parts[2], "package selector exclusions")?
405                        .iter()
406                        .map(|value| profile_scalar(value, "package selector exclusion"))
407                        .collect::<Result<Vec<_>, _>>()?;
408                    Ok(PackageSelector {
409                        namespace,
410                        mode: PackageSelectorMode::Exclude(excluded),
411                    })
412                }
413                "base" | "complete" | "exclude" => Err(format!(
414                    "package {package} selector {index} has invalid arity for :{mode}"
415                )),
416                _ => Err(format!(
417                    "package {package} selector {index} has unsupported mode :{mode}"
418                )),
419            }
420        })
421        .collect()
422}
423
424fn parse_bundles(descriptor: &[(Form, Form)]) -> Result<Vec<PackageBundle>, String> {
425    let Some(form) = lookup(descriptor, "bundle") else {
426        return Ok(Vec::new());
427    };
428    sequence(form, "package :bundle")?
429        .iter()
430        .enumerate()
431        .map(|(index, form)| {
432            let entries = map(form, "package bundle must be a map")?;
433            let path = string(required(entries, "path")?, "package bundle :path")?;
434            let include = sequence(required(entries, "include")?, "package bundle :include")?
435                .iter()
436                .map(|value| string(value, "package bundle include path"))
437                .collect::<Result<Vec<_>, _>>()?;
438            if path.is_empty() {
439                return Err(format!("package bundle {index} has an empty path"));
440            }
441            Ok(PackageBundle { path, include })
442        })
443        .collect()
444}
445
446fn optional_identifiers(descriptor: &[(Form, Form)], key: &str) -> Result<Vec<String>, String> {
447    let Some(form) = lookup(descriptor, key) else {
448        return Ok(Vec::new());
449    };
450    let mut values = sequence(form, &format!("package :{key}"))?
451        .iter()
452        .map(|value| profile_scalar(value, &format!("package :{key}")))
453        .collect::<Result<Vec<_>, _>>()?;
454    values.sort();
455    values.dedup();
456    Ok(values)
457}
458
459fn sequence<'a>(form: &'a Form, label: &str) -> Result<&'a Vec<Form>, String> {
460    match form {
461        Form::Vector(values) | Form::List(values) => Ok(values),
462        _ => Err(format!("{label} must be a vector or list")),
463    }
464}
465
466fn profile_scalar(form: &Form, label: &str) -> Result<String, String> {
467    match form {
468        Form::String(value) | Form::Symbol(value) | Form::Keyword(value) if !value.is_empty() => {
469            Ok(value.clone())
470        }
471        _ => Err(format!(
472            "{label} must be a non-empty string, symbol, or keyword"
473        )),
474    }
475}
476
477fn selector_matches(selector: &PackageSelector, namespace: &str) -> bool {
478    let within = |root: &str| namespace == root || namespace.starts_with(&format!("{root}."));
479    match &selector.mode {
480        PackageSelectorMode::Complete => within(&selector.namespace),
481        PackageSelectorMode::Base => {
482            namespace == selector.namespace
483                || namespace.starts_with(&format!("{}.base.", selector.namespace))
484        }
485        PackageSelectorMode::Exclude(excluded) => {
486            within(&selector.namespace) && !excluded.iter().any(|root| within(root))
487        }
488    }
489}
490
491fn map<'a>(form: &'a Form, message: &str) -> Result<&'a Vec<(Form, Form)>, String> {
492    match form {
493        Form::Map(entries) => Ok(entries),
494        _ => Err(message.into()),
495    }
496}
497fn lookup<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
498    entries.iter().find_map(|(candidate, value)| {
499        matches!(candidate, Form::Keyword(name) if name == key).then_some(value)
500    })
501}
502fn lookup_any<'a>(entries: &'a [(Form, Form)], keys: &[&str]) -> Option<&'a Form> {
503    keys.iter().find_map(|key| lookup(entries, key))
504}
505fn required<'a>(entries: &'a [(Form, Form)], key: &str) -> Result<&'a Form, String> {
506    lookup(entries, key).ok_or_else(|| format!("locked package is missing :{key}"))
507}
508fn string(form: &Form, label: &str) -> Result<String, String> {
509    match form {
510        Form::String(value) => Ok(value.clone()),
511        _ => Err(format!("{label} must be a string")),
512    }
513}
514fn scalar(form: &Form, label: &str) -> Result<String, String> {
515    match form {
516        Form::String(value) | Form::Symbol(value) => Ok(value.clone()),
517        _ => Err(format!("{label} must be a string or symbol")),
518    }
519}
520fn symbols(form: &Form, label: &str) -> Result<Vec<String>, String> {
521    let Form::Vector(values) = form else {
522        return Err(format!("{label} must be a vector"));
523    };
524    let mut output = values
525        .iter()
526        .map(|value| scalar(value, label))
527        .collect::<Result<Vec<_>, _>>()?;
528    output.sort();
529    output.dedup();
530    Ok(output)
531}
532fn map_keys(form: &Form, label: &str) -> Result<Vec<String>, String> {
533    let entries = map(form, &format!("{label} must be a map"))?;
534    let mut output = entries
535        .iter()
536        .map(|(key, _)| scalar(key, label))
537        .collect::<Result<Vec<_>, _>>()?;
538    output.sort();
539    output.dedup();
540    Ok(output)
541}
542fn validate_sha256(value: &str) -> Result<(), String> {
543    let value = value.strip_prefix("sha256:").unwrap_or(value);
544    if value.len() == 64 && value.chars().all(|value| value.is_ascii_hexdigit()) {
545        Ok(())
546    } else {
547        Err("locked package :archive-sha256 must be SHA-256".into())
548    }
549}
550fn validate_commit(value: &str, label: &str) -> Result<(), String> {
551    if value.len() == 40
552        && value
553            .chars()
554            .all(|value| value.is_ascii_hexdigit() && !value.is_ascii_uppercase())
555    {
556        Ok(())
557    } else {
558        Err(format!(
559            "locked package :{label} must be a lowercase 40-character Git commit"
560        ))
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn reads_exact_lock_catalog_and_rejects_namespace_conflicts() {
570        let digest = format!("sha256:{}", "a".repeat(64));
571        let commit = "b".repeat(40);
572        let source = format!("{{:lock/format \"0.0.0-alpha\" :packages {{\"hara:demo/base\" {{:version \"1.0.0\" :tap \"hara\" :registry-commit \"{commit}\" :identity-revision \"{commit}\" :archive-sha256 \"{digest}\" :namespaces [demo.base]}} \"hara:demo/core\" {{:version \"1.2.3\" :tap \"hara\" :registry-commit \"{commit}\" :identity-revision \"{commit}\" :archive-sha256 \"{digest}\" :namespaces [demo.core demo.util] :dependencies {{\"hara:demo/base\" \"1.0.0\"}}}}}}}}");
573        let catalog = catalog_from_lock(&source).unwrap();
574        assert_eq!(catalog[1].namespaces, vec!["demo.core", "demo.util"]);
575        assert_eq!(catalog[1].dependencies, vec!["hara:demo/base"]);
576    }
577
578    #[test]
579    fn accepts_package_name_aliases_and_rejects_duplicate_semantic_names() {
580        let digest = format!("sha256:{}", "a".repeat(64));
581        let commit = "b".repeat(40);
582        let source = format!(
583            "{{:lock/format \"0.0.0-alpha\" :packages {{\"hara:demo/one\" {{:package/name \"demo.shared\" :version \"1.0.0\" :tap \"hara\" :registry-commit \"{commit}\" :identity-revision \"{commit}\" :archive-sha256 \"{digest}\" :namespaces [demo.one]}} \"hara:demo/two\" {{:name \"demo.shared\" :version \"1.0.0\" :tap \"hara\" :registry-commit \"{commit}\" :identity-revision \"{commit}\" :archive-sha256 \"{digest}\" :namespaces [demo.two]}}}}}}"
584        );
585        let error = catalog_from_lock(&source).unwrap_err();
586        assert!(error.contains("package/name-conflict"), "{error}");
587    }
588
589    #[test]
590    fn reads_foundation_selectors_and_expands_semantic_packages() {
591        let source = r#"{
592          xyz.zcaudate/code.test {:description "tests" :include [[code.test :complete]]}
593          lang.model.v1.postgres {:include [[lang.model.v1.spec-postgres :complete]
594                                            [postgres.core :complete]
595                                            [postgres.typed :complete]
596                                            [postgres.gen :complete]]
597                                  :dependencies [lang.base]}
598          lang.base {:include [[lang.base :complete]]}
599        }"#;
600        let definitions = definitions_from_packages_edn(source).unwrap();
601        assert_eq!(definitions[0].name, "lang.base");
602        let postgres = definitions
603            .iter()
604            .find(|definition| definition.name == "lang.model.v1.postgres")
605            .unwrap();
606        let available = vec![
607            "lang.model.v1.spec-postgres".into(),
608            "lang.model.v1.spec-postgres.deftype.common".into(),
609            "postgres.core".into(),
610            "postgres.core.graph".into(),
611            "postgres.typed".into(),
612            "postgres.gen.rpc".into(),
613            "db.postgres".into(),
614        ];
615        assert_eq!(
616            package_namespaces(postgres, &available),
617            vec![
618                "lang.model.v1.spec-postgres".to_owned(),
619                "lang.model.v1.spec-postgres.deftype.common".to_owned(),
620                "postgres.core".to_owned(),
621                "postgres.core.graph".to_owned(),
622                "postgres.gen.rpc".to_owned(),
623                "postgres.typed".to_owned(),
624            ]
625        );
626        validate_package_definitions(&definitions, &available).unwrap();
627        assert_eq!(
628            package_dependency_order(&definitions, &["lang.model.v1.postgres".to_owned()]).unwrap(),
629            vec!["lang.base", "lang.model.v1.postgres"]
630        );
631    }
632
633    #[test]
634    fn foundation_base_and_exclude_selectors_follow_namespace_boundaries() {
635        let source = r#"{
636          demo {:include [[demo :base]]}
637          other {:include [[other :exclude [other.internal]]]}
638        }"#;
639        let definitions = definitions_from_packages_edn(source).unwrap();
640        let available = vec![
641            "demo".into(),
642            "demo.base".into(),
643            "demo.base.value".into(),
644            "demo.extra".into(),
645            "other".into(),
646            "other.internal".into(),
647            "other.internal.deep".into(),
648            "other.public".into(),
649        ];
650        assert_eq!(
651            package_namespaces(&definitions[0], &available),
652            vec!["demo".to_owned(), "demo.base.value".to_owned()]
653        );
654        assert_eq!(
655            package_namespaces(&definitions[1], &available),
656            vec!["other".to_owned(), "other.public".to_owned()]
657        );
658    }
659
660    #[test]
661    fn rejects_overlapping_semantic_package_ownership_and_cycles() {
662        let source = r#"{
663          a {:include [[demo :complete]] :dependencies [b]}
664          b {:include [[other :complete]] :dependencies [a]}
665          c {:include [[demo.child :complete]]}
666        }"#;
667        let definitions = definitions_from_packages_edn(source).unwrap();
668        let available = vec!["demo".into(), "demo.child".into(), "other".into()];
669        let error = validate_package_definitions(&definitions, &available).unwrap_err();
670        assert!(error.contains("package/namespace-overlap"), "{error}");
671        let error = package_dependency_order(&definitions, &["a".into()]).unwrap_err();
672        assert!(error.contains("package/dependency-cycle"), "{error}");
673    }
674
675    #[test]
676    fn resolves_unique_tap_suffixes_but_rejects_ambiguous_dependencies() {
677        let source = r#"{
678          xyz.zcaudate/base {:include [[demo.base :complete]]}
679          xyz.zcaudate/app {:include [[demo.app :complete]] :dependencies [base]}
680        }"#;
681        let definitions = definitions_from_packages_edn(source).unwrap();
682        assert_eq!(
683            package_dependency_order(&definitions, &["app".into()]).unwrap(),
684            vec!["xyz.zcaudate/base", "xyz.zcaudate/app"]
685        );
686
687        let source = r#"{
688          one/base {:include [[demo.one :complete]]}
689          two/base {:include [[demo.two :complete]]}
690          app {:include [[demo.app :complete]] :dependencies [base]}
691        }"#;
692        let definitions = definitions_from_packages_edn(source).unwrap();
693        let error = package_dependency_order(&definitions, &["app".into()]).unwrap_err();
694        assert!(error.contains("package/ambiguous-dependency"), "{error}");
695    }
696}