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