1use serde::Deserialize;
42use std::collections::HashMap;
43use std::path::{Path, PathBuf};
44
45#[derive(Debug, Deserialize)]
48pub struct Manifest {
49 pub package: Option<PackageMeta>,
50 #[serde(default)]
51 pub dependencies: HashMap<String, Dependency>,
52 #[serde(default)]
56 pub store: Option<StoreSection>,
57}
58
59#[derive(Debug, Default, Deserialize)]
61pub struct StoreSection {
62 #[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 #[serde(default)]
77 pub registry: Option<String>,
78 #[serde(default)]
87 pub lex: Option<String>,
88}
89
90pub 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
107pub 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 Git {
120 git: String,
121 #[serde(default)]
122 branch: Option<String>,
123 #[serde(default)]
124 tag: Option<String>,
125 #[serde(default)]
126 rev: Option<String>,
127 },
128 Registry { registry: String, version: String },
129}
130
131impl Dependency {
132 pub fn validate(&self) -> Result<(), String> {
134 if let Dependency::Git { branch, tag, rev, .. } = self {
135 let count = [branch, tag, rev].iter().filter(|o| o.is_some()).count();
136 if count > 1 {
137 return Err("at most one of `branch`, `tag`, `rev` may be set on a git dependency".into());
138 }
139 }
140 Ok(())
141 }
142}
143
144impl Manifest {
145 pub fn load(toml_path: &Path) -> Result<Self, String> {
146 let src = std::fs::read_to_string(toml_path)
147 .map_err(|e| format!("reading {}: {e}", toml_path.display()))?;
148 toml::from_str(&src)
149 .map_err(|e| format!("parsing {}: {e}", toml_path.display()))
150 }
151}
152
153pub fn find_manifest(start: &Path) -> Option<(PathBuf, PathBuf)> {
158 let mut dir = if start.is_dir() {
159 start.to_path_buf()
160 } else {
161 start.parent()?.to_path_buf()
162 };
163 loop {
164 let candidate = dir.join("lex.toml");
165 if candidate.exists() {
166 return Some((candidate, dir));
167 }
168 match dir.parent() {
169 Some(p) if p != dir => dir = p.to_path_buf(),
170 _ => return None,
171 }
172 }
173}
174
175pub fn resolve_package_import(
182 importer: &Path,
183 pkg_name: &str,
184 module_path: &str,
185) -> Result<PathBuf, PackageError> {
186 let (toml_path, toml_dir) = find_manifest(importer).ok_or_else(|| {
187 PackageError::NoManifest {
188 reference: format!("{pkg_name}/{module_path}"),
189 searched_from: importer.display().to_string(),
190 }
191 })?;
192
193 let manifest = Manifest::load(&toml_path)
194 .map_err(|e| PackageError::ManifestParse { path: toml_path.display().to_string(), detail: e })?;
195
196 let dep = manifest.dependencies.get(pkg_name).ok_or_else(|| {
197 PackageError::UnknownPackage {
198 name: pkg_name.to_string(),
199 manifest: toml_path.display().to_string(),
200 }
201 })?;
202
203 let pkg_root = match dep {
204 Dependency::Path { path } => {
205 let raw = toml_dir.join(path);
206 raw.canonicalize().map_err(|e| PackageError::Io {
207 path: raw.display().to_string(),
208 detail: e.to_string(),
209 })?
210 }
211 Dependency::Git { git, branch, tag, rev } => {
212 dep.validate().map_err(|e| PackageError::ManifestParse {
213 path: toml_path.display().to_string(),
214 detail: e,
215 })?;
216 let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
217 git_ensure_cached(pkg_name, git, &git_ref)?
218 }
219 Dependency::Registry { registry, version } => {
220 let locked = crate::lock::LockFile::load_dir(&toml_dir)
224 .and_then(|lf| lf.entry(pkg_name).map(|e| e.version.clone()));
225 let effective = match locked {
226 Some(v) => v,
227 None if crate::semver::parse_exact(version).is_some() => version.clone(),
228 None => {
229 return Err(PackageError::UnlockedRegistryDep {
232 name: pkg_name.to_string(),
233 constraint: version.clone(),
234 });
235 }
236 };
237 registry_ensure_cached(pkg_name, registry, &effective)?
238 }
239 };
240
241 find_module_file(&pkg_root, module_path).ok_or_else(|| PackageError::ModuleNotFound {
242 pkg: pkg_name.to_string(),
243 module: module_path.to_string(),
244 pkg_root: pkg_root.display().to_string(),
245 })
246}
247
248fn find_module_file(pkg_root: &Path, module_path: &str) -> Option<PathBuf> {
251 let rel = PathBuf::from(module_path).with_extension("lex");
252 let in_src = pkg_root.join("src").join(&rel);
253 if in_src.exists() {
254 return Some(in_src);
255 }
256 let at_root = pkg_root.join(&rel);
257 if at_root.exists() {
258 return Some(at_root);
259 }
260 None
261}
262
263#[derive(Debug)]
267enum GitRef<'a> {
268 Branch(&'a str),
269 Tag(&'a str),
270 Rev(&'a str),
271 DefaultBranch,
272}
273
274impl<'a> GitRef<'a> {
275 fn from(branch: Option<&'a str>, tag: Option<&'a str>, rev: Option<&'a str>) -> Self {
276 if let Some(b) = branch { return GitRef::Branch(b); }
277 if let Some(t) = tag { return GitRef::Tag(t); }
278 if let Some(r) = rev { return GitRef::Rev(r); }
279 GitRef::DefaultBranch
280 }
281
282 fn cache_slug(&self) -> String {
285 match self {
286 GitRef::Branch(b) => format!("@branch-{}", sanitize_ref(b)),
287 GitRef::Tag(t) => format!("@tag-{}", sanitize_ref(t)),
288 GitRef::Rev(r) => format!("@rev-{}", &r[..r.len().min(12)]),
289 GitRef::DefaultBranch => String::new(),
290 }
291 }
292}
293
294fn sanitize_ref(r: &str) -> String {
296 r.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect()
297}
298
299fn git_ensure_cached(pkg_name: &str, url: &str, git_ref: &GitRef<'_>) -> Result<PathBuf, PackageError> {
305 let cache_root = packages_cache_dir()?;
306 let dir_name = format!("{}{}", pkg_name, git_ref.cache_slug());
307 let pkg_dir = cache_root.join(&dir_name);
308 if pkg_dir.exists() {
309 return Ok(pkg_dir);
310 }
311 std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
312 path: cache_root.display().to_string(),
313 detail: e.to_string(),
314 })?;
315
316 let dest = pkg_dir.to_str().unwrap_or(&dir_name);
317
318 let status = match git_ref {
319 GitRef::Rev(rev) => {
320 let s = run_git(&["clone", "--quiet", url, dest], url)?;
323 if s {
324 run_git(&["-C", dest, "checkout", "--quiet", rev], url)?;
325 true
326 } else {
327 false
328 }
329 }
330 GitRef::Tag(tag) => run_git(&["clone", "--quiet", "--depth=1", "--branch", tag, url, dest], url)?,
331 GitRef::Branch(branch) => run_git(&["clone", "--quiet", "--depth=1", "--branch", branch, url, dest], url)?,
332 GitRef::DefaultBranch => run_git(&["clone", "--quiet", "--depth=1", url, dest], url)?,
333 };
334
335 if !status {
336 let _ = std::fs::remove_dir_all(&pkg_dir);
338 return Err(PackageError::GitFailed {
339 url: url.to_string(),
340 detail: "`git` exited with non-zero status".into(),
341 });
342 }
343
344 pkg_dir.canonicalize().map_err(|e| PackageError::Io {
345 path: pkg_dir.display().to_string(),
346 detail: e.to_string(),
347 })
348}
349
350fn run_git(args: &[&str], url: &str) -> Result<bool, PackageError> {
353 let status = std::process::Command::new("git")
354 .args(args)
355 .status()
356 .map_err(|e| PackageError::GitFailed {
357 url: url.to_string(),
358 detail: format!("could not run `git`: {e}"),
359 })?;
360 Ok(status.success())
361}
362
363fn registry_ensure_cached(
373 pkg_name: &str,
374 registry: &str,
375 version: &str,
376) -> Result<PathBuf, PackageError> {
377 let cache_root = packages_cache_dir()?;
378 let pkg_dir = cache_root.join(format!("{pkg_name}-{version}"));
381 if pkg_dir.exists() {
382 return Ok(pkg_dir);
383 }
384 std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
385 path: cache_root.display().to_string(),
386 detail: e.to_string(),
387 })?;
388
389 let url = match crate::registry::public(registry) {
390 Some(pr) => pr.archive_url(pkg_name, version),
391 None => format!(
392 "{}/v1/pkg/{}/{}/archive",
393 registry.trim_end_matches('/'),
394 pkg_name,
395 version,
396 ),
397 };
398 let response = ureq::get(&url).call().map_err(|e| PackageError::RegistryFailed {
399 name: pkg_name.to_string(),
400 registry: registry.to_string(),
401 version: version.to_string(),
402 detail: format!("GET {url}: {e}"),
403 })?;
404 if response.status() != 200 {
405 return Err(PackageError::RegistryFailed {
406 name: pkg_name.to_string(),
407 registry: registry.to_string(),
408 version: version.to_string(),
409 detail: format!("GET {url} returned HTTP {}", response.status()),
410 });
411 }
412
413 let archive_bytes = response
414 .into_body()
415 .read_to_vec()
416 .map_err(|e| PackageError::RegistryFailed {
417 name: pkg_name.to_string(),
418 registry: registry.to_string(),
419 version: version.to_string(),
420 detail: format!("reading response body: {e}"),
421 })?;
422
423 let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes));
424 let mut ar = tar::Archive::new(gz);
425 ar.unpack(&pkg_dir).map_err(|e| PackageError::RegistryFailed {
426 name: pkg_name.to_string(),
427 registry: registry.to_string(),
428 version: version.to_string(),
429 detail: format!("extracting archive: {e}"),
430 })?;
431
432 pkg_dir.canonicalize().map_err(|e| PackageError::Io {
433 path: pkg_dir.display().to_string(),
434 detail: e.to_string(),
435 })
436}
437
438pub fn packages_cache_root() -> Option<PathBuf> {
444 packages_cache_dir().ok()
445}
446
447fn packages_cache_dir() -> Result<PathBuf, PackageError> {
448 if let Ok(dir) = std::env::var("LEX_PACKAGES_DIR") {
449 return Ok(PathBuf::from(dir));
450 }
451 let home = std::env::var("HOME")
452 .or_else(|_| std::env::var("USERPROFILE"))
453 .map_err(|_| PackageError::Io {
454 path: "~/.lex/packages".into(),
455 detail: "could not determine home directory (set LEX_PACKAGES_DIR)".into(),
456 })?;
457 Ok(PathBuf::from(home).join(".lex").join("packages"))
458}
459
460#[derive(Debug, thiserror::Error)]
463pub enum PackageError {
464 #[error("no lex.toml found searching up from {searched_from} (needed to resolve \"{reference}\")")]
465 NoManifest { reference: String, searched_from: String },
466
467 #[error("failed to parse {path}: {detail}")]
468 ManifestParse { path: String, detail: String },
469
470 #[error("package \"{name}\" not found in {manifest}")]
471 UnknownPackage { name: String, manifest: String },
472
473 #[error("module \"{module}\" not found in package \"{pkg}\" (looked in {pkg_root}/src/ and {pkg_root}/)")]
474 ModuleNotFound { pkg: String, module: String, pkg_root: String },
475
476 #[error("git clone of {url} failed: {detail}")]
477 GitFailed { url: String, detail: String },
478
479 #[error("registry fetch of {name}@{version} from {registry} failed: {detail}")]
480 RegistryFailed { name: String, registry: String, version: String, detail: String },
481
482 #[error("registry dependency \"{name}\" declares constraint \"{constraint}\" but is not in lex.lock — run `lex pkg lock` to pin a version")]
483 UnlockedRegistryDep { name: String, constraint: String },
484
485 #[error("I/O error at {path}: {detail}")]
486 Io { path: String, detail: String },
487}
488
489#[cfg(test)]
490mod floor_tests {
491 use super::{parse_version, satisfies_floor};
492
493 #[test]
494 fn parses_plain_and_v_prefixed() {
495 assert_eq!(parse_version("0.10.18"), Some((0, 10, 18)));
496 assert_eq!(parse_version("v0.10.18"), Some((0, 10, 18)));
497 assert_eq!(parse_version(" 1.2.3 "), Some((1, 2, 3)));
498 }
499
500 #[test]
501 fn refuses_what_it_cannot_read_rather_than_guessing() {
502 for bad in ["nightly", "0.10", "0.10.18.1", "", "0.x.1", "1.2.3-rc1"] {
506 assert_eq!(parse_version(bad), None, "should not parse: {bad}");
507 assert_eq!(satisfies_floor("0.10.18", bad), None);
508 assert_eq!(satisfies_floor(bad, "0.10.18"), None);
509 }
510 }
511
512 #[test]
513 fn compares_by_component_not_lexically() {
514 assert_eq!(satisfies_floor("0.10.11", "0.10.15"), Some(false));
518 assert_eq!(satisfies_floor("0.10.18", "0.10.15"), Some(true));
519 assert_eq!(satisfies_floor("0.10.9", "0.10.15"), Some(false));
520 assert_eq!(satisfies_floor("0.9.20", "0.10.0"), Some(false));
521 }
522
523 #[test]
524 fn an_exactly_met_floor_is_met() {
525 assert_eq!(satisfies_floor("0.10.15", "0.10.15"), Some(true));
526 }
527
528 #[test]
529 fn the_field_is_read_from_a_manifest() {
530 let m: super::Manifest = toml::from_str(
533 "[package]\nname = \"p\"\nversion = \"0.1.0\"\nlex = \"0.10.15\"\n",
534 )
535 .expect("parse");
536 assert_eq!(m.package.unwrap().lex.as_deref(), Some("0.10.15"));
537 }
538
539 #[test]
540 fn a_manifest_without_the_field_still_parses() {
541 let m: super::Manifest =
542 toml::from_str("[package]\nname = \"p\"\nversion = \"0.1.0\"\n").expect("parse");
543 assert_eq!(m.package.unwrap().lex, None);
544 }
545}