1use std::collections::{BTreeMap, BTreeSet, HashMap};
22use std::path::{Component, Path, PathBuf};
23
24use crate::discovery::read_source;
25use crate::manifest::{DependencySource, DependencySpec, dependencies_from, package_config_from};
26
27#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct PackageId {
30 pub org: String,
31 pub package: String,
32}
33
34impl PackageId {
35 pub fn name(&self) -> String {
37 format!("{}/{}", self.org, self.package)
38 }
39
40 pub fn encoded(&self) -> String {
45 format!("${}_{}", self.org.replace('-', "$"), self.package)
46 }
47
48 pub fn out_dir(&self) -> PathBuf {
50 PathBuf::from("deps").join(&self.org).join(&self.package)
51 }
52
53 pub fn canonical(&self, unit: &str) -> String {
55 format!("{}.{unit}", self.encoded())
56 }
57
58 pub fn decode(segment: &str) -> Option<PackageId> {
61 let rest = segment.strip_prefix('$')?;
62 let (org, package) = rest.split_once('_')?;
63 if org.is_empty() || package.is_empty() {
64 return None;
65 }
66 Some(PackageId {
67 org: org.replace('$', "-"),
68 package: package.to_owned(),
69 })
70 }
71}
72
73pub fn split_canonical(name: &str) -> Option<(PackageId, &str)> {
76 let (first, rest) = name.split_once('.')?;
77 Some((PackageId::decode(first)?, rest))
78}
79
80pub fn display_unit_name(name: &str) -> String {
83 match split_canonical(name) {
84 Some((id, unit)) => format!("{}:{unit}", id.name()),
85 None => name.to_owned(),
86 }
87}
88
89pub fn display_names(text: &str) -> String {
93 if !text.contains('$') {
94 return text.to_owned();
95 }
96 let mut out = String::with_capacity(text.len());
97 let mut rest = text;
98 while let Some(at) = rest.find('$') {
99 out.push_str(&rest[..at]);
100 let tail = &rest[at..];
101 let end = tail[1..]
103 .find(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '$'))
104 .map_or(tail.len(), |i| i + 1);
105 let segment = &tail[..end];
106 match PackageId::decode(segment) {
107 Some(id) if tail[end..].starts_with('.') => {
108 out.push_str(&id.name());
109 out.push(':');
110 rest = &tail[end + 1..];
111 }
112 Some(id) => {
113 out.push_str(&id.name());
114 rest = &tail[end..];
115 }
116 None => {
117 out.push('$');
118 rest = &tail[1..];
119 }
120 }
121 }
122 out.push_str(rest);
123 out
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Package {
129 pub id: PackageId,
130 pub dir: PathBuf,
132 pub trees: Vec<(PathBuf, PathBuf)>,
137 pub exports: Option<Vec<String>>,
140 pub aliases: BTreeMap<String, usize>,
143 pub fetched: bool,
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq)]
151pub struct PackageGraph {
152 pub root_aliases: BTreeMap<String, usize>,
154 pub dev_aliases: BTreeSet<String>,
157 pub unresolved_aliases: BTreeSet<String>,
161 pub packages: Vec<Package>,
162 pub npm_approved: BTreeSet<(String, String)>,
165}
166
167impl PackageGraph {
168 pub fn trees(&self) -> Vec<(PathBuf, PathBuf)> {
170 self.packages
171 .iter()
172 .flat_map(|p| p.trees.iter().cloned())
173 .collect()
174 }
175
176 pub fn package_of(&self, path: &Path) -> Option<usize> {
183 let path = lexical(&std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()));
184 self.packages.iter().position(|p| {
185 p.trees.iter().any(|(root, _)| {
186 let root = lexical(&std::path::absolute(root).unwrap_or_else(|_| root.clone()));
187 path.starts_with(&root)
188 })
189 })
190 }
191
192 pub fn chain_to(&self, target: usize) -> Vec<String> {
195 let mut parent: Vec<Option<Option<usize>>> = vec![None; self.packages.len()];
196 let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
197 for &i in self.root_aliases.values() {
198 if parent[i].is_none() {
199 parent[i] = Some(None);
200 queue.push_back(i);
201 }
202 }
203 while let Some(i) = queue.pop_front() {
204 for &j in self.packages[i].aliases.values() {
205 if parent[j].is_none() {
206 parent[j] = Some(Some(i));
207 queue.push_back(j);
208 }
209 }
210 }
211 let mut chain = Vec::new();
212 let mut at = Some(target);
213 while let Some(i) = at {
214 chain.push(self.packages[i].id.name());
215 at = parent[i].flatten();
216 if chain.len() > self.packages.len() {
217 break;
218 }
219 }
220 chain.reverse();
221 chain
222 }
223
224 pub fn by_id(&self, id: &PackageId) -> Option<usize> {
226 self.packages.iter().position(|p| &p.id == id)
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct GraphProblem {
234 pub code: &'static str,
235 pub message: String,
236 pub note: Option<String>,
237}
238
239impl GraphProblem {
240 fn new(code: &'static str, message: String) -> Self {
241 GraphProblem {
242 code,
243 message,
244 note: None,
245 }
246 }
247
248 fn with_note(mut self, note: impl Into<String>) -> Self {
249 self.note = Some(note.into());
250 self
251 }
252}
253
254pub const DEPENDENCY_NOT_FOUND: &str = "bynk.package.dependency_not_found";
256pub const NAME_MISMATCH: &str = "bynk.package.name_mismatch";
258pub const DUPLICATE_SOURCE: &str = "bynk.package.duplicate_source";
260pub const DEPENDENCY_CYCLE: &str = "bynk.package.dependency_cycle";
262pub const PATH_OUTSIDE_ROOT: &str = "bynk.dependency.path_outside_root";
264pub const NOT_FETCHED: &str = "bynk.package.not_fetched";
267pub const VERSION_CONFLICT: &str = "bynk.package.version_conflict";
269pub const LOCK_INVALID: &str = "bynk.package.lock_invalid";
271pub const SOURCE_MISMATCH: &str = "bynk.registry.source_mismatch";
273pub const UNAVAILABLE: &str = "bynk.registry.unavailable";
276
277pub fn routes_of(root: &Path, overlay: &HashMap<PathBuf, String>) -> BTreeMap<String, String> {
280 let mut out = BTreeMap::new();
281 let Some((doc, _)) = read_manifest(root, overlay) else {
282 return out;
283 };
284 let Some(table) = doc.get("registries").and_then(|v| v.as_table()) else {
285 return out;
286 };
287 for r in crate::manifest::registries_from(table).unwrap_or_default() {
288 for org in r.organisations {
289 out.insert(org, r.name.clone());
290 }
291 }
292 out
293}
294
295pub fn resolve_graph(
304 project_root: &Path,
305 overlay: &HashMap<PathBuf, String>,
306) -> (Option<PackageGraph>, Vec<GraphProblem>) {
307 let mut problems = Vec::new();
308 let Some(root_doc) = read_manifest(project_root, overlay) else {
309 return (None, problems);
310 };
311 let deps = specs_of(&root_doc);
312 let dev = dev_specs_of(&root_doc);
313 if deps.is_empty() && dev.is_empty() {
314 return (None, problems);
315 }
316 let normal: BTreeSet<String> = deps.iter().map(|s| s.alias.clone()).collect();
317 let root_specs: Vec<(DependencySpec, bool)> = deps
318 .into_iter()
319 .map(|s| (s, false))
320 .chain(
321 dev.into_iter()
322 .filter(|s| !normal.contains(&s.alias))
323 .map(|s| (s, true)),
324 )
325 .collect();
326 let lock = match crate::lock::read_lock(&crate::lock::lock_root(project_root), overlay) {
328 Ok(lock) => lock,
329 Err(e) => {
330 problems.push(GraphProblem::new(LOCK_INVALID, e.0));
331 None
332 }
333 };
334 let home = crate::lock::bynk_home();
335 let routes = routes_of(&crate::lock::lock_root(project_root), overlay);
338 let workspace = crate::workspace::find_parent_workspace(project_root).or_else(|| {
339 crate::workspace::is_workspace_root(project_root).then(|| project_root.to_path_buf())
340 });
341 let workspace = workspace.map(|w| real(&w));
342 let root_real = real(project_root);
343
344 let mut graph = PackageGraph {
345 npm_approved: lock
346 .iter()
347 .flat_map(|l| &l.npm)
348 .filter(|n| n.approved)
349 .map(|n| (n.package.clone(), n.range.clone()))
350 .collect(),
351 ..PackageGraph::default()
352 };
353 let mut dirs: Vec<PathBuf> = Vec::new();
355 let mut queue: Vec<(Option<usize>, PathBuf, DependencySpec, bool)> = root_specs
357 .into_iter()
358 .map(|(s, dev)| (None, project_root.to_path_buf(), s, dev))
359 .collect();
360 let mut cursor = 0;
361 while cursor < queue.len() {
362 let (declarer, declarer_dir, spec, dev) = queue[cursor].clone();
363 cursor += 1;
364 let declarer_fetched = declarer.is_some_and(|i| graph.packages[i].fetched);
365 let who = match declarer {
366 None => "this project".to_string(),
367 Some(i) => format!("dependency `{}`", graph.packages[i].id.name()),
368 };
369 let mut fetched = false;
370 let (dir, rel) = match &spec.source {
371 DependencySource::Path(p) => {
372 if declarer_fetched {
373 problems.push(GraphProblem::new(
374 PATH_OUTSIDE_ROOT,
375 format!(
376 "{who} declares the path dependency `{}` (`{p}`), and a fetched package's path dependencies are never honoured",
377 spec.alias
378 ),
379 ));
380 continue;
381 }
382 (lexical_join(&declarer_dir, Path::new(p)), p.clone())
383 }
384 DependencySource::Git { url, rev } => {
385 if declarer_fetched && url.to_ascii_lowercase().starts_with("file:") {
386 problems.push(GraphProblem::new(
387 PATH_OUTSIDE_ROOT,
388 format!(
389 "{who} declares the git dependency `{}` at the local URL `{url}`, and a fetched package can't reach a local repository",
390 spec.alias
391 ),
392 ));
393 continue;
394 }
395 let store = lock
396 .as_ref()
397 .and_then(|l| l.git(&spec.package, url, rev))
398 .and_then(|(entry, _)| {
399 crate::lock::store_dir(home.as_deref()?, entry.checksum.as_deref()?)
400 })
401 .filter(|d| d.is_dir());
402 let Some(store) = store else {
403 problems.push(GraphProblem::new(
404 NOT_FETCHED,
405 format!(
406 "{who}'s git dependency `{}` (`{url}` at `{rev}`) hasn't been fetched",
407 spec.alias
408 ),
409 ).with_note("run `bynk fetch` (or any `bynk` command without `--offline`) to fetch it and update `bynk.lock`"));
410 continue;
411 };
412 fetched = true;
413 (store, format!("{url} at {rev}"))
414 }
415 DependencySource::Version(range) => {
416 let org = spec.package.split('/').next().unwrap_or_default();
419 let routed = routes
420 .get(org)
421 .cloned()
422 .unwrap_or_else(|| crate::lock::FIRST_PARTY_REGISTRY.to_string());
423 let locked = lock.as_ref().and_then(|l| l.package(&spec.package));
424 let locked_registry =
425 locked.and_then(|p| crate::lock::parse_registry_source(&p.source));
426 if let Some(other) = locked_registry
427 && other != routed
428 {
429 problems.push(GraphProblem::new(
430 SOURCE_MISMATCH,
431 format!(
432 "`bynk.lock` locks `{}` to the registry `{other}`, but its organisation `{org}` now routes to `{routed}`",
433 spec.package
434 ),
435 ).with_note("a misrouted organisation fails rather than resolving a same-named package elsewhere: restore the `[registries]` entry, or delete the lock entry and run `bynk update` to move it deliberately"));
436 continue;
437 }
438 let store = locked
439 .filter(|_| locked_registry.is_some())
440 .and_then(|p| crate::lock::store_dir(home.as_deref()?, p.checksum.as_deref()?))
441 .filter(|d| d.is_dir());
442 let Some(store) = store else {
443 if routed == crate::lock::FIRST_PARTY_REGISTRY
444 && crate::lock::FIRST_PARTY.is_none()
445 {
446 problems.push(GraphProblem::new(
447 UNAVAILABLE,
448 format!(
449 "{who}'s dependency `{}` is in the organisation `{org}`, which no registry serves — the first-party registry isn't live yet",
450 spec.alias
451 ),
452 ).with_note(format!("add a `[registries.<name>]` whose `organisations` lists `{org}`")));
453 } else {
454 problems.push(GraphProblem::new(
455 NOT_FETCHED,
456 format!(
457 "{who}'s registry dependency `{}` (`{}` at `{range}`, from `{routed}`) hasn't been fetched",
458 spec.alias, spec.package
459 ),
460 ).with_note("run `bynk fetch` (or any `bynk` command without `--offline`) to fetch it and update `bynk.lock`"));
461 }
462 continue;
463 };
464 fetched = true;
465 (store, format!("{} {range} from {routed}", spec.package))
466 }
467 };
468 let dir_real = real(&dir);
469 if declarer.is_some() && !fetched {
470 let inside = workspace.as_ref().is_some_and(|w| dir_real.starts_with(w));
471 if !inside {
472 problems.push(GraphProblem::new(
473 PATH_OUTSIDE_ROOT,
474 format!(
475 "{who} declares the path dependency `{}` (`{rel}`), which is outside {}",
476 spec.alias,
477 if workspace.is_some() { "the workspace" } else { "any workspace" }
478 ),
479 ).with_note("a dependency's own path dependencies are honoured only between members of one workspace"));
480 continue;
481 }
482 }
483 if dir_real == root_real {
484 problems.push(GraphProblem::new(
485 DEPENDENCY_CYCLE,
486 format!(
487 "{who}'s dependency `{}` is this project itself, so the package graph has a cycle",
488 spec.alias
489 ),
490 ));
491 continue;
492 }
493 let index = if let Some(existing) = dirs.iter().position(|d| *d == dir_real) {
494 let id = &graph.packages[existing].id;
495 if id.name() != spec.package {
496 problems.push(GraphProblem::new(
497 NAME_MISMATCH,
498 format!(
499 "{who}'s dependency `{}` declares `package = \"{}\"`, but `{rel}` is the package `{}`",
500 spec.alias,
501 spec.package,
502 id.name()
503 ),
504 ));
505 continue;
506 }
507 existing
508 } else {
509 let Some(doc) = read_manifest(&dir, overlay) else {
510 problems.push(GraphProblem::new(
511 DEPENDENCY_NOT_FOUND,
512 format!(
513 "{who}'s dependency `{}` points at `{rel}`, which has no readable `bynk.toml`",
514 spec.alias
515 ),
516 ));
517 continue;
518 };
519 if let Err(e) = crate::check_manifest_str(&doc.1) {
520 problems.push(GraphProblem::new(
521 DEPENDENCY_NOT_FOUND,
522 format!(
523 "dependency `{}`'s `bynk.toml` is invalid: {e}",
524 spec.package
525 ),
526 ));
527 continue;
528 }
529 let Some(config) = doc
530 .0
531 .get("package")
532 .and_then(|v| v.as_table())
533 .and_then(|t| package_config_from(t).ok())
534 else {
535 problems.push(GraphProblem::new(
536 DEPENDENCY_NOT_FOUND,
537 format!(
538 "{who}'s dependency `{}` points at `{rel}`, which isn't a package: its `bynk.toml` has no `[package]`",
539 spec.alias
540 ),
541 ));
542 continue;
543 };
544 let id = PackageId {
545 org: config.org.clone(),
546 package: config.package.clone(),
547 };
548 if id.name() != spec.package {
549 problems.push(GraphProblem::new(
550 NAME_MISMATCH,
551 format!(
552 "{who}'s dependency `{}` declares `package = \"{}\"`, but `{rel}` is the package `{}`",
553 spec.alias,
554 spec.package,
555 id.name()
556 ),
557 ));
558 continue;
559 }
560 if let Some(other) = graph.by_id(&id)
561 && (fetched || graph.packages[other].fetched)
562 {
563 problems.push(GraphProblem::new(
564 VERSION_CONFLICT,
565 format!(
566 "the package `{}` is reached at two versions: `{}` and `{}`",
567 id.name(),
568 graph.packages[other].dir.display(),
569 dir.display()
570 ),
571 ).with_note("a build holds one copy of each package; point every dependency on it at the same revision"));
572 continue;
573 }
574 if let Some(other) = graph.by_id(&id) {
575 problems.push(GraphProblem::new(
576 DUPLICATE_SOURCE,
577 format!(
578 "the package `{}` is reached from two directories: `{}` and `{}`",
579 id.name(),
580 graph.packages[other].dir.display(),
581 dir.display()
582 ),
583 ).with_note("a build holds one copy of each package; point every dependency on it at the same directory"));
584 continue;
585 }
586 let trees = source_trees(project_root, &dir, &doc.0, fetched);
587 graph.packages.push(Package {
588 id,
589 dir: dir.clone(),
590 trees,
591 exports: config.exports,
592 aliases: BTreeMap::new(),
593 fetched,
594 });
595 dirs.push(dir_real.clone());
596 let index = graph.packages.len() - 1;
597 for s in specs_of(&doc) {
598 queue.push((Some(index), dir.clone(), s, false));
599 }
600 index
601 };
602 match declarer {
603 None => {
604 graph.root_aliases.insert(spec.alias.clone(), index);
605 if dev {
606 graph.dev_aliases.insert(spec.alias.clone());
607 }
608 }
609 Some(i) => {
610 graph.packages[i].aliases.insert(spec.alias.clone(), index);
611 }
612 }
613 }
614 for (_, _, spec, _) in queue.iter().filter(|(d, ..)| d.is_none()) {
615 if !graph.root_aliases.contains_key(&spec.alias) {
616 graph.unresolved_aliases.insert(spec.alias.clone());
617 }
618 }
619 if let Some(cycle) = find_cycle(&graph) {
620 problems.push(GraphProblem::new(
621 DEPENDENCY_CYCLE,
622 format!(
623 "the package graph has a cycle: {}",
624 cycle
625 .iter()
626 .map(|&i| format!("`{}`", graph.packages[i].id.name()))
627 .collect::<Vec<_>>()
628 .join(" → ")
629 ),
630 ));
631 }
632 if graph.packages.is_empty()
633 && graph.root_aliases.is_empty()
634 && graph.unresolved_aliases.is_empty()
635 {
636 return (None, problems);
637 }
638 (Some(graph), problems)
639}
640
641pub fn dependency_trees(
645 project_root: &Path,
646 overlay: &HashMap<PathBuf, String>,
647) -> Vec<(PathBuf, PathBuf)> {
648 resolve_graph(project_root, overlay)
649 .0
650 .map(|g| g.trees())
651 .unwrap_or_default()
652}
653
654fn read_manifest(dir: &Path, overlay: &HashMap<PathBuf, String>) -> Option<(toml::Table, String)> {
657 let path = dir.join("bynk.toml");
658 let text = match read_source(&path, overlay) {
659 Ok(text) => text,
660 Err(_) => std::fs::read_to_string(&path).ok()?,
661 };
662 let doc = text.parse::<toml::Table>().ok()?;
663 Some((doc, text))
664}
665
666fn specs_of(doc: &(toml::Table, String)) -> Vec<DependencySpec> {
669 doc.0
670 .get("dependencies")
671 .and_then(|v| v.as_table())
672 .and_then(|t| dependencies_from("dependencies", t).ok())
673 .unwrap_or_default()
674}
675
676fn dev_specs_of(doc: &(toml::Table, String)) -> Vec<DependencySpec> {
679 doc.0
680 .get("dev-dependencies")
681 .and_then(|v| v.as_table())
682 .and_then(|t| dependencies_from("dev-dependencies", t).ok())
683 .unwrap_or_default()
684}
685
686fn source_trees(
689 project_root: &Path,
690 dir: &Path,
691 doc: &toml::Table,
692 fetched: bool,
693) -> Vec<(PathBuf, PathBuf)> {
694 let include: Vec<String> = match doc
695 .get("paths")
696 .and_then(|v| v.as_table())
697 .and_then(|t| t.get("include"))
698 {
699 Some(toml::Value::Array(items)) => items
700 .iter()
701 .filter_map(|v| v.as_str().map(str::to_owned))
702 .collect(),
703 Some(toml::Value::String(s)) => vec![s.clone()],
704 _ => vec!["src".to_string()],
705 };
706 let mut out: Vec<(PathBuf, PathBuf)> = Vec::new();
707 for entry in include {
708 if Path::new(&entry).file_name().is_some_and(|n| n == "tests") {
709 continue;
710 }
711 let root = lexical_join(dir, Path::new(&entry));
712 if out.iter().any(|(r, _)| *r == root) {
713 continue;
714 }
715 let prefix = if fetched {
718 root.clone()
719 } else {
720 relative_to(project_root, &root)
721 };
722 out.push((root, prefix));
723 }
724 out
725}
726
727pub fn package_source_roots(dir: &Path, doc: &toml::Table) -> Vec<PathBuf> {
730 source_trees(dir, dir, doc, true)
731 .into_iter()
732 .map(|(root, _)| root)
733 .collect()
734}
735
736fn find_cycle(graph: &PackageGraph) -> Option<Vec<usize>> {
738 fn visit(
739 graph: &PackageGraph,
740 node: usize,
741 state: &mut Vec<u8>,
742 stack: &mut Vec<usize>,
743 ) -> Option<Vec<usize>> {
744 state[node] = 1;
745 stack.push(node);
746 for &next in graph.packages[node].aliases.values() {
747 if state[next] == 1 {
748 let start = stack.iter().position(|&n| n == next).unwrap_or(0);
749 let mut cycle = stack[start..].to_vec();
750 cycle.push(next);
751 return Some(cycle);
752 }
753 if state[next] == 0
754 && let Some(c) = visit(graph, next, state, stack)
755 {
756 return Some(c);
757 }
758 }
759 stack.pop();
760 state[node] = 2;
761 None
762 }
763 let mut state = vec![0u8; graph.packages.len()];
764 for start in 0..graph.packages.len() {
765 if state[start] == 0 {
766 let mut stack = Vec::new();
767 if let Some(c) = visit(graph, start, &mut state, &mut stack) {
768 return Some(c);
769 }
770 }
771 }
772 None
773}
774
775fn lexical_join(base: &Path, rel: &Path) -> PathBuf {
779 if rel.is_absolute() {
780 return lexical(rel);
781 }
782 lexical(&base.join(rel))
783}
784
785fn lexical(p: &Path) -> PathBuf {
788 let mut out: Vec<Component> = Vec::new();
789 for c in p.components() {
790 match c {
791 Component::CurDir => {}
792 Component::ParentDir => match out.last() {
793 Some(Component::Normal(_)) => {
794 out.pop();
795 }
796 Some(Component::RootDir | Component::Prefix(_)) => {}
797 _ => out.push(c),
798 },
799 other => out.push(other),
800 }
801 }
802 if out.is_empty() {
803 return PathBuf::from(".");
804 }
805 out.iter().collect()
806}
807
808fn relative_to(base: &Path, target: &Path) -> PathBuf {
812 let base = lexical(base);
813 let target = lexical(target);
814 if base.is_absolute() != target.is_absolute() {
815 return target;
816 }
817 let b: Vec<Component> = base
818 .components()
819 .filter(|c| *c != Component::CurDir)
820 .collect();
821 let t: Vec<Component> = target
822 .components()
823 .filter(|c| *c != Component::CurDir)
824 .collect();
825 let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count();
826 let mut out = PathBuf::new();
827 for _ in common..b.len() {
828 out.push("..");
829 }
830 for c in &t[common..] {
831 out.push(c.as_os_str());
832 }
833 out
834}
835
836fn real(p: &Path) -> PathBuf {
839 p.canonicalize().unwrap_or_else(|_| {
840 std::env::current_dir()
841 .map(|cwd| lexical_join(&cwd, p))
842 .unwrap_or_else(|_| lexical(p))
843 })
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849
850 #[test]
851 fn encoding_round_trips_and_is_injective() {
852 let ids = [
853 ("acme", "money"),
854 ("acme-labs", "ledger_kit"),
855 ("a--b", "c"),
856 ("a-", "b_c"),
857 ("a", "b_c"),
858 ];
859 let mut seen = std::collections::HashSet::new();
860 for (org, package) in ids {
861 let id = PackageId {
862 org: org.into(),
863 package: package.into(),
864 };
865 let enc = id.encoded();
866 assert!(seen.insert(enc.clone()), "{enc} collides");
867 assert_eq!(PackageId::decode(&enc), Some(id.clone()));
868 let ident = id.canonical("currency.rates").replace('.', "_");
870 assert!(
871 ident
872 .chars()
873 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
874 );
875 }
876 }
877
878 #[test]
879 fn display_rewrites_canonical_names_only() {
880 assert_eq!(
881 display_names("unknown commons `$acme_money.currency` here"),
882 "unknown commons `acme/money:currency` here"
883 );
884 assert_eq!(
885 display_names("`$acme$labs_ledger_kit.a.b`"),
886 "`acme-labs/ledger_kit:a.b`"
887 );
888 assert_eq!(display_names("costs $5"), "costs $5");
889 assert_eq!(display_names("no dollars"), "no dollars");
890 }
891
892 #[test]
893 fn relative_and_lexical_paths() {
894 assert_eq!(lexical(Path::new("a/./b/../c")), PathBuf::from("a/c"));
895 assert_eq!(lexical(Path::new("../money")), PathBuf::from("../money"));
896 assert_eq!(
897 relative_to(
898 Path::new("/w/apps/shop"),
899 Path::new("/w/packages/money/src")
900 ),
901 PathBuf::from("../../packages/money/src")
902 );
903 assert_eq!(
904 relative_to(Path::new("."), Path::new("vendor/money/src")),
905 PathBuf::from("vendor/money/src")
906 );
907 }
908}