Skip to main content

aura_lang/vfs/
mod.rs

1//! Virtual File System (SPEC §5): module resolution and loading behind a trait.
2
3pub 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    /// The exact version after resolution (D8).
14    Registry {
15        path: String,
16        version: String,
17    },
18    /// A placeholder for direct URL imports; not resolved yet (packages go through `aura add`).
19    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    /// version — as written in the source, with a `v` prefix (a range: `v1`, `v1.2`, or exact `v1.2.3`).
36    Registry {
37        path: &'s str,
38        version: &'s str,
39    },
40}
41
42pub trait FileResolver {
43    /// Canonicalizes the specifier relative to the importing module.
44    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
52/// A version as a sequence of numeric components; a range is a prefix match
53/// (`v1.2` is satisfied by any `1.2.*`, SPEC §5.2).
54pub 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
63/// The network registry convention (used ONLY by the `aura add` command;
64/// eval never touches the network): `github/<owner>/<repo>@vX.Y.Z` →
65/// the `package.aura` raw file at the repo's `vX.Y.Z` tag.
66pub 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
84/// Local disk: file imports relative to the importing file, registry —
85/// a local cache directory `<registry_dir>/<path>/<version>.aura`,
86/// populated by the `aura add` command (eval never touches the network).
87pub 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
161/// Dry-run wrapper (SPEC §6.3): module loads are performed but recorded into a report.
162pub 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
184/// An in-memory resolver for tests and dry-run snapshots. Keys: the file path as-is,
185/// registry — `"<path>@v<version>"` (exact match, no ranges).
186pub 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        // network install requires an exact version
232        assert!(registry_url("github/acme/aura-k8s", "v1.2").is_err());
233        // unknown host / bare name
234        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}