Skip to main content

bynk_project/
manifest.rs

1//! Packaging slice 1 (#2207, track `design/tracks/packaging.md` §3, §4, §6.2):
2//! the `bynk.toml` tables that give a project a package identity and group
3//! projects into a workspace — `[package]`, `[dependencies]`,
4//! `[dev-dependencies]`, `[registries]` and `[workspace]`.
5//!
6//! Everything here is checked; nothing here resolves. `[registries]` is inert
7//! configuration, and a well-formed, non-empty `[dev-dependencies]` is refused
8//! until slice 2b. `[dependencies]` path sources resolve in
9//! [`crate::packages`] (#2223), and `[workspace]` drives the `bynk` driver's
10//! `check`, `fmt` and `test` across its members ([`crate::workspace`]).
11//!
12//! The readers take a `toml::Table` and return typed configs, the way
13//! `workers_config_from` and `test_targets_from` do; [`crate::check_manifest_str`]
14//! calls them, and so refuses what they refuse.
15
16use std::collections::{BTreeMap, HashMap};
17use std::path::{Path, PathBuf};
18
19use crate::discovery::read_source;
20use crate::paths::{ProjectPathsError, did_you_mean};
21
22/// The longest an organisation, package or registry name segment may be.
23pub const MAX_SEGMENT_LEN: usize = 64;
24
25/// The organisation reserved for first-party packages (ADR 0012), and the
26/// name `mirror-of` uses for the first-party registry.
27pub const RESERVED: &str = "bynk";
28
29/// The entry keys of a `[dependencies]` or `[dev-dependencies]` entry.
30pub const DEPENDENCY_KEYS: &[&str] = &["package", "version", "git", "rev", "path"];
31
32/// The entry keys of a `[registries.<name>]` entry.
33pub const REGISTRY_KEYS: &[&str] = &["index", "organisations", "mirror-of", "key"];
34
35/// `[package]`: a shareable project's identity (track §3.2, decision D).
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct PackageConfig {
38    /// The organisation segment of `name` (`acme` in `acme/money`).
39    pub org: String,
40    /// The package segment of `name` (`money` in `acme/money`).
41    pub package: String,
42    /// Strict semver, when given. Publishing (slice 4) will require it.
43    pub version: Option<String>,
44    pub description: Option<String>,
45    pub license: Option<String>,
46    pub repository: Option<String>,
47    /// The units the package shares, when listed. `None` means every commons
48    /// and adapter it declares. Whether each names a commons or adapter the
49    /// project declares is `bynk check`'s to say, once the units are known.
50    pub exports: Option<Vec<String>>,
51}
52
53/// Where a declared dependency comes from: exactly one of these.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum DependencySource {
56    /// A registry version range. Only its pin is checked here: validating the
57    /// range syntax is slice 4's, together with the registry resolver that
58    /// reads it (#2223).
59    Version(String),
60    /// A git repository at a revision.
61    Git { url: String, rev: String },
62    /// A directory, relative to the project.
63    Path(String),
64}
65
66/// One `[dependencies]` or `[dev-dependencies]` entry.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct DependencySpec {
69    /// The entry's key: the name source reaches the package's units by.
70    pub alias: String,
71    /// The package, as `org/package`.
72    pub package: String,
73    pub source: DependencySource,
74}
75
76/// One `[registries.<name>]` entry (track §6.2, decision E). Inert until
77/// slice 4: nothing fetches.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct RegistryConfig {
80    pub name: String,
81    pub index: String,
82    /// The organisations routed to this registry.
83    pub organisations: Vec<String>,
84    /// The registry this one mirrors: `bynk`, or another registry's name.
85    pub mirror_of: Option<String>,
86    /// The pinned origin key, `ed25519:<base64>`.
87    pub key: Option<String>,
88}
89
90/// `[workspace]` (decision C): the member globs and the paths they skip.
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
92pub struct WorkspaceConfig {
93    pub members: Vec<String>,
94    pub exclude: Vec<String>,
95}
96
97/// Why a name in `bynk.toml` isn't well formed, as the tail of a message.
98fn 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
135/// Whether `word` is a reserved Bynk keyword, which no name source can write
136/// may be.
137fn is_keyword(word: &str) -> bool {
138    bynk_syntax::keywords::KEYWORDS
139        .iter()
140        .any(|k| k.word == word)
141}
142
143/// The problem with an organisation segment (decision G: `[a-z][a-z0-9-]*`).
144pub fn org_problem(org: &str) -> Option<String> {
145    segment_problem(org, true)
146}
147
148/// The problem with a package segment or an alias (decision G:
149/// `[a-z][a-z0-9_]*`, not a keyword): each is written in source as the first
150/// segment of `<alias>.<unit>`.
151pub 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
158/// The problem with a registry name (decision G: `[a-z][a-z0-9-]*`, not
159/// `bynk`).
160pub 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
170/// The environment variable a registry's token is read from (track §6.1):
171/// `BYNK_REGISTRY_<NAME>_TOKEN`, the name uppercased with `-` mapped to `_`.
172/// A registry name holds no `_`, so two names never share a variable.
173pub fn registry_token_var(name: &str) -> String {
174    format!(
175        "BYNK_REGISTRY_{}_TOKEN",
176        name.to_ascii_uppercase().replace('-', "_")
177    )
178}
179
180/// The problem with an `org/package` name, as `(segment, problem)`.
181pub 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
201/// Whether `version` is strict semver 2.0: `MAJOR.MINOR.PATCH`, no leading
202/// zeros, with an optional `-prerelease` and `+build`.
203pub 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
235/// Whether `name` is shaped like a unit name: dot-separated identifiers.
236fn 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
245/// The project's `[package]` as both the build and the editor see it, the
246/// way [`crate::read_unstable_config`] reads `[unstable]`: the overlay's
247/// `bynk.toml` when it holds one, otherwise the one on disk. `None` when there
248/// is no manifest, no `[package]`, or one that isn't well formed: its shape
249/// errors are [`crate::check_manifest`]'s to report.
250pub 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
263/// A list of strings, or `None` when `value` is anything else.
264fn 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
272/// `[package]`'s typed content (decision D). An unknown key is
273/// [`crate::check_manifest_str`]'s to refuse.
274pub 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
342/// A `[dependencies]` or `[dev-dependencies]` table's entries, each checked
343/// for shape (decision A). `table` is the table's name, for messages.
344pub 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            // #2274: a registry range is Cargo-style semver (`^1.4`,
420            // `~1.4.2`, `=1.4.2`, `>=1.2, <2`; a bare `1.4` means `^1.4`).
421            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
447/// Refuse a map-table entry key that isn't one of `keys`, with did-you-mean.
448fn 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
464/// Whether an `index` URL is acceptable: `https://`, `http://` to the local
465/// machine only (decision E), or `file://` (#2274).
466fn 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    // #2274: a registry in a local directory — air-gapped, or a test.
471    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
497/// Whether `key` is `ed25519:` and the base64 of a 32-byte key.
498fn 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
512/// `[registries]`'s entries, each checked (decision E), and the rules across
513/// them: an organisation is routed by one registry at most, and a mirror
514/// mirrors `bynk` or a registry that isn't itself a mirror.
515pub 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        // Credentials first: a `token` is never a typo for some other key.
531        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 &registry.organisations {
600            if let Some(first) = routed.insert(org, &registry.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) = &registry.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 == &registry.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
646/// Whether a workspace `members`/`exclude` entry names a directory under the
647/// root: a relative path with no `..`, no root or drive anchor (`/packages`
648/// on Windows has no prefix, so `is_relative` alone lets it through), and at
649/// least one named component — `.` names the root itself, which is never a
650/// member, and an `exclude = ["."]` would otherwise exclude every member.
651fn 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
664/// `[workspace]`'s typed content (decision C). An unknown key is
665/// [`crate::check_manifest_str`]'s to refuse; whether the globs match any
666/// member is [`crate::workspace::load_workspace`]'s.
667pub 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        // `acme_internal` is not a registry name, so it can't collide.
732        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}