Skip to main content

lex_syntax/
workspace.rs

1//! `lex.toml` manifest parsing and package resolution.
2//!
3//! A `lex.toml` file marks a project root and declares its package
4//! dependencies. Import paths of the form `"pkg-name/module"` are
5//! resolved against this file.
6//!
7//! ## File format
8//!
9//! ```toml
10//! [package]
11//! name = "lex-web"
12//! version = "0.1.0"
13//!
14//! [dependencies]
15//! lex-schema = { path = "../lex-schema" }
16//! # pin to a tag:
17//! lex-schema = { git = "https://github.com/alpibrusl/lex-schema", tag = "v1.2.0" }
18//! # pin to a commit:
19//! lex-schema = { git = "https://github.com/alpibrusl/lex-schema", rev = "abc1234" }
20//! # track a branch:
21//! lex-schema = { git = "https://github.com/alpibrusl/lex-schema", branch = "stable" }
22//! # or from a registry:
23//! lex-schema = { registry = "https://lexhub.alpibru.com", version = "0.9.2" }
24//! ```
25//!
26//! At most one of `tag`, `rev`, `branch` may be set; omitting all three
27//! clones the default branch at HEAD (not reproducible — pin for releases).
28//!
29//! ## Module resolution
30//!
31//! `import "lex-schema/validate" as v` splits into `pkg = "lex-schema"`,
32//! `module = "validate"`. The loader:
33//!
34//! 1. Walks up from the importing file to find the nearest `lex.toml`.
35//! 2. Looks up `lex-schema` in `[dependencies]`.
36//! 3. For `path =`: resolves `{dep_path}/src/validate.lex`; falls back to
37//!    `{dep_path}/validate.lex` if `src/` doesn't exist.
38//! 4. For `git =`: clones the repo into `~/.lex/packages/lex-schema-<ref>/`
39//!    (once; subsequent loads hit the cache), then resolves the same way.
40
41use serde::Deserialize;
42use std::collections::HashMap;
43use std::path::{Path, PathBuf};
44
45// ── Manifest types ────────────────────────────────────────────────────────────
46
47#[derive(Debug, Deserialize)]
48pub struct Manifest {
49    pub package: Option<PackageMeta>,
50    #[serde(default)]
51    pub dependencies: HashMap<String, Dependency>,
52    /// `[store]` — where store-touching commands (`lex branch`,
53    /// `publish`, `merge`, `op`, `store`, ...) keep this project's
54    /// stages, branches, and traces when no `--store` is given (#772).
55    #[serde(default)]
56    pub store: Option<StoreSection>,
57}
58
59/// `[store]` table of `lex.toml`.
60#[derive(Debug, Default, Deserialize)]
61pub struct StoreSection {
62    /// Store root, relative to the manifest's directory (an absolute
63    /// path is used as is). Defaults to `.lex/store` when absent.
64    #[serde(default)]
65    pub path: Option<String>,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct PackageMeta {
70    pub name: String,
71    #[serde(default)]
72    pub version: String,
73    #[serde(default)]
74    pub description: Option<String>,
75    /// Default registry URL for `lex pkg publish` when `--registry` is not supplied.
76    #[serde(default)]
77    pub registry: Option<String>,
78    /// Minimum toolchain this package needs, as `lex = "0.10.15"`.
79    ///
80    /// Packages across the org have declared this for a long time; until
81    /// #803 nothing read it, because serde silently drops unknown fields.
82    /// A dependency that had moved onto a newer stdlib installed without
83    /// complaint into a project pinned older, and the mismatch surfaced
84    /// as `unknown_field` errors naming a function nobody in the
85    /// consuming repo had written.
86    #[serde(default)]
87    pub lex: Option<String>,
88}
89
90/// Parse a `MAJOR.MINOR.PATCH` version into comparable parts.
91///
92/// Deliberately not a semver crate: these are toolchain pins written by
93/// hand in `lex.toml`, always three numeric components, and a
94/// dependency that spells its floor some other way should be skipped
95/// rather than guessed at.
96pub fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
97    let mut it = v.trim().trim_start_matches('v').split('.');
98    let major = it.next()?.parse().ok()?;
99    let minor = it.next()?.parse().ok()?;
100    let patch = it.next()?.parse().ok()?;
101    if it.next().is_some() {
102        return None;
103    }
104    Some((major, minor, patch))
105}
106
107/// Does `running` satisfy a declared `floor`?
108///
109/// `None` when either side is unparseable — the caller should say so
110/// rather than treat "cannot tell" as either satisfied or violated.
111pub fn satisfies_floor(running: &str, floor: &str) -> Option<bool> {
112    Some(parse_version(running)? >= parse_version(floor)?)
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(untagged)]
117pub enum Dependency {
118    Path     { path: String },
119    /// A dual reference: the vcs registry is the PRIMARY, default resolution
120    /// source (pinned via `lex.lock`), and `git` is a recorded mirror/fallback
121    /// — used only when the registry is unreachable, or when the CLI is asked
122    /// to resolve from git instead (`--source git`). Listed before `Git`/
123    /// `Registry` so the untagged deserializer picks it whenever all of
124    /// `registry` + `version` + `git` are present. This is what lets a manifest
125    /// carry BOTH a git and a vcs reference for the same dependency.
126    Both     {
127        registry: String,
128        version:  String,
129        git:      String,
130        #[serde(default)]
131        branch:   Option<String>,
132        #[serde(default)]
133        tag:      Option<String>,
134        #[serde(default)]
135        rev:      Option<String>,
136    },
137    Git      {
138        git:    String,
139        #[serde(default)]
140        branch: Option<String>,
141        #[serde(default)]
142        tag:    Option<String>,
143        #[serde(default)]
144        rev:    Option<String>,
145    },
146    Registry { registry: String, version: String },
147}
148
149/// A git dependency coordinate: `(url, branch, tag, rev)`. `branch`/`tag`/`rev`
150/// are mutually exclusive (at most one set); all `None` means the default branch.
151pub type GitCoord<'a> = (&'a str, Option<&'a str>, Option<&'a str>, Option<&'a str>);
152
153impl Dependency {
154    /// Return an error if more than one of branch/tag/rev is set.
155    pub fn validate(&self) -> Result<(), String> {
156        let refs = match self {
157            Dependency::Git { branch, tag, rev, .. } => [branch, tag, rev],
158            Dependency::Both { branch, tag, rev, .. } => [branch, tag, rev],
159            _ => return Ok(()),
160        };
161        if refs.iter().filter(|o| o.is_some()).count() > 1 {
162            return Err("at most one of `branch`, `tag`, `rev` may be set on a git dependency".into());
163        }
164        Ok(())
165    }
166
167    /// The vcs/registry coordinate `(registry, version)` if this dep resolves
168    /// (by default) from a registry — both a bare `Registry` and the dual
169    /// `Both`. `None` for pure git or path deps.
170    pub fn registry_coord(&self) -> Option<(&str, &str)> {
171        match self {
172            Dependency::Registry { registry, version }
173            | Dependency::Both { registry, version, .. } => Some((registry, version)),
174            _ => None,
175        }
176    }
177
178    /// The git coordinate `(git, branch, tag, rev)` this dep carries — the
179    /// sole source for a pure `Git`, or the recorded fallback mirror on a
180    /// dual `Both`. `None` for registry-only or path deps.
181    pub fn git_coord(&self) -> Option<GitCoord<'_>> {
182        match self {
183            Dependency::Git { git, branch, tag, rev }
184            | Dependency::Both { git, branch, tag, rev, .. } => {
185                Some((git, branch.as_deref(), tag.as_deref(), rev.as_deref()))
186            }
187            _ => None,
188        }
189    }
190}
191
192impl Manifest {
193    pub fn load(toml_path: &Path) -> Result<Self, String> {
194        let src = std::fs::read_to_string(toml_path)
195            .map_err(|e| format!("reading {}: {e}", toml_path.display()))?;
196        toml::from_str(&src)
197            .map_err(|e| format!("parsing {}: {e}", toml_path.display()))
198    }
199}
200
201// ── Discovery ─────────────────────────────────────────────────────────────────
202
203/// Walk up from `start` (a file or directory) looking for `lex.toml`.
204/// Returns `(toml_path, toml_dir)` for the nearest ancestor that has one.
205pub fn find_manifest(start: &Path) -> Option<(PathBuf, PathBuf)> {
206    let mut dir = if start.is_dir() {
207        start.to_path_buf()
208    } else {
209        start.parent()?.to_path_buf()
210    };
211    loop {
212        let candidate = dir.join("lex.toml");
213        if candidate.exists() {
214            return Some((candidate, dir));
215        }
216        match dir.parent() {
217            Some(p) if p != dir => dir = p.to_path_buf(),
218            _ => return None,
219        }
220    }
221}
222
223// ── Resolution ────────────────────────────────────────────────────────────────
224
225/// Resolve `pkg_name/module_path` to a `.lex` file on disk.
226///
227/// `importer` is the file that contains the import statement; it's used
228/// to locate the nearest `lex.toml`.
229pub fn resolve_package_import(
230    importer: &Path,
231    pkg_name: &str,
232    module_path: &str,
233) -> Result<PathBuf, PackageError> {
234    let (toml_path, toml_dir) = find_manifest(importer).ok_or_else(|| {
235        PackageError::NoManifest {
236            reference: format!("{pkg_name}/{module_path}"),
237            searched_from: importer.display().to_string(),
238        }
239    })?;
240
241    let manifest = Manifest::load(&toml_path)
242        .map_err(|e| PackageError::ManifestParse { path: toml_path.display().to_string(), detail: e })?;
243
244    let dep = manifest.dependencies.get(pkg_name).ok_or_else(|| {
245        PackageError::UnknownPackage {
246            name: pkg_name.to_string(),
247            manifest: toml_path.display().to_string(),
248        }
249    })?;
250
251    let pkg_root = match dep {
252        Dependency::Path { path } => {
253            let raw = toml_dir.join(path);
254            raw.canonicalize().map_err(|e| PackageError::Io {
255                path: raw.display().to_string(),
256                detail: e.to_string(),
257            })?
258        }
259        Dependency::Git { git, branch, tag, rev } => {
260            dep.validate().map_err(|e| PackageError::ManifestParse {
261                path: toml_path.display().to_string(),
262                detail: e,
263            })?;
264            let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
265            git_ensure_cached(pkg_name, git, &git_ref)?
266        }
267        Dependency::Registry { registry, version } => {
268            resolve_registry_dep(pkg_name, registry, version, &toml_dir)?
269        }
270        Dependency::Both { registry, version, git, branch, tag, rev } => {
271            dep.validate().map_err(|e| PackageError::ManifestParse {
272                path: toml_path.display().to_string(),
273                detail: e,
274            })?;
275            // vcs is the PRIMARY, default source; git is the recorded
276            // fallback mirror. `LEX_DEP_SOURCE=git` (set by the CLI's
277            // `--source git`) forces the git ref; otherwise resolve from the
278            // registry and fall back to git only when there is no way to ask
279            // the registry (no lock pin and a non-exact constraint).
280            let resolve_git = || -> Result<PathBuf, PackageError> {
281                let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
282                git_ensure_cached(pkg_name, git, &git_ref)
283            };
284            if dep_source_prefers_git() {
285                resolve_git()?
286            } else {
287                match resolve_registry_dep(pkg_name, registry, version, &toml_dir) {
288                    Ok(root) => root,
289                    Err(PackageError::UnlockedRegistryDep { .. }) => resolve_git()?,
290                    Err(e) => return Err(e),
291                }
292            }
293        }
294    };
295
296    find_module_file(&pkg_root, module_path).ok_or_else(|| PackageError::ModuleNotFound {
297        pkg: pkg_name.to_string(),
298        module: module_path.to_string(),
299        pkg_root: pkg_root.display().to_string(),
300    })
301}
302
303/// Whether dependency resolution should prefer a dual dep's git fallback over
304/// its vcs/registry primary. Set by the CLI's `--source git` flag via the
305/// `LEX_DEP_SOURCE` env var so the pure source resolver stays flag-free.
306/// Default (unset, or any value other than `git`) is vcs-primary.
307pub fn dep_source_prefers_git() -> bool {
308    std::env::var("LEX_DEP_SOURCE")
309        .map(|v| v.eq_ignore_ascii_case("git"))
310        .unwrap_or(false)
311}
312
313/// Resolve a registry (vcs) dependency to a cached package root. A registry
314/// dependency declares a *constraint*; the exact release to fetch comes from
315/// `lex.lock` (#893). Prefer the locked pin; a declaration that is already an
316/// exact version resolves directly; a constraint with no lock entry can't be
317/// fetched (there is no single version to ask the registry for).
318fn resolve_registry_dep(
319    pkg_name: &str,
320    registry: &str,
321    version: &str,
322    toml_dir: &Path,
323) -> Result<PathBuf, PackageError> {
324    let locked = crate::lock::LockFile::load_dir(toml_dir)
325        .and_then(|lf| lf.entry(pkg_name).map(|e| e.version.clone()));
326    let effective = match locked {
327        Some(v) => v,
328        None if crate::semver::parse_exact(version).is_some() => version.to_string(),
329        None => {
330            return Err(PackageError::UnlockedRegistryDep {
331                name: pkg_name.to_string(),
332                constraint: version.to_string(),
333            });
334        }
335    };
336    registry_ensure_cached(pkg_name, registry, &effective)
337}
338
339/// Look for `{module_path}.lex` inside a package root, checking `src/`
340/// first then the root itself.
341fn find_module_file(pkg_root: &Path, module_path: &str) -> Option<PathBuf> {
342    let rel = PathBuf::from(module_path).with_extension("lex");
343    let in_src = pkg_root.join("src").join(&rel);
344    if in_src.exists() {
345        return Some(in_src);
346    }
347    let at_root = pkg_root.join(&rel);
348    if at_root.exists() {
349        return Some(at_root);
350    }
351    None
352}
353
354// ── Git cache ─────────────────────────────────────────────────────────────────
355
356/// Parsed ref from a git dependency declaration.
357#[derive(Debug)]
358enum GitRef<'a> {
359    Branch(&'a str),
360    Tag(&'a str),
361    Rev(&'a str),
362    DefaultBranch,
363}
364
365impl<'a> GitRef<'a> {
366    fn from(branch: Option<&'a str>, tag: Option<&'a str>, rev: Option<&'a str>) -> Self {
367        if let Some(b) = branch { return GitRef::Branch(b); }
368        if let Some(t) = tag    { return GitRef::Tag(t); }
369        if let Some(r) = rev    { return GitRef::Rev(r); }
370        GitRef::DefaultBranch
371    }
372
373    /// Slug appended to the cache directory name to prevent collisions between
374    /// different refs of the same repo.
375    fn cache_slug(&self) -> String {
376        match self {
377            GitRef::Branch(b)    => format!("@branch-{}", sanitize_ref(b)),
378            GitRef::Tag(t)       => format!("@tag-{}", sanitize_ref(t)),
379            GitRef::Rev(r)       => format!("@rev-{}", &r[..r.len().min(12)]),
380            GitRef::DefaultBranch => String::new(),
381        }
382    }
383}
384
385/// Replace characters that are not safe in directory names.
386fn sanitize_ref(r: &str) -> String {
387    r.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect()
388}
389
390/// Return the local cache directory for `pkg_name`, cloning from `url`
391/// at the given ref if it isn't there yet.
392///
393/// Cache root: `$LEX_PACKAGES_DIR` if set, otherwise `~/.lex/packages/`.
394/// Cache key:  `{pkg_name}{ref_slug}` so different tags/revs don't collide.
395fn git_ensure_cached(pkg_name: &str, url: &str, git_ref: &GitRef<'_>) -> Result<PathBuf, PackageError> {
396    let cache_root = packages_cache_dir()?;
397    let dir_name = format!("{}{}", pkg_name, git_ref.cache_slug());
398    let pkg_dir = cache_root.join(&dir_name);
399    if pkg_dir.exists() {
400        return Ok(pkg_dir);
401    }
402    std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
403        path: cache_root.display().to_string(),
404        detail: e.to_string(),
405    })?;
406
407    let dest = pkg_dir.to_str().unwrap_or(&dir_name);
408
409    let status = match git_ref {
410        GitRef::Rev(rev) => {
411            // Shallow clone is not possible for arbitrary commits; do a full
412            // clone then check out the specific revision.
413            let s = run_git(&["clone", "--quiet", url, dest], url)?;
414            if s {
415                run_git(&["-C", dest, "checkout", "--quiet", rev], url)?;
416                true
417            } else {
418                false
419            }
420        }
421        GitRef::Tag(tag) => run_git(&["clone", "--quiet", "--depth=1", "--branch", tag, url, dest], url)?,
422        GitRef::Branch(branch) => run_git(&["clone", "--quiet", "--depth=1", "--branch", branch, url, dest], url)?,
423        GitRef::DefaultBranch  => run_git(&["clone", "--quiet", "--depth=1", url, dest], url)?,
424    };
425
426    if !status {
427        // Clean up partial clone so a retry doesn't hit the cache check above.
428        let _ = std::fs::remove_dir_all(&pkg_dir);
429        return Err(PackageError::GitFailed {
430            url: url.to_string(),
431            detail: "`git` exited with non-zero status".into(),
432        });
433    }
434
435    pkg_dir.canonicalize().map_err(|e| PackageError::Io {
436        path: pkg_dir.display().to_string(),
437        detail: e.to_string(),
438    })
439}
440
441/// Run a git command and return `Ok(true)` on success, `Ok(false)` on non-zero
442/// exit, or `Err` if git could not be spawned.
443fn run_git(args: &[&str], url: &str) -> Result<bool, PackageError> {
444    let status = std::process::Command::new("git")
445        .args(args)
446        .status()
447        .map_err(|e| PackageError::GitFailed {
448            url: url.to_string(),
449            detail: format!("could not run `git`: {e}"),
450        })?;
451    Ok(status.success())
452}
453
454/// Download a registry package archive and extract it to the local cache.
455///
456/// Cache path: `$LEX_PACKAGES_DIR/{name}-{version}/` (versioned to avoid
457/// collisions with git-cached packages at `{name}/`).
458///
459/// Download URL: the hub's public surface
460/// `{host}/v1/public/{tenant}/{name}/{version}/archive[?store=…]` for a
461/// tenant-qualified registry (#917), else the legacy
462/// `{registry}/v1/pkg/{name}/{version}/archive`.
463fn registry_ensure_cached(
464    pkg_name: &str,
465    registry: &str,
466    version: &str,
467) -> Result<PathBuf, PackageError> {
468    let cache_root = packages_cache_dir()?;
469    // Registry packages are cached under `{name}-{version}` to keep multiple
470    // versions side-by-side and separate from git-cached directories.
471    let pkg_dir = cache_root.join(format!("{pkg_name}-{version}"));
472    if pkg_dir.exists() {
473        return Ok(pkg_dir);
474    }
475    std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
476        path: cache_root.display().to_string(),
477        detail: e.to_string(),
478    })?;
479
480    let url = match crate::registry::public(registry) {
481        Some(pr) => pr.archive_url(pkg_name, version),
482        None => format!(
483            "{}/v1/pkg/{}/{}/archive",
484            registry.trim_end_matches('/'),
485            pkg_name,
486            version,
487        ),
488    };
489    let response = ureq::get(&url).call().map_err(|e| PackageError::RegistryFailed {
490        name: pkg_name.to_string(),
491        registry: registry.to_string(),
492        version: version.to_string(),
493        detail: format!("GET {url}: {e}"),
494    })?;
495    if response.status() != 200 {
496        return Err(PackageError::RegistryFailed {
497            name: pkg_name.to_string(),
498            registry: registry.to_string(),
499            version: version.to_string(),
500            detail: format!("GET {url} returned HTTP {}", response.status()),
501        });
502    }
503
504    let archive_bytes = response
505        .into_body()
506        .read_to_vec()
507        .map_err(|e| PackageError::RegistryFailed {
508            name: pkg_name.to_string(),
509            registry: registry.to_string(),
510            version: version.to_string(),
511            detail: format!("reading response body: {e}"),
512        })?;
513
514    let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes));
515    let mut ar = tar::Archive::new(gz);
516    ar.unpack(&pkg_dir).map_err(|e| PackageError::RegistryFailed {
517        name: pkg_name.to_string(),
518        registry: registry.to_string(),
519        version: version.to_string(),
520        detail: format!("extracting archive: {e}"),
521    })?;
522
523    pkg_dir.canonicalize().map_err(|e| PackageError::Io {
524        path: pkg_dir.display().to_string(),
525        detail: e.to_string(),
526    })
527}
528
529/// The flat package cache root used by the resolver — `$LEX_PACKAGES_DIR`
530/// if set, otherwise `~/.lex/packages/`. Returns `None` only when neither is
531/// available. Exposed so tooling can recognise dependencies whose path
532/// resolves *into* this cache (and are therefore aliases of an
533/// already-installed package rather than independent sources).
534pub fn packages_cache_root() -> Option<PathBuf> {
535    packages_cache_dir().ok()
536}
537
538fn packages_cache_dir() -> Result<PathBuf, PackageError> {
539    if let Ok(dir) = std::env::var("LEX_PACKAGES_DIR") {
540        return Ok(PathBuf::from(dir));
541    }
542    let home = std::env::var("HOME")
543        .or_else(|_| std::env::var("USERPROFILE"))
544        .map_err(|_| PackageError::Io {
545            path: "~/.lex/packages".into(),
546            detail: "could not determine home directory (set LEX_PACKAGES_DIR)".into(),
547        })?;
548    Ok(PathBuf::from(home).join(".lex").join("packages"))
549}
550
551// ── Errors ────────────────────────────────────────────────────────────────────
552
553#[derive(Debug, thiserror::Error)]
554pub enum PackageError {
555    #[error("no lex.toml found searching up from {searched_from} (needed to resolve \"{reference}\")")]
556    NoManifest { reference: String, searched_from: String },
557
558    #[error("failed to parse {path}: {detail}")]
559    ManifestParse { path: String, detail: String },
560
561    #[error("package \"{name}\" not found in {manifest}")]
562    UnknownPackage { name: String, manifest: String },
563
564    #[error("module \"{module}\" not found in package \"{pkg}\" (looked in {pkg_root}/src/ and {pkg_root}/)")]
565    ModuleNotFound { pkg: String, module: String, pkg_root: String },
566
567    #[error("git clone of {url} failed: {detail}")]
568    GitFailed { url: String, detail: String },
569
570    #[error("registry fetch of {name}@{version} from {registry} failed: {detail}")]
571    RegistryFailed { name: String, registry: String, version: String, detail: String },
572
573    #[error("registry dependency \"{name}\" declares constraint \"{constraint}\" but is not in lex.lock — run `lex pkg lock` to pin a version")]
574    UnlockedRegistryDep { name: String, constraint: String },
575
576    #[error("I/O error at {path}: {detail}")]
577    Io { path: String, detail: String },
578}
579
580#[cfg(test)]
581mod floor_tests {
582    use super::{parse_version, satisfies_floor};
583
584    #[test]
585    fn parses_plain_and_v_prefixed() {
586        assert_eq!(parse_version("0.10.18"), Some((0, 10, 18)));
587        assert_eq!(parse_version("v0.10.18"), Some((0, 10, 18)));
588        assert_eq!(parse_version("  1.2.3 "), Some((1, 2, 3)));
589    }
590
591    #[test]
592    fn refuses_what_it_cannot_read_rather_than_guessing() {
593        // A floor it cannot parse must be reported as unknown, never
594        // silently treated as met (which would defeat the check) or as
595        // violated (which would fail working installs).
596        for bad in ["nightly", "0.10", "0.10.18.1", "", "0.x.1", "1.2.3-rc1"] {
597            assert_eq!(parse_version(bad), None, "should not parse: {bad}");
598            assert_eq!(satisfies_floor("0.10.18", bad), None);
599            assert_eq!(satisfies_floor(bad, "0.10.18"), None);
600        }
601    }
602
603    #[test]
604    fn compares_by_component_not_lexically() {
605        // The case that motivated #803: 0.10.11 < 0.10.15, though a
606        // string comparison says otherwise ("0.10.11" > "0.10.15" is
607        // false, but "0.10.9" > "0.10.15" is true lexically).
608        assert_eq!(satisfies_floor("0.10.11", "0.10.15"), Some(false));
609        assert_eq!(satisfies_floor("0.10.18", "0.10.15"), Some(true));
610        assert_eq!(satisfies_floor("0.10.9", "0.10.15"), Some(false));
611        assert_eq!(satisfies_floor("0.9.20", "0.10.0"), Some(false));
612    }
613
614    #[test]
615    fn an_exactly_met_floor_is_met() {
616        assert_eq!(satisfies_floor("0.10.15", "0.10.15"), Some(true));
617    }
618
619    #[test]
620    fn the_field_is_read_from_a_manifest() {
621        // Before #803 this field existed in every lex.toml in the org and
622        // was dropped on the floor by serde, so assert it survives parsing.
623        let m: super::Manifest = toml::from_str(
624            "[package]\nname = \"p\"\nversion = \"0.1.0\"\nlex = \"0.10.15\"\n",
625        )
626        .expect("parse");
627        assert_eq!(m.package.unwrap().lex.as_deref(), Some("0.10.15"));
628    }
629
630    #[test]
631    fn a_manifest_without_the_field_still_parses() {
632        let m: super::Manifest =
633            toml::from_str("[package]\nname = \"p\"\nversion = \"0.1.0\"\n").expect("parse");
634        assert_eq!(m.package.unwrap().lex, None);
635    }
636}
637
638#[cfg(test)]
639mod dual_ref_tests {
640    use super::{Dependency, Manifest};
641
642    fn dep<'a>(m: &'a Manifest, name: &str) -> &'a Dependency {
643        m.dependencies.get(name).expect("dep present")
644    }
645
646    #[test]
647    fn a_dep_with_both_git_and_registry_parses_as_both_not_git() {
648        // The untagged deserializer must pick `Both` (not `Git`, which is
649        // listed after it) when registry+version+git are all present —
650        // otherwise the vcs primary would be silently dropped.
651        let m: Manifest = toml::from_str(
652            "[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
653             [dependencies]\n\
654             lex-schema = { registry = \"vcs.lexlang.org/lex-official/lex-schema\", version = \"^0.9\", git = \"https://github.com/alpibrusl/lex-schema\" }\n",
655        )
656        .expect("parse");
657        let d = dep(&m, "lex-schema");
658        assert!(matches!(d, Dependency::Both { .. }), "got {d:?}");
659        assert_eq!(
660            d.registry_coord(),
661            Some(("vcs.lexlang.org/lex-official/lex-schema", "^0.9")),
662            "vcs primary must be readable"
663        );
664        assert_eq!(
665            d.git_coord(),
666            Some(("https://github.com/alpibrusl/lex-schema", None, None, None)),
667            "git mirror must be readable"
668        );
669        d.validate().expect("valid");
670    }
671
672    #[test]
673    fn bare_git_and_bare_registry_still_parse_to_their_own_variants() {
674        let m: Manifest = toml::from_str(
675            "[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
676             [dependencies]\n\
677             g = { git = \"https://example.com/g\", tag = \"v1\" }\n\
678             r = { registry = \"vcs.example/tenant/r\", version = \"^1.0\" }\n",
679        )
680        .expect("parse");
681        assert!(matches!(dep(&m, "g"), Dependency::Git { .. }));
682        assert!(matches!(dep(&m, "r"), Dependency::Registry { .. }));
683        // Accessors: bare git has no registry coord; bare registry has no git.
684        assert_eq!(dep(&m, "g").registry_coord(), None);
685        assert_eq!(dep(&m, "g").git_coord(), Some(("https://example.com/g", None, Some("v1"), None)));
686        assert_eq!(dep(&m, "r").registry_coord(), Some(("vcs.example/tenant/r", "^1.0")));
687        assert_eq!(dep(&m, "r").git_coord(), None);
688    }
689
690    #[test]
691    fn a_dual_dep_rejects_two_git_refs() {
692        let m: Manifest = toml::from_str(
693            "[package]\nname = \"c\"\nversion = \"0.1.0\"\n\n\
694             [dependencies]\n\
695             d = { registry = \"vcs/r\", version = \"1.0.0\", git = \"https://x/g\", branch = \"main\", tag = \"v1\" }\n",
696        )
697        .expect("parse");
698        assert!(dep(&m, "d").validate().is_err(), "two git refs must be rejected");
699    }
700}