pub mod loader;
pub mod lockfile;
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ModuleId {
Local(PathBuf),
Registry {
path: String,
version: String,
},
Url(String),
}
impl fmt::Display for ModuleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ModuleId::Local(p) => write!(f, "{}", p.display()),
ModuleId::Registry { path, version } => write!(f, "{path}@v{version}"),
ModuleId::Url(u) => write!(f, "{u}"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum ImportSpec<'s> {
File(&'s str),
Registry {
path: &'s str,
version: &'s str,
},
}
pub trait FileResolver {
fn resolve(
&self,
spec: &ImportSpec<'_>,
importer: Option<&ModuleId>,
) -> Result<ModuleId, String>;
fn load(&self, id: &ModuleId) -> Result<String, String>;
}
pub fn parse_version(s: &str) -> Option<Vec<u64>> {
let s = s.strip_prefix('v').unwrap_or(s);
s.split('.').map(|c| c.parse().ok()).collect()
}
pub fn version_satisfies(request: &[u64], exact: &[u64]) -> bool {
exact.len() >= request.len() && request.iter().zip(exact).all(|(a, b)| a == b)
}
pub fn registry_url(path: &str, version: &str) -> Result<String, String> {
let version = version.strip_prefix('v').unwrap_or(version);
if parse_version(version).is_none_or(|v| v.len() != 3) {
return Err(format!(
"network install requires an exact version (vX.Y.Z), got 'v{version}'"
));
}
let segments: Vec<&str> = path.split('/').collect();
match segments.as_slice() {
["github", owner, repo] => Ok(format!(
"https://raw.githubusercontent.com/{owner}/{repo}/v{version}/package.aura"
)),
_ => Err(format!(
"unsupported registry path '{path}': expected github/<owner>/<repo>"
)),
}
}
pub struct LocalFsResolver {
pub root: PathBuf,
pub registry_dir: PathBuf,
}
impl FileResolver for LocalFsResolver {
fn resolve(
&self,
spec: &ImportSpec<'_>,
importer: Option<&ModuleId>,
) -> Result<ModuleId, String> {
match spec {
ImportSpec::File(rel) => {
let base = match importer {
Some(ModuleId::Local(p)) => p.parent().unwrap_or(Path::new(".")).to_path_buf(),
_ => self.root.clone(),
};
let joined = base.join(rel);
let canon = std::fs::canonicalize(&joined)
.map_err(|e| format!("cannot resolve '{rel}': {e}"))?;
Ok(ModuleId::Local(canon))
}
ImportSpec::Registry { path, version } => {
let request = parse_version(version)
.ok_or_else(|| format!("malformed version '{version}'"))?;
let dir = self.registry_dir.join(path);
let mut best: Option<Vec<u64>> = None;
let entries = std::fs::read_dir(&dir).map_err(|_| {
format!(
"module '{path}' not found in registry cache {}",
dir.display()
)
})?;
for entry in entries.flatten() {
let file = entry.file_name();
let Some(stem) = Path::new(&file).file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Some(candidate) = parse_version(stem) else {
continue;
};
if version_satisfies(&request, &candidate)
&& best.as_ref().is_none_or(|b| candidate > *b)
{
best = Some(candidate);
}
}
let best = best
.ok_or_else(|| format!("no cached version of '{path}' satisfies {version}"))?;
let exact = best
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(".");
Ok(ModuleId::Registry {
path: path.to_string(),
version: exact,
})
}
}
}
fn load(&self, id: &ModuleId) -> Result<String, String> {
match id {
ModuleId::Local(p) => std::fs::read_to_string(p).map_err(|e| e.to_string()),
ModuleId::Registry { path, version } => {
let file = self.registry_dir.join(path).join(format!("{version}.aura"));
std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))
}
ModuleId::Url(u) => Err(format!("url imports are not supported yet: {u}")),
}
}
}
pub struct RecordingResolver<'r> {
pub inner: &'r dyn FileResolver,
pub log: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
}
impl FileResolver for RecordingResolver<'_> {
fn resolve(
&self,
spec: &ImportSpec<'_>,
importer: Option<&ModuleId>,
) -> Result<ModuleId, String> {
self.inner.resolve(spec, importer)
}
fn load(&self, id: &ModuleId) -> Result<String, String> {
let result = self.inner.load(id);
if result.is_ok() {
self.log.borrow_mut().push(id.to_string());
}
result
}
}
pub struct MemoryResolver {
pub files: HashMap<String, String>,
}
impl FileResolver for MemoryResolver {
fn resolve(
&self,
spec: &ImportSpec<'_>,
_importer: Option<&ModuleId>,
) -> Result<ModuleId, String> {
match spec {
ImportSpec::File(p) => Ok(ModuleId::Local(PathBuf::from(p))),
ImportSpec::Registry { path, version } => {
let version = version.strip_prefix('v').unwrap_or(version);
Ok(ModuleId::Registry {
path: path.to_string(),
version: version.to_string(),
})
}
}
}
fn load(&self, id: &ModuleId) -> Result<String, String> {
let key = match id {
ModuleId::Local(p) => p.to_string_lossy().replace('\\', "/"),
ModuleId::Registry { path, version } => format!("{path}@v{version}"),
ModuleId::Url(u) => u.clone(),
};
self.files
.get(&key)
.cloned()
.ok_or_else(|| format!("not found: {key}"))
}
}
#[cfg(test)]
mod tests {
use super::registry_url;
#[test]
fn registry_url_convention() {
assert_eq!(
registry_url("github/acme/aura-k8s", "v1.2.3").unwrap(),
"https://raw.githubusercontent.com/acme/aura-k8s/v1.2.3/package.aura"
);
assert!(registry_url("github/acme/aura-k8s", "v1.2").is_err());
assert!(registry_url("gitlab/acme/pkg", "v1.0.0").is_err());
assert!(registry_url("just-a-name", "v1.0.0").is_err());
}
}