1use std::collections::{BTreeMap, HashMap};
17use std::path::{Path, PathBuf};
18
19use crate::discovery::read_source;
20use crate::paths::{ProjectPathsError, did_you_mean};
21
22pub const MAX_SEGMENT_LEN: usize = 64;
24
25pub const RESERVED: &str = "bynk";
28
29pub const DEPENDENCY_KEYS: &[&str] = &["package", "version", "git", "rev", "path"];
31
32pub const REGISTRY_KEYS: &[&str] = &["index", "organisations", "mirror-of", "key"];
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct PackageConfig {
38 pub org: String,
40 pub package: String,
42 pub version: Option<String>,
44 pub description: Option<String>,
45 pub license: Option<String>,
46 pub repository: Option<String>,
47 pub exports: Option<Vec<String>>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum DependencySource {
56 Version(String),
60 Git { url: String, rev: String },
62 Path(String),
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct DependencySpec {
69 pub alias: String,
71 pub package: String,
73 pub source: DependencySource,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct RegistryConfig {
80 pub name: String,
81 pub index: String,
82 pub organisations: Vec<String>,
84 pub mirror_of: Option<String>,
86 pub key: Option<String>,
88}
89
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct WorkspaceConfig {
93 pub members: Vec<String>,
94 pub exclude: Vec<String>,
95}
96
97fn segment_problem(segment: &str, hyphens: bool) -> Option<String> {
99 let allowed = if hyphens {
100 "`[a-z][a-z0-9-]*`"
101 } else {
102 "`[a-z][a-z0-9_]*`"
103 };
104 if segment.is_empty() {
105 return Some("is empty".to_string());
106 }
107 if segment.chars().count() > MAX_SEGMENT_LEN {
108 return Some(format!("is longer than {MAX_SEGMENT_LEN} characters"));
109 }
110 let mut chars = segment.chars();
111 let first_ok = chars.next().is_some_and(|c| c.is_ascii_lowercase());
112 let rest_ok = chars.all(|c| {
113 c.is_ascii_lowercase() || c.is_ascii_digit() || if hyphens { c == '-' } else { c == '_' }
114 });
115 if first_ok && rest_ok {
116 return None;
117 }
118 if !hyphens && segment.contains('-') {
119 let fixed = segment.replace('-', "_");
120 if segment_problem(&fixed, false).is_none() {
121 return Some(format!(
122 "can't contain `-`, because it is also a name in Bynk source, where `-` is minus — did you mean `{fixed}`?"
123 ));
124 }
125 }
126 if hyphens && segment.contains('_') {
127 let fixed = segment.replace('_', "-");
128 if segment_problem(&fixed, true).is_none() {
129 return Some(format!("can't contain `_` — did you mean `{fixed}`?"));
130 }
131 }
132 Some(format!("must match {allowed}"))
133}
134
135fn is_keyword(word: &str) -> bool {
138 bynk_syntax::keywords::KEYWORDS
139 .iter()
140 .any(|k| k.word == word)
141}
142
143pub fn org_problem(org: &str) -> Option<String> {
145 segment_problem(org, true)
146}
147
148pub fn identifier_problem(name: &str) -> Option<String> {
152 if let Some(problem) = segment_problem(name, false) {
153 return Some(problem);
154 }
155 is_keyword(name).then(|| "is a Bynk keyword".to_string())
156}
157
158pub fn registry_name_problem(name: &str) -> Option<String> {
161 if name == RESERVED {
162 return Some(
163 "is reserved: `mirror-of = \"bynk\"` already means the first-party registry"
164 .to_string(),
165 );
166 }
167 segment_problem(name, true)
168}
169
170pub fn registry_token_var(name: &str) -> String {
174 format!(
175 "BYNK_REGISTRY_{}_TOKEN",
176 name.to_ascii_uppercase().replace('-', "_")
177 )
178}
179
180pub fn package_name_problem(name: &str) -> Option<String> {
182 let Some((org, package)) = name.split_once('/') else {
183 return Some(format!(
184 "`{name}` must be `organisation/package`, like `acme/money`"
185 ));
186 };
187 if package.contains('/') {
188 return Some(format!(
189 "`{name}` must be `organisation/package`, with one `/`"
190 ));
191 }
192 if let Some(problem) = org_problem(org) {
193 return Some(format!("names the organisation `{org}`, which {problem}"));
194 }
195 if let Some(problem) = identifier_problem(package) {
196 return Some(format!("names the package `{package}`, which {problem}"));
197 }
198 None
199}
200
201pub fn is_semver(version: &str) -> bool {
204 let (rest, build) = match version.split_once('+') {
205 Some((rest, build)) => (rest, Some(build)),
206 None => (version, None),
207 };
208 let (core, pre) = match rest.split_once('-') {
209 Some((core, pre)) => (core, Some(pre)),
210 None => (rest, None),
211 };
212 let numeric = |s: &str| {
213 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) && (s == "0" || !s.starts_with('0'))
214 };
215 let ident = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
216 let parts: Vec<&str> = core.split('.').collect();
217 if parts.len() != 3 || !parts.iter().all(|p| numeric(p)) {
218 return false;
219 }
220 if let Some(pre) = pre
221 && !pre
222 .split('.')
223 .all(|id| ident(id) && (!id.chars().all(|c| c.is_ascii_digit()) || numeric(id)))
224 {
225 return false;
226 }
227 if let Some(build) = build
228 && !build.split('.').all(ident)
229 {
230 return false;
231 }
232 true
233}
234
235fn is_unit_name(name: &str) -> bool {
237 !name.is_empty()
238 && name.split('.').all(|seg| {
239 let mut chars = seg.chars();
240 chars.next().is_some_and(|c| c.is_ascii_alphabetic())
241 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
242 })
243}
244
245pub fn read_package_config(
251 project_root: &Path,
252 overlay: &HashMap<PathBuf, String>,
253) -> Option<PackageConfig> {
254 let manifest = project_root.join("bynk.toml");
255 let content = match read_source(&manifest, overlay) {
256 Ok(text) => text,
257 Err(_) => std::fs::read_to_string(&manifest).ok()?,
258 };
259 let doc = content.parse::<toml::Table>().ok()?;
260 package_config_from(doc.get("package")?.as_table()?).ok()
261}
262
263fn string_list(value: &toml::Value) -> Option<Vec<String>> {
265 value
266 .as_array()?
267 .iter()
268 .map(|v| v.as_str().map(str::to_owned))
269 .collect()
270}
271
272pub fn package_config_from(table: &toml::Table) -> Result<PackageConfig, ProjectPathsError> {
275 let bad = |key: &'static str, reason: String| ProjectPathsError::BadPackage { key, reason };
276 let text = |key: &'static str| -> Result<Option<String>, ProjectPathsError> {
277 match table.get(key) {
278 None => Ok(None),
279 Some(v) => v
280 .as_str()
281 .map(|s| Some(s.to_owned()))
282 .ok_or_else(|| bad(key, "must be a string".to_string())),
283 }
284 };
285 let Some(name) = text("name")? else {
286 return Err(bad(
287 "name",
288 "is required: a package is named `organisation/package`, like `name = \"acme/money\"`"
289 .to_string(),
290 ));
291 };
292 if let Some(problem) = package_name_problem(&name) {
293 return Err(bad("name", problem));
294 }
295 let (org, package) = name.split_once('/').expect("checked above");
296 if org == RESERVED {
297 return Err(bad(
298 "name",
299 format!(
300 "names the organisation `{RESERVED}`, which is reserved for first-party packages"
301 ),
302 ));
303 }
304 let version = text("version")?;
305 if let Some(v) = &version
306 && !is_semver(v)
307 {
308 return Err(bad(
309 "version",
310 format!("`{v}` isn't a semver version, like `1.4.0`"),
311 ));
312 }
313 let exports = match table.get("exports") {
314 None => None,
315 Some(v) => {
316 let list = string_list(v).ok_or_else(|| {
317 bad(
318 "exports",
319 "must be a list of unit names, like `exports = [\"money\"]`".to_string(),
320 )
321 })?;
322 if let Some(entry) = list.iter().find(|e| !is_unit_name(e)) {
323 return Err(bad(
324 "exports",
325 format!("lists `{entry}`, which isn't a unit name"),
326 ));
327 }
328 Some(list)
329 }
330 };
331 Ok(PackageConfig {
332 org: org.to_owned(),
333 package: package.to_owned(),
334 version,
335 description: text("description")?,
336 license: text("license")?,
337 repository: text("repository")?,
338 exports,
339 })
340}
341
342pub fn dependencies_from(
345 table: &'static str,
346 entries: &toml::Table,
347) -> Result<Vec<DependencySpec>, ProjectPathsError> {
348 let mut out = Vec::new();
349 for (alias, entry) in entries {
350 let bad = |reason: String| ProjectPathsError::BadDependency {
351 table,
352 alias: alias.clone(),
353 reason,
354 };
355 if let Some(problem) = identifier_problem(alias) {
356 return Err(bad(format!(
357 "is an alias, written in source as `{alias}.<unit>`, so it {problem}"
358 )));
359 }
360 if alias == RESERVED {
361 return Err(bad(format!(
362 "can't be an alias: `{RESERVED}` is reserved for the standard library"
363 )));
364 }
365 let Some(fields) = entry.as_table() else {
366 return Err(bad(
367 "must be a table, like `{ package = \"acme/money\", version = \"^1\" }` — there is no bare-version shorthand"
368 .to_string(),
369 ));
370 };
371 check_entry_keys(table, alias, fields, DEPENDENCY_KEYS)?;
372 let text = |key: &str| -> Result<Option<String>, ProjectPathsError> {
373 match fields.get(key) {
374 None => Ok(None),
375 Some(v) => match v.as_str() {
376 Some(s) if !s.trim().is_empty() => Ok(Some(s.to_owned())),
377 _ => Err(bad(format!("`{key}` must be a non-empty string"))),
378 },
379 }
380 };
381 let Some(package) = text("package")? else {
382 return Err(bad(
383 "needs `package`, the `organisation/package` it names".to_string()
384 ));
385 };
386 if let Some(problem) = package_name_problem(&package) {
387 return Err(bad(format!("`package` {problem}")));
388 }
389 let (version, git, rev, path) =
390 (text("version")?, text("git")?, text("rev")?, text("path")?);
391 let given: Vec<&str> = [("version", &version), ("git", &git), ("path", &path)]
392 .iter()
393 .filter(|(_, v)| v.is_some())
394 .map(|(k, _)| *k)
395 .collect();
396 if given.len() != 1 {
397 let found = if given.is_empty() {
398 "none".to_string()
399 } else {
400 given
401 .iter()
402 .map(|k| format!("`{k}`"))
403 .collect::<Vec<_>>()
404 .join(" and ")
405 };
406 return Err(bad(format!(
407 "must give exactly one of `version`, `git` or `path` (it gives {found})"
408 )));
409 }
410 if rev.is_some() && git.is_none() {
411 return Err(bad("has a `rev`, which only goes with `git`".to_string()));
412 }
413 let source = if let Some(v) = version {
414 if crate::paths::is_unpinned_range(&v) {
415 return Err(bad(format!(
416 "`version = \"{v}\"` is unpinned — name a version, like `\"^1.4\"`"
417 )));
418 }
419 if let Err(e) = semver::VersionReq::parse(&v) {
422 return Err(bad(format!(
423 "`version = \"{v}\"` isn't a version range ({e}) — write one like `\"^1.4\"`"
424 )));
425 }
426 DependencySource::Version(v)
427 } else if let Some(url) = git {
428 let Some(rev) = rev else {
429 return Err(bad(
430 "has a `git` source with no `rev` — pin a tag or commit, like `rev = \"v0.3.0\"`"
431 .to_string(),
432 ));
433 };
434 DependencySource::Git { url, rev }
435 } else {
436 DependencySource::Path(path.expect("exactly one source"))
437 };
438 out.push(DependencySpec {
439 alias: alias.clone(),
440 package,
441 source,
442 });
443 }
444 Ok(out)
445}
446
447fn check_entry_keys(
449 table: &'static str,
450 entry: &str,
451 fields: &toml::Table,
452 keys: &'static [&'static str],
453) -> Result<(), ProjectPathsError> {
454 match fields.keys().find(|k| !keys.contains(&k.as_str())) {
455 Some(key) => Err(ProjectPathsError::UnknownEntryKey {
456 table,
457 entry: entry.to_owned(),
458 key: key.clone(),
459 }),
460 None => Ok(()),
461 }
462}
463
464fn index_problem(index: &str) -> Option<String> {
467 let Some((scheme, rest)) = index.split_once("://") else {
468 return Some(format!("`index = \"{index}\"` must be an `https://` URL"));
469 };
470 if scheme == "file" {
472 return (rest.is_empty() || rest == "/")
473 .then(|| format!("`index = \"{index}\"` names no directory"));
474 }
475 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
476 let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
477 let bare_host = if host.starts_with('[') {
478 host.split_once(']')
479 .map_or(host, |(h, _)| h)
480 .trim_start_matches('[')
481 } else {
482 host.split(':').next().unwrap_or_default()
483 };
484 if bare_host.is_empty() {
485 return Some(format!("`index = \"{index}\"` names no host"));
486 }
487 match scheme {
488 "https" => None,
489 "http" if bare_host == "localhost" => None,
490 "http" => Some(format!(
491 "`index = \"{index}\"` must be `https://` — `http://` is allowed only for `localhost`"
492 )),
493 _ => Some(format!("`index = \"{index}\"` must be an `https://` URL")),
494 }
495}
496
497fn is_ed25519_key(key: &str) -> bool {
499 let Some(b64) = key.strip_prefix("ed25519:") else {
500 return false;
501 };
502 let body = b64.trim_end_matches('=');
503 let pad = b64.len() - body.len();
504 b64.len() % 4 == 0
505 && pad <= 2
506 && body
507 .chars()
508 .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')
509 && b64.len() / 4 * 3 - pad == 32
510}
511
512pub fn registries_from(entries: &toml::Table) -> Result<Vec<RegistryConfig>, ProjectPathsError> {
516 let mut out: Vec<RegistryConfig> = Vec::new();
517 for (name, entry) in entries {
518 let bad = |reason: String| ProjectPathsError::BadRegistry {
519 name: name.clone(),
520 reason,
521 };
522 if let Some(problem) = registry_name_problem(name) {
523 return Err(bad(format!("is a registry name, which {problem}")));
524 }
525 let Some(fields) = entry.as_table() else {
526 return Err(bad(
527 "must be a table, like `[registries.acme-internal]` with an `index`".to_string(),
528 ));
529 };
530 if fields.contains_key("token") {
532 return Err(ProjectPathsError::RegistryCredential { name: name.clone() });
533 }
534 check_entry_keys("registries", name, fields, REGISTRY_KEYS)?;
535 let index = match fields.get("index") {
536 None => return Err(bad("needs an `index` URL".to_string())),
537 Some(v) => v
538 .as_str()
539 .map(str::to_owned)
540 .ok_or_else(|| bad("`index` must be a URL string".to_string()))?,
541 };
542 if let Some(problem) = index_problem(&index) {
543 return Err(bad(problem));
544 }
545 let organisations = match fields.get("organisations") {
546 None => Vec::new(),
547 Some(v) => string_list(v).ok_or_else(|| {
548 bad("`organisations` must be a list of organisation names".to_string())
549 })?,
550 };
551 for org in &organisations {
552 if org == RESERVED {
553 return Err(bad(format!(
554 "lists the organisation `{RESERVED}`, which always resolves on the first-party registry"
555 )));
556 }
557 if let Some(problem) = org_problem(org) {
558 return Err(bad(format!(
559 "lists the organisation `{org}`, which {problem}"
560 )));
561 }
562 }
563 let mirror_of = match fields.get("mirror-of") {
564 None => None,
565 Some(v) => Some(
566 v.as_str()
567 .map(str::to_owned)
568 .ok_or_else(|| bad("`mirror-of` must be a registry name".to_string()))?,
569 ),
570 };
571 if mirror_of.is_some() && fields.contains_key("organisations") {
572 return Err(bad(
573 "has both `mirror-of` and `organisations` — a mirror serves another registry's organisations and routes none of its own"
574 .to_string(),
575 ));
576 }
577 let key = match fields.get("key") {
578 None => None,
579 Some(v) => match v.as_str() {
580 Some(k) if is_ed25519_key(k) => Some(k.to_owned()),
581 _ => {
582 return Err(bad(
583 "`key` must be `ed25519:` followed by the base64 of a 32-byte public key"
584 .to_string(),
585 ));
586 }
587 },
588 };
589 out.push(RegistryConfig {
590 name: name.clone(),
591 index,
592 organisations,
593 mirror_of,
594 key,
595 });
596 }
597 let mut routed: BTreeMap<&str, &str> = BTreeMap::new();
598 for registry in &out {
599 for org in ®istry.organisations {
600 if let Some(first) = routed.insert(org, ®istry.name) {
601 return Err(ProjectPathsError::BadRegistry {
602 name: registry.name.clone(),
603 reason: format!(
604 "lists the organisation `{org}`, which `[registries.{first}]` already lists — an organisation resolves on one registry"
605 ),
606 });
607 }
608 }
609 }
610 for registry in &out {
611 let Some(target) = ®istry.mirror_of else {
612 continue;
613 };
614 if target == RESERVED {
615 continue;
616 }
617 let bad = |reason: String| ProjectPathsError::BadRegistry {
618 name: registry.name.clone(),
619 reason,
620 };
621 if target == ®istry.name {
622 return Err(bad("can't be a mirror of itself".to_string()));
623 }
624 match out.iter().find(|r| &r.name == target) {
625 None => {
626 let names = out.iter().map(|r| r.name.as_str()).chain([RESERVED]);
627 let hint = match did_you_mean(target, names) {
628 Some(n) => format!(" — did you mean `{n}`?"),
629 None => format!(" — it must be `{RESERVED}` or a registry declared here"),
630 };
631 return Err(bad(format!(
632 "has `mirror-of = \"{target}\"`, which names no registry{hint}"
633 )));
634 }
635 Some(other) if other.mirror_of.is_some() => {
636 return Err(bad(format!(
637 "mirrors `{target}`, which is itself a mirror — a mirror names `{RESERVED}` or a registry that isn't one"
638 )));
639 }
640 Some(_) => {}
641 }
642 }
643 Ok(out)
644}
645
646fn is_under_root(pattern: &str) -> bool {
652 use std::path::Component;
653 let path = std::path::Path::new(pattern);
654 path.is_relative()
655 && path.components().all(|c| {
656 !matches!(
657 c,
658 Component::ParentDir | Component::Prefix(_) | Component::RootDir
659 )
660 })
661 && path.components().any(|c| matches!(c, Component::Normal(_)))
662}
663
664pub fn workspace_config_from(table: &toml::Table) -> Result<WorkspaceConfig, ProjectPathsError> {
668 let bad = |reason: String| ProjectPathsError::BadWorkspace { reason };
669 let list = |key: &str| -> Result<Vec<String>, ProjectPathsError> {
670 match table.get(key) {
671 None => Ok(Vec::new()),
672 Some(v) => string_list(v).ok_or_else(|| {
673 bad(format!(
674 "`{key}` must be a list of paths, like `{key} = [\"packages/*\"]`"
675 ))
676 }),
677 }
678 };
679 if !table.contains_key("members") {
680 return Err(bad(
681 "needs `members`, the member directories, like `members = [\"packages/*\"]`"
682 .to_string(),
683 ));
684 }
685 let members = list("members")?;
686 let exclude = list("exclude")?;
687 for (key, entries) in [("members", &members), ("exclude", &exclude)] {
688 if let Some(p) = entries.iter().find(|p| !is_under_root(p)) {
689 return Err(bad(format!(
690 "`{key}` lists `{p}`, which isn't a relative path under the workspace root"
691 )));
692 }
693 if let Some(p) = entries.iter().find(|p| p.contains("**")) {
694 return Err(bad(format!(
695 "`{key}` lists `{p}` — a pattern matches one directory level per `*`; `**` isn't supported"
696 )));
697 }
698 }
699 Ok(WorkspaceConfig { members, exclude })
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 #[test]
707 fn semver_is_strict() {
708 for ok in [
709 "0.0.0",
710 "1.4.0",
711 "10.20.30",
712 "1.0.0-alpha.1",
713 "1.0.0+build.5",
714 "1.0.0-rc.1+b",
715 ] {
716 assert!(is_semver(ok), "{ok}");
717 }
718 for bad in [
719 "1", "1.4", "01.0.0", "1.0.0-", "1.0.0-01", "v1.0.0", "1.0.0.0", "1.0.0+",
720 ] {
721 assert!(!is_semver(bad), "{bad}");
722 }
723 }
724
725 #[test]
726 fn the_token_variable_is_injective_over_registry_names() {
727 assert_eq!(
728 registry_token_var("acme-internal"),
729 "BYNK_REGISTRY_ACME_INTERNAL_TOKEN"
730 );
731 assert!(registry_name_problem("acme_internal").is_some());
733 }
734
735 #[test]
736 fn segments_follow_decision_g() {
737 assert!(org_problem("acme-labs").is_none());
738 assert!(identifier_problem("ledger_kit").is_none());
739 assert!(
740 identifier_problem("ledger-kit")
741 .unwrap()
742 .contains("did you mean `ledger_kit`?")
743 );
744 assert!(identifier_problem("context").unwrap().contains("keyword"));
745 assert!(org_problem("Acme").is_some());
746 assert!(org_problem(&"a".repeat(65)).unwrap().contains("64"));
747 assert!(
748 org_problem("acme_labs")
749 .unwrap()
750 .contains("did you mean `acme-labs`?")
751 );
752 }
753
754 #[test]
755 fn an_ed25519_key_is_32_bytes_of_base64() {
756 let key = format!("ed25519:{}=", "A".repeat(43));
757 assert!(is_ed25519_key(&key));
758 assert!(!is_ed25519_key("ed25519:AAAA"));
759 assert!(!is_ed25519_key(&key.replace("ed25519", "rsa")));
760 }
761}