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}
79
80#[derive(Debug, Deserialize)]
81#[serde(untagged)]
82pub enum Dependency {
83 Path { path: String },
84 Git {
85 git: String,
86 #[serde(default)]
87 branch: Option<String>,
88 #[serde(default)]
89 tag: Option<String>,
90 #[serde(default)]
91 rev: Option<String>,
92 },
93 Registry { registry: String, version: String },
94}
95
96impl Dependency {
97 pub fn validate(&self) -> Result<(), String> {
99 if let Dependency::Git { branch, tag, rev, .. } = self {
100 let count = [branch, tag, rev].iter().filter(|o| o.is_some()).count();
101 if count > 1 {
102 return Err("at most one of `branch`, `tag`, `rev` may be set on a git dependency".into());
103 }
104 }
105 Ok(())
106 }
107}
108
109impl Manifest {
110 pub fn load(toml_path: &Path) -> Result<Self, String> {
111 let src = std::fs::read_to_string(toml_path)
112 .map_err(|e| format!("reading {}: {e}", toml_path.display()))?;
113 toml::from_str(&src)
114 .map_err(|e| format!("parsing {}: {e}", toml_path.display()))
115 }
116}
117
118pub fn find_manifest(start: &Path) -> Option<(PathBuf, PathBuf)> {
123 let mut dir = if start.is_dir() {
124 start.to_path_buf()
125 } else {
126 start.parent()?.to_path_buf()
127 };
128 loop {
129 let candidate = dir.join("lex.toml");
130 if candidate.exists() {
131 return Some((candidate, dir));
132 }
133 match dir.parent() {
134 Some(p) if p != dir => dir = p.to_path_buf(),
135 _ => return None,
136 }
137 }
138}
139
140pub fn resolve_package_import(
147 importer: &Path,
148 pkg_name: &str,
149 module_path: &str,
150) -> Result<PathBuf, PackageError> {
151 let (toml_path, toml_dir) = find_manifest(importer).ok_or_else(|| {
152 PackageError::NoManifest {
153 reference: format!("{pkg_name}/{module_path}"),
154 searched_from: importer.display().to_string(),
155 }
156 })?;
157
158 let manifest = Manifest::load(&toml_path)
159 .map_err(|e| PackageError::ManifestParse { path: toml_path.display().to_string(), detail: e })?;
160
161 let dep = manifest.dependencies.get(pkg_name).ok_or_else(|| {
162 PackageError::UnknownPackage {
163 name: pkg_name.to_string(),
164 manifest: toml_path.display().to_string(),
165 }
166 })?;
167
168 let pkg_root = match dep {
169 Dependency::Path { path } => {
170 let raw = toml_dir.join(path);
171 raw.canonicalize().map_err(|e| PackageError::Io {
172 path: raw.display().to_string(),
173 detail: e.to_string(),
174 })?
175 }
176 Dependency::Git { git, branch, tag, rev } => {
177 dep.validate().map_err(|e| PackageError::ManifestParse {
178 path: toml_path.display().to_string(),
179 detail: e,
180 })?;
181 let git_ref = GitRef::from(branch.as_deref(), tag.as_deref(), rev.as_deref());
182 git_ensure_cached(pkg_name, git, &git_ref)?
183 }
184 Dependency::Registry { registry, version } => {
185 registry_ensure_cached(pkg_name, registry, version)?
186 }
187 };
188
189 find_module_file(&pkg_root, module_path).ok_or_else(|| PackageError::ModuleNotFound {
190 pkg: pkg_name.to_string(),
191 module: module_path.to_string(),
192 pkg_root: pkg_root.display().to_string(),
193 })
194}
195
196fn find_module_file(pkg_root: &Path, module_path: &str) -> Option<PathBuf> {
199 let rel = PathBuf::from(module_path).with_extension("lex");
200 let in_src = pkg_root.join("src").join(&rel);
201 if in_src.exists() {
202 return Some(in_src);
203 }
204 let at_root = pkg_root.join(&rel);
205 if at_root.exists() {
206 return Some(at_root);
207 }
208 None
209}
210
211#[derive(Debug)]
215enum GitRef<'a> {
216 Branch(&'a str),
217 Tag(&'a str),
218 Rev(&'a str),
219 DefaultBranch,
220}
221
222impl<'a> GitRef<'a> {
223 fn from(branch: Option<&'a str>, tag: Option<&'a str>, rev: Option<&'a str>) -> Self {
224 if let Some(b) = branch { return GitRef::Branch(b); }
225 if let Some(t) = tag { return GitRef::Tag(t); }
226 if let Some(r) = rev { return GitRef::Rev(r); }
227 GitRef::DefaultBranch
228 }
229
230 fn cache_slug(&self) -> String {
233 match self {
234 GitRef::Branch(b) => format!("@branch-{}", sanitize_ref(b)),
235 GitRef::Tag(t) => format!("@tag-{}", sanitize_ref(t)),
236 GitRef::Rev(r) => format!("@rev-{}", &r[..r.len().min(12)]),
237 GitRef::DefaultBranch => String::new(),
238 }
239 }
240}
241
242fn sanitize_ref(r: &str) -> String {
244 r.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect()
245}
246
247fn git_ensure_cached(pkg_name: &str, url: &str, git_ref: &GitRef<'_>) -> Result<PathBuf, PackageError> {
253 let cache_root = packages_cache_dir()?;
254 let dir_name = format!("{}{}", pkg_name, git_ref.cache_slug());
255 let pkg_dir = cache_root.join(&dir_name);
256 if pkg_dir.exists() {
257 return Ok(pkg_dir);
258 }
259 std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
260 path: cache_root.display().to_string(),
261 detail: e.to_string(),
262 })?;
263
264 let dest = pkg_dir.to_str().unwrap_or(&dir_name);
265
266 let status = match git_ref {
267 GitRef::Rev(rev) => {
268 let s = run_git(&["clone", "--quiet", url, dest], url)?;
271 if s {
272 run_git(&["-C", dest, "checkout", "--quiet", rev], url)?;
273 true
274 } else {
275 false
276 }
277 }
278 GitRef::Tag(tag) => run_git(&["clone", "--quiet", "--depth=1", "--branch", tag, url, dest], url)?,
279 GitRef::Branch(branch) => run_git(&["clone", "--quiet", "--depth=1", "--branch", branch, url, dest], url)?,
280 GitRef::DefaultBranch => run_git(&["clone", "--quiet", "--depth=1", url, dest], url)?,
281 };
282
283 if !status {
284 let _ = std::fs::remove_dir_all(&pkg_dir);
286 return Err(PackageError::GitFailed {
287 url: url.to_string(),
288 detail: "`git` exited with non-zero status".into(),
289 });
290 }
291
292 pkg_dir.canonicalize().map_err(|e| PackageError::Io {
293 path: pkg_dir.display().to_string(),
294 detail: e.to_string(),
295 })
296}
297
298fn run_git(args: &[&str], url: &str) -> Result<bool, PackageError> {
301 let status = std::process::Command::new("git")
302 .args(args)
303 .status()
304 .map_err(|e| PackageError::GitFailed {
305 url: url.to_string(),
306 detail: format!("could not run `git`: {e}"),
307 })?;
308 Ok(status.success())
309}
310
311fn registry_ensure_cached(
318 pkg_name: &str,
319 registry: &str,
320 version: &str,
321) -> Result<PathBuf, PackageError> {
322 let cache_root = packages_cache_dir()?;
323 let pkg_dir = cache_root.join(format!("{pkg_name}-{version}"));
326 if pkg_dir.exists() {
327 return Ok(pkg_dir);
328 }
329 std::fs::create_dir_all(&cache_root).map_err(|e| PackageError::Io {
330 path: cache_root.display().to_string(),
331 detail: e.to_string(),
332 })?;
333
334 let url = format!(
335 "{}/v1/pkg/{}/{}/archive",
336 registry.trim_end_matches('/'),
337 pkg_name,
338 version,
339 );
340 let response = ureq::get(&url).call().map_err(|e| PackageError::RegistryFailed {
341 name: pkg_name.to_string(),
342 registry: registry.to_string(),
343 version: version.to_string(),
344 detail: format!("GET {url}: {e}"),
345 })?;
346 if response.status() != 200 {
347 return Err(PackageError::RegistryFailed {
348 name: pkg_name.to_string(),
349 registry: registry.to_string(),
350 version: version.to_string(),
351 detail: format!("GET {url} returned HTTP {}", response.status()),
352 });
353 }
354
355 let archive_bytes = response
356 .into_body()
357 .read_to_vec()
358 .map_err(|e| PackageError::RegistryFailed {
359 name: pkg_name.to_string(),
360 registry: registry.to_string(),
361 version: version.to_string(),
362 detail: format!("reading response body: {e}"),
363 })?;
364
365 let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive_bytes));
366 let mut ar = tar::Archive::new(gz);
367 ar.unpack(&pkg_dir).map_err(|e| PackageError::RegistryFailed {
368 name: pkg_name.to_string(),
369 registry: registry.to_string(),
370 version: version.to_string(),
371 detail: format!("extracting archive: {e}"),
372 })?;
373
374 pkg_dir.canonicalize().map_err(|e| PackageError::Io {
375 path: pkg_dir.display().to_string(),
376 detail: e.to_string(),
377 })
378}
379
380pub fn packages_cache_root() -> Option<PathBuf> {
386 packages_cache_dir().ok()
387}
388
389fn packages_cache_dir() -> Result<PathBuf, PackageError> {
390 if let Ok(dir) = std::env::var("LEX_PACKAGES_DIR") {
391 return Ok(PathBuf::from(dir));
392 }
393 let home = std::env::var("HOME")
394 .or_else(|_| std::env::var("USERPROFILE"))
395 .map_err(|_| PackageError::Io {
396 path: "~/.lex/packages".into(),
397 detail: "could not determine home directory (set LEX_PACKAGES_DIR)".into(),
398 })?;
399 Ok(PathBuf::from(home).join(".lex").join("packages"))
400}
401
402#[derive(Debug, thiserror::Error)]
405pub enum PackageError {
406 #[error("no lex.toml found searching up from {searched_from} (needed to resolve \"{reference}\")")]
407 NoManifest { reference: String, searched_from: String },
408
409 #[error("failed to parse {path}: {detail}")]
410 ManifestParse { path: String, detail: String },
411
412 #[error("package \"{name}\" not found in {manifest}")]
413 UnknownPackage { name: String, manifest: String },
414
415 #[error("module \"{module}\" not found in package \"{pkg}\" (looked in {pkg_root}/src/ and {pkg_root}/)")]
416 ModuleNotFound { pkg: String, module: String, pkg_root: String },
417
418 #[error("git clone of {url} failed: {detail}")]
419 GitFailed { url: String, detail: String },
420
421 #[error("registry fetch of {name}@{version} from {registry} failed: {detail}")]
422 RegistryFailed { name: String, registry: String, version: String, detail: String },
423
424 #[error("I/O error at {path}: {detail}")]
425 Io { path: String, detail: String },
426}