1pub mod loader;
4pub mod lockfile;
5
6use std::collections::HashMap;
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub enum ModuleId {
12 Local(PathBuf),
13 Registry {
15 path: String,
16 version: String,
17 },
18 Url(String),
20}
21
22impl fmt::Display for ModuleId {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 match self {
25 ModuleId::Local(p) => write!(f, "{}", p.display()),
26 ModuleId::Registry { path, version } => write!(f, "{path}@v{version}"),
27 ModuleId::Url(u) => write!(f, "{u}"),
28 }
29 }
30}
31
32#[derive(Debug, Clone, Copy)]
33pub enum ImportSpec<'s> {
34 File(&'s str),
35 Registry {
37 path: &'s str,
38 version: &'s str,
39 },
40}
41
42pub trait FileResolver {
43 fn resolve(
45 &self,
46 spec: &ImportSpec<'_>,
47 importer: Option<&ModuleId>,
48 ) -> Result<ModuleId, String>;
49 fn load(&self, id: &ModuleId) -> Result<String, String>;
50}
51
52pub fn parse_version(s: &str) -> Option<Vec<u64>> {
55 let s = s.strip_prefix('v').unwrap_or(s);
56 s.split('.').map(|c| c.parse().ok()).collect()
57}
58
59pub fn version_satisfies(request: &[u64], exact: &[u64]) -> bool {
60 exact.len() >= request.len() && request.iter().zip(exact).all(|(a, b)| a == b)
61}
62
63pub fn registry_url(path: &str, version: &str) -> Result<String, String> {
67 let version = version.strip_prefix('v').unwrap_or(version);
68 if parse_version(version).is_none_or(|v| v.len() != 3) {
69 return Err(format!(
70 "network install requires an exact version (vX.Y.Z), got 'v{version}'"
71 ));
72 }
73 let segments: Vec<&str> = path.split('/').collect();
74 match segments.as_slice() {
75 ["github", owner, repo] => Ok(format!(
76 "https://raw.githubusercontent.com/{owner}/{repo}/v{version}/package.aura"
77 )),
78 _ => Err(format!(
79 "unsupported registry path '{path}': expected github/<owner>/<repo>"
80 )),
81 }
82}
83
84pub struct LocalFsResolver {
88 pub root: PathBuf,
89 pub registry_dir: PathBuf,
90}
91
92impl FileResolver for LocalFsResolver {
93 fn resolve(
94 &self,
95 spec: &ImportSpec<'_>,
96 importer: Option<&ModuleId>,
97 ) -> Result<ModuleId, String> {
98 match spec {
99 ImportSpec::File(rel) => {
100 let base = match importer {
101 Some(ModuleId::Local(p)) => p.parent().unwrap_or(Path::new(".")).to_path_buf(),
102 _ => self.root.clone(),
103 };
104 let joined = base.join(rel);
105 let canon = std::fs::canonicalize(&joined)
106 .map_err(|e| format!("cannot resolve '{rel}': {e}"))?;
107 Ok(ModuleId::Local(canon))
108 }
109 ImportSpec::Registry { path, version } => {
110 let request = parse_version(version)
111 .ok_or_else(|| format!("malformed version '{version}'"))?;
112 let dir = self.registry_dir.join(path);
113 let mut best: Option<Vec<u64>> = None;
114 let entries = std::fs::read_dir(&dir).map_err(|_| {
115 format!(
116 "module '{path}' not found in registry cache {}",
117 dir.display()
118 )
119 })?;
120 for entry in entries.flatten() {
121 let file = entry.file_name();
122 let Some(stem) = Path::new(&file).file_stem().and_then(|s| s.to_str()) else {
123 continue;
124 };
125 let Some(candidate) = parse_version(stem) else {
126 continue;
127 };
128 if version_satisfies(&request, &candidate)
129 && best.as_ref().is_none_or(|b| candidate > *b)
130 {
131 best = Some(candidate);
132 }
133 }
134 let best = best
135 .ok_or_else(|| format!("no cached version of '{path}' satisfies {version}"))?;
136 let exact = best
137 .iter()
138 .map(u64::to_string)
139 .collect::<Vec<_>>()
140 .join(".");
141 Ok(ModuleId::Registry {
142 path: path.to_string(),
143 version: exact,
144 })
145 }
146 }
147 }
148
149 fn load(&self, id: &ModuleId) -> Result<String, String> {
150 match id {
151 ModuleId::Local(p) => std::fs::read_to_string(p).map_err(|e| e.to_string()),
152 ModuleId::Registry { path, version } => {
153 let file = self.registry_dir.join(path).join(format!("{version}.aura"));
154 std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))
155 }
156 ModuleId::Url(u) => Err(format!("url imports are not supported yet: {u}")),
157 }
158 }
159}
160
161pub struct RecordingResolver<'r> {
163 pub inner: &'r dyn FileResolver,
164 pub log: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
165}
166
167impl FileResolver for RecordingResolver<'_> {
168 fn resolve(
169 &self,
170 spec: &ImportSpec<'_>,
171 importer: Option<&ModuleId>,
172 ) -> Result<ModuleId, String> {
173 self.inner.resolve(spec, importer)
174 }
175 fn load(&self, id: &ModuleId) -> Result<String, String> {
176 let result = self.inner.load(id);
177 if result.is_ok() {
178 self.log.borrow_mut().push(id.to_string());
179 }
180 result
181 }
182}
183
184pub struct MemoryResolver {
187 pub files: HashMap<String, String>,
188}
189
190impl FileResolver for MemoryResolver {
191 fn resolve(
192 &self,
193 spec: &ImportSpec<'_>,
194 _importer: Option<&ModuleId>,
195 ) -> Result<ModuleId, String> {
196 match spec {
197 ImportSpec::File(p) => Ok(ModuleId::Local(PathBuf::from(p))),
198 ImportSpec::Registry { path, version } => {
199 let version = version.strip_prefix('v').unwrap_or(version);
200 Ok(ModuleId::Registry {
201 path: path.to_string(),
202 version: version.to_string(),
203 })
204 }
205 }
206 }
207
208 fn load(&self, id: &ModuleId) -> Result<String, String> {
209 let key = match id {
210 ModuleId::Local(p) => p.to_string_lossy().replace('\\', "/"),
211 ModuleId::Registry { path, version } => format!("{path}@v{version}"),
212 ModuleId::Url(u) => u.clone(),
213 };
214 self.files
215 .get(&key)
216 .cloned()
217 .ok_or_else(|| format!("not found: {key}"))
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::registry_url;
224
225 #[test]
226 fn registry_url_convention() {
227 assert_eq!(
228 registry_url("github/acme/aura-k8s", "v1.2.3").unwrap(),
229 "https://raw.githubusercontent.com/acme/aura-k8s/v1.2.3/package.aura"
230 );
231 assert!(registry_url("github/acme/aura-k8s", "v1.2").is_err());
233 assert!(registry_url("gitlab/acme/pkg", "v1.0.0").is_err());
235 assert!(registry_url("just-a-name", "v1.0.0").is_err());
236 }
237}