1use crate::kernel::{parse, Form};
2
3#[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
150pub 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
190pub 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
203pub 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
236pub 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
263pub 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}