use std::path::{Path, PathBuf};
use std::process::Command;
pub struct Base {
templates: PathBuf,
source: PathBuf,
}
impl Base {
pub fn read(&self, path: &str) -> Option<String> {
if let Ok(text) = std::fs::read_to_string(self.templates.join(path)) {
return Some(text);
}
let name = LEGACY_CONSTANTS.iter().find(|(p, _)| *p == path)?.1;
constant(&std::fs::read_to_string(&self.source).ok()?, name)
}
pub fn bytes(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(self.templates.join(path)).ok()
}
pub fn template_paths(&self) -> Vec<String> {
let mut found = Vec::new();
walk(&self.templates, &self.templates, &mut found);
found.sort();
found
}
}
const LEGACY_CONSTANTS: &[(&str, &str)] = &[
("src/main.rs", "MAIN_RS"),
("database/migrations/mod.rs", "MIGRATIONS_REGISTRY"),
("database/seeders/mod.rs", "SEEDERS_REGISTRY"),
("database/seeders/auth_kit_seeder.rs", "SEEDER"),
("config/auth.json", "CONFIG_AUTH"),
("config/rbac.json", "CONFIG_RBAC"),
("config/webauthn.json", "CONFIG_WEBAUTHN"),
];
fn constant(source: &str, name: &str) -> Option<String> {
let opening = format!("pub const {name}: &str = r#\"");
let start = source.find(&opening)? + opening.len();
let end = source[start..].find("\"#;")?;
Some(source[start..start + end].to_string())
}
fn walk(root: &Path, dir: &Path, found: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(root, &path, found);
} else if let Ok(relative) = path.strip_prefix(root)
&& let Some(text) = relative.to_str()
{
found.push(text.to_string());
}
}
}
pub fn base_kit(version: &str, kit: &str) -> Result<Base, String> {
let unpacked = cache_root()?.join(format!("rustlavel-cli-{version}"));
let templates = unpacked.join("templates").join(kit);
let source = unpacked.join("src").join("auth_kit.rs");
if templates.is_dir() {
return Ok(Base { templates, source });
}
let parent = unpacked
.parent()
.ok_or_else(|| "the cache directory has no parent".to_string())?
.to_path_buf();
std::fs::create_dir_all(&parent).map_err(|e| format!("cannot create {}: {e}", parent.display()))?;
let archive = parent.join(format!("rustlavel-cli-{version}.crate"));
if !archive.is_file() {
download(version, &archive)?;
}
unpack(&archive, &parent)?;
if !templates.is_dir() {
return Err(format!(
"rustlavel-cli {version} does not carry a `{kit}` kit. The project says it was \
created with one, so either the version in .rustlavel/manifest.json is wrong or \
the kit was renamed."
));
}
Ok(Base { templates, source })
}
fn download(version: &str, into: &Path) -> Result<(), String> {
let url = format!("https://static.crates.io/crates/rustlavel-cli/rustlavel-cli-{version}.crate");
let output = Command::new("curl")
.args(["--proto", "=https", "--tlsv1.2", "--location", "--fail", "--silent", "--show-error"])
.arg("--output")
.arg(into)
.arg(&url)
.output()
.map_err(|e| format!("cannot run curl: {e}. `rustlavel upgrade` needs it to fetch the version this project was created with."))?;
if !output.status.success() {
let _ = std::fs::remove_file(into);
let why = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"could not download rustlavel-cli {version} from crates.io: {}\n {url}",
why.trim()
));
}
Ok(())
}
fn unpack(archive: &Path, into: &Path) -> Result<(), String> {
let output = Command::new("tar")
.arg("-xzf")
.arg(archive)
.arg("-C")
.arg(into)
.output()
.map_err(|e| format!("cannot run tar: {e}. `rustlavel upgrade` needs it to unpack the version this project was created with."))?;
if !output.status.success() {
return Err(format!(
"could not unpack {}: {}",
archive.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
}
fn cache_root() -> Result<PathBuf, String> {
if let Ok(dir) = std::env::var("XDG_CACHE_HOME")
&& !dir.is_empty()
{
return Ok(PathBuf::from(dir).join("rustlavel").join("base"));
}
let home = std::env::var("HOME")
.map_err(|_| "neither HOME nor XDG_CACHE_HOME is set, so there is nowhere to cache the download".to_string())?;
Ok(PathBuf::from(home).join(".cache").join("rustlavel").join("base"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_legacy_constant_names_a_file_the_kit_still_writes() {
for (path, name) in LEGACY_CONSTANTS {
assert!(
crate::auth_kit::FILES.iter().any(|(p, _)| p == path),
"`{path}` is reconstructed from the old `{name}` constant, but the kit no \
longer writes it. Either the path changed and this entry has to follow, or \
the entry should go."
);
}
}
#[test]
fn a_constant_is_read_out_of_rust_source() {
let source = "//! A file.\npub const SEEDER: &str = r#\"one\ntwo\n\"#;\n\npub const OTHER: &str = r#\"x\"#;\n";
assert_eq!(constant(source, "SEEDER").as_deref(), Some("one\ntwo\n"));
assert_eq!(constant(source, "OTHER").as_deref(), Some("x"));
assert_eq!(constant(source, "ABSENT"), None);
}
#[test]
fn a_constant_in_an_unfamiliar_shape_is_not_guessed_at() {
assert_eq!(constant("pub const SEEDER: &str = \"plain\";", "SEEDER"), None);
assert_eq!(constant("pub const SEEDER: &str = r#\"unterminated", "SEEDER"), None);
}
#[test]
fn the_cache_lives_under_xdg_when_it_is_set() {
let before = std::env::var("XDG_CACHE_HOME").ok();
unsafe { std::env::set_var("XDG_CACHE_HOME", "/tmp/some-cache") };
assert_eq!(cache_root().unwrap(), PathBuf::from("/tmp/some-cache/rustlavel/base"));
match before {
Some(value) => unsafe { std::env::set_var("XDG_CACHE_HOME", value) },
None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
}
}
#[test]
fn an_empty_xdg_falls_back_to_home() {
let before = std::env::var("XDG_CACHE_HOME").ok();
unsafe { std::env::set_var("XDG_CACHE_HOME", "") };
let root = cache_root().unwrap();
assert!(root.starts_with(std::env::var("HOME").unwrap()), "{root:?}");
match before {
Some(value) => unsafe { std::env::set_var("XDG_CACHE_HOME", value) },
None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
}
}
}