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