use std::collections::BTreeMap;
use std::future::Future;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::process::Command;
use codas::types::cryptography::CryptoHasher;
use crate::proc::canonicalize::CanonicalizeProcessor;
use crate::proc::{MediaType, ProcessingError};
use crate::tool::KitConfig;
const CACHED_KITS_DIR: &str = ".aer/kits/cached";
const VENDORED_KITS_DIR: &str = ".aer/kits/vendored";
const LOCKFILE_PATH: &str = ".aer/kits/lockfile.toml";
const PRECANON_ROOT: &str = "http://KITPRECANON/";
const GITIGNORE_CONTENT: &str = "# Auto-generated by aer. Do not edit.\nkits/cached/\ntools/\n";
const GITATTRIBUTES_CONTENT: &str = "\
# Auto-generated by aer. Do not edit.\n\
kits/vendored/** -diff\n\
kits/vendored/** merge=ours\n";
#[derive(Debug, Serialize, Deserialize, Default)]
struct Lockfile {
#[serde(default)]
kits: BTreeMap<String, LockfileEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct LockfileEntry {
git: String,
#[serde(rename = "ref")]
git_ref: String,
commit: String,
#[serde(default)]
hash: String,
}
async fn read_lockfile(config_dir: &Path) -> io::Result<Option<Lockfile>> {
let path = config_dir.join(LOCKFILE_PATH);
match fs::read_to_string(&path).await {
Ok(content) => {
let lockfile: Lockfile = toml::from_str(&content).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid lockfile: {}", e),
)
})?;
Ok(Some(lockfile))
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
async fn write_lockfile(config_dir: &Path, lockfile: &Lockfile) -> io::Result<()> {
let path = config_dir.join(LOCKFILE_PATH);
let content = toml::to_string_pretty(lockfile)
.map_err(|e| io::Error::other(format!("failed to serialize lockfile: {}", e)))?;
fs::write(&path, content).await
}
async fn ensure_aer_directory(config_dir: &Path) -> io::Result<()> {
let aer_dir = config_dir.join(".aer");
fs::create_dir_all(aer_dir.join("kits/cached")).await?;
fs::create_dir_all(aer_dir.join("kits/vendored")).await?;
write_if_changed(&aer_dir.join(".gitignore"), GITIGNORE_CONTENT).await?;
write_if_changed(&aer_dir.join(".gitattributes"), GITATTRIBUTES_CONTENT).await?;
Ok(())
}
async fn write_if_changed(path: &Path, content: &str) -> io::Result<()> {
match fs::read_to_string(path).await {
Ok(existing) if existing == content => Ok(()),
_ => fs::write(path, content).await,
}
}
async fn run_cmd(mut cmd: Command, error_context: &str) -> io::Result<String> {
let output = cmd.output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::other(format!(
"{}: {}",
error_context,
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn check_lockfile_drift(
kits: &BTreeMap<String, KitConfig>,
lockfile: Option<&Lockfile>,
config_dir: &Path,
) -> io::Result<()> {
let vendored_dir = config_dir.join(VENDORED_KITS_DIR);
for (name, kit) in kits {
if let Some(lf) = lockfile {
if let Some(entry) = lf.kits.get(name) {
if entry.git != kit.git_url || entry.git_ref != kit.git_ref {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Kit `{}` has changed since last refresh (config: git={} ref={}, lockfile: git={} ref={}). \
Run `aer kits refresh` to update.",
name, kit.git_url, kit.git_ref, entry.git, entry.git_ref
),
));
}
if kit.vendored && !entry.hash.is_empty() {
let vendored_kit_dir = vendored_dir.join(name);
if fs::try_exists(&vendored_kit_dir).await? {
let current_hash = hash_directory(&vendored_kit_dir).await?;
if current_hash != entry.hash {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Kit `{}` vendored files have been modified. \
Run `aer kits refresh --update` to restore from git.",
name
),
));
}
}
}
} else if kit.vendored {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Kit `{}` is vendored but missing from lockfile. Run `aer kits refresh` to update.",
name
),
));
}
} else if kit.vendored {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Kit `{}` is vendored but no lockfile exists. Run `aer kits refresh` to update.",
name
),
));
}
}
Ok(())
}
fn hash_directory(
dir: &Path,
) -> std::pin::Pin<Box<dyn Future<Output = io::Result<String>> + Send + '_>> {
Box::pin(async move {
let mut hasher = CryptoHasher::default();
let mut paths = Vec::new();
collect_file_paths(dir, dir, &mut paths).await?;
paths.sort();
for path in &paths {
hasher.write(path.as_bytes());
let full = dir.join(path);
let content = fs::read(&full).await?;
hasher.write(&content);
}
Ok(hasher.finalize().to_hex().to_string())
})
}
fn collect_file_paths<'a>(
base: &'a Path,
dir: &'a Path,
out: &'a mut Vec<String>,
) -> std::pin::Pin<Box<dyn Future<Output = io::Result<()>> + Send + 'a>> {
Box::pin(async move {
let mut entries = fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
let path = entry.path();
if file_type.is_dir() {
collect_file_paths(base, &path, out).await?;
} else if let Ok(rel) = path.strip_prefix(base) {
out.push(rel.to_string_lossy().to_string());
}
}
Ok(())
})
}
async fn git_ls_remote(url: &str, git_ref: &str) -> io::Result<Option<String>> {
let mut cmd = Command::new("git");
cmd.args(["ls-remote", url, git_ref]);
let output = run_cmd(cmd, "git ls-remote failed").await?;
Ok(output.split_whitespace().next().map(|s| s.to_string()))
}
async fn git_fetch(url: &str, git_ref: &str, dest: &Path) -> io::Result<()> {
let mut cmd = Command::new("git");
cmd.arg("init").arg(dest);
run_cmd(cmd, "git init failed").await?;
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(dest)
.args(["fetch", "--depth", "1", url, git_ref]);
run_cmd(cmd, "git fetch failed").await?;
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dest).args(["checkout", "FETCH_HEAD"]);
run_cmd(cmd, "git checkout failed").await?;
Ok(())
}
async fn git_commit_hash(repo: &Path) -> io::Result<String> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(repo).args(["rev-parse", "HEAD"]);
run_cmd(cmd, "git rev-parse failed").await
}
async fn git_current_ref(repo: &Path) -> io::Result<Option<String>> {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(repo)
.args(["symbolic-ref", "--short", "HEAD"]);
if let Ok(branch) = run_cmd(cmd, "git symbolic-ref").await {
return Ok(Some(branch));
}
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(repo)
.args(["describe", "--tags", "--exact-match", "HEAD"]);
if let Ok(tag) = run_cmd(cmd, "git describe").await {
return Ok(Some(tag));
}
let mut cmd = Command::new("git");
cmd.arg("-C").arg(repo).args(["rev-parse", "HEAD"]);
if let Ok(hash) = run_cmd(cmd, "git rev-parse").await {
return Ok(Some(hash));
}
Ok(None)
}
async fn is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path)
.await
.map(|m| m.is_symlink())
.unwrap_or(false)
}
#[derive(Debug, Clone)]
pub struct ResolvedKit {
pub name: String,
pub local_path: PathBuf,
pub dest: String,
}
pub async fn resolve_kits(
kits: &BTreeMap<String, KitConfig>,
config_dir: &Path,
) -> io::Result<Vec<ResolvedKit>> {
if kits.is_empty() {
return Ok(Vec::new());
}
ensure_aer_directory(config_dir).await?;
let cached_dir = config_dir.join(CACHED_KITS_DIR);
let vendored_dir = config_dir.join(VENDORED_KITS_DIR);
let lockfile = read_lockfile(config_dir).await?;
check_lockfile_drift(kits, lockfile.as_ref(), config_dir).await?;
let mut resolved = Vec::with_capacity(kits.len());
for (name, kit) in kits {
let dest = kit
.dest
.clone()
.unwrap_or_else(|| format!("/vendor/kits/{}", name));
if let Some(local_path) = &kit.path {
let local = config_dir.join(local_path);
if fs::try_exists(&local).await? {
let kit_assets_dir = local.join("kit");
if !fs::try_exists(&kit_assets_dir).await? {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Kit `{}` has no `kit/` directory", name),
));
}
tracing::info!("Kit `{}`: using local path {}", name, local.display());
resolved.push(ResolvedKit {
name: name.clone(),
local_path: kit_assets_dir,
dest,
});
continue;
}
tracing::debug!(
"Kit `{}`: local path {} not found, falling back",
name,
local.display()
);
}
if kit.vendored {
let vendored_kit_dir = vendored_dir.join(name);
if fs::try_exists(&vendored_kit_dir).await? {
tracing::info!("Kit `{}`: using vendored copy", name);
resolved.push(ResolvedKit {
name: name.clone(),
local_path: vendored_kit_dir,
dest,
});
continue;
}
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Kit `{}` is vendored but not found at {}. Run `aer kits refresh` to populate.",
name,
vendored_kit_dir.display()
),
));
}
let kit_dir = cached_dir.join(name);
if fs::try_exists(&kit_dir).await? && !is_symlink(&kit_dir).await {
if let Some(current) = git_current_ref(&kit_dir).await?
&& (current == kit.git_ref
|| current.starts_with(&kit.git_ref)
|| kit.git_ref.starts_with(¤t))
{
tracing::info!("Kit `{}`: cached at ref {}", name, kit.git_ref);
let kit_assets_dir = kit_dir.join("kit");
if !fs::try_exists(&kit_assets_dir).await? {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Kit `{}` has no `kit/` directory", name),
));
}
resolved.push(ResolvedKit {
name: name.clone(),
local_path: kit_assets_dir,
dest,
});
continue;
}
tracing::info!("Kit `{}`: ref changed, re-cloning", name);
fs::remove_dir_all(&kit_dir).await?;
} else if is_symlink(&kit_dir).await {
fs::remove_file(&kit_dir).await?;
}
git_fetch(&kit.git_url, &kit.git_ref, &kit_dir).await?;
tracing::info!("Kit `{}`: resolved at ref {}", name, kit.git_ref);
let kit_assets_dir = kit_dir.join("kit");
if !fs::try_exists(&kit_assets_dir).await? {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Kit `{}` has no `kit/` directory", name),
));
}
resolved.push(ResolvedKit {
name: name.clone(),
local_path: kit_assets_dir,
dest,
});
}
Ok(resolved)
}
pub async fn refresh_kits(
kits: &BTreeMap<String, KitConfig>,
config_dir: &Path,
target_kit: Option<&str>,
force_update: bool,
) -> io::Result<()> {
ensure_aer_directory(config_dir).await?;
let mut lockfile = read_lockfile(config_dir).await?.unwrap_or_default();
let vendored_dir = config_dir.join(VENDORED_KITS_DIR);
let mut any_refreshed = false;
for (name, kit) in kits {
if !kit.vendored {
continue;
}
if let Some(target) = target_kit
&& name != target
{
continue;
}
if !force_update
&& let Some(entry) = lockfile.kits.get(name)
&& entry.git == kit.git_url
&& entry.git_ref == kit.git_ref
{
let vendored_kit_dir = vendored_dir.join(name);
if fs::try_exists(&vendored_kit_dir).await? {
let remote_commit = git_ls_remote(&kit.git_url, &kit.git_ref).await?;
if remote_commit.as_deref() == Some(entry.commit.as_str()) {
tracing::info!("Kit `{}`: already up to date", name);
continue;
}
tracing::info!(
"Kit `{}`: remote ref updated ({} -> {})",
name,
&entry.commit[..12.min(entry.commit.len())],
remote_commit
.as_deref()
.map(|c| &c[..12.min(c.len())])
.unwrap_or("unknown"),
);
}
}
tracing::info!("Kit `{}`: fetching from {}", name, kit.git_url);
let temp_dir = config_dir.join(format!(".aer/kits/.tmp-{}", name));
if fs::try_exists(&temp_dir).await? {
fs::remove_dir_all(&temp_dir).await?;
}
fs::create_dir_all(&temp_dir).await?;
let fetch_result = git_fetch(&kit.git_url, &kit.git_ref, &temp_dir).await;
if let Err(e) = fetch_result {
let _ = fs::remove_dir_all(&temp_dir).await;
return Err(e);
}
let commit = git_commit_hash(&temp_dir).await?;
let temp_kit_dir = temp_dir.join("kit");
if !fs::try_exists(&temp_kit_dir).await? {
let _ = fs::remove_dir_all(&temp_dir).await;
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Kit `{}` has no `kit/` directory in its repository", name),
));
}
let vendored_kit_dir = vendored_dir.join(name);
if fs::try_exists(&vendored_kit_dir).await? {
fs::remove_dir_all(&vendored_kit_dir).await?;
}
fs::rename(&temp_kit_dir, &vendored_kit_dir).await?;
let _ = fs::remove_dir_all(&temp_dir).await;
let hash = hash_directory(&vendored_kit_dir).await?;
lockfile.kits.insert(
name.clone(),
LockfileEntry {
git: kit.git_url.clone(),
git_ref: kit.git_ref.clone(),
commit,
hash,
},
);
tracing::info!("Kit `{}`: vendored at ref {}", name, kit.git_ref);
any_refreshed = true;
}
if let Some(target) = target_kit {
if !kits.contains_key(target) {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Kit `{}` not found in configuration", target),
));
}
if !kits[target].vendored {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Kit `{}` is not configured as vendored", target),
));
}
}
write_lockfile(config_dir, &lockfile).await?;
if any_refreshed {
tracing::info!("Lockfile updated");
}
Ok(())
}
pub fn pre_canonicalize_kit_assets(
assets: &[(String, Vec<u8>)],
dest: &str,
) -> Vec<(String, Vec<u8>)> {
let processor = match CanonicalizeProcessor::new(PRECANON_ROOT) {
Some(p) => p,
None => return assets.to_vec(),
};
let dest_trimmed = dest.trim_start_matches('/');
assets
.iter()
.map(|(relative_path, content)| {
let prefixed_path = if dest_trimmed.is_empty() {
relative_path.clone()
} else {
format!("{}/{}", dest_trimmed, relative_path)
};
let media_type = relative_path
.rsplit('.')
.next()
.map(MediaType::from_extension)
.unwrap_or(MediaType::Unknown {
extension: [String::new().into()],
});
let new_content = match media_type {
MediaType::Css | MediaType::Scss => {
if let Ok(text) = std::str::from_utf8(content) {
let processed = processor.process_css(text, &prefixed_path);
let result = processed.replace(PRECANON_ROOT, "/");
result.into_bytes()
} else {
content.clone()
}
}
MediaType::Html => {
if let Ok(text) = std::str::from_utf8(content) {
match processor.process_html(text, &prefixed_path.into()) {
Ok(processed) => {
let result = processed.replace(PRECANON_ROOT, "/");
result.into_bytes()
}
Err(ProcessingError::Malformed { message }) => {
tracing::warn!(
"Pre-canonicalization failed for {}: {}",
relative_path,
message
);
content.clone()
}
Err(_) => content.clone(),
}
} else {
content.clone()
}
}
_ => content.clone(),
};
(relative_path.clone(), new_content)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pre_canonicalizes_css_urls() {
let css = r#"@font-face { src: url("../fonts/font.ttf"); }"#;
let assets = vec![("styles/main.css".to_string(), css.as_bytes().to_vec())];
let result = pre_canonicalize_kit_assets(&assets, "/vendor/kits/base");
assert_eq!(result.len(), 1);
let output = std::str::from_utf8(&result[0].1).unwrap();
assert!(
output.contains("/vendor/kits/base/fonts/font.ttf"),
"Expected canonicalized URL, got: {}",
output
);
}
#[test]
fn pre_canonicalizes_css_with_root_dest() {
let css = r#"body { background: url("images/bg.png"); }"#;
let assets = vec![("styles/main.css".to_string(), css.as_bytes().to_vec())];
let result = pre_canonicalize_kit_assets(&assets, "/");
let output = std::str::from_utf8(&result[0].1).unwrap();
assert!(
output.contains("/styles/images/bg.png"),
"Expected canonicalized URL, got: {}",
output
);
}
#[test]
fn pre_canonicalizes_html_hrefs() {
let html = r#"<a href="../page.html">Link</a>"#;
let assets = vec![("pages/index.html".to_string(), html.as_bytes().to_vec())];
let result = pre_canonicalize_kit_assets(&assets, "/vendor/kits/base");
let output = std::str::from_utf8(&result[0].1).unwrap();
assert!(
output.contains("/vendor/kits/base/page.html"),
"Expected canonicalized URL, got: {}",
output
);
}
#[test]
fn preserves_binary_assets() {
let binary = vec![0u8, 1, 2, 3, 255];
let assets = vec![("image.png".to_string(), binary.clone())];
let result = pre_canonicalize_kit_assets(&assets, "/vendor/kits/base");
assert_eq!(result[0].1, binary);
}
#[test]
fn preserves_absolute_urls_in_css() {
let css = r#"@font-face { src: url("/absolute/font.ttf"); }"#;
let assets = vec![("styles/main.css".to_string(), css.as_bytes().to_vec())];
let result = pre_canonicalize_kit_assets(&assets, "/vendor/kits/base");
let output = std::str::from_utf8(&result[0].1).unwrap();
assert!(
output.contains("/absolute/font.ttf"),
"Expected preserved absolute URL, got: {}",
output
);
}
#[test]
fn lockfile_round_trip() {
let mut lockfile = Lockfile::default();
lockfile.kits.insert(
"brand".to_string(),
LockfileEntry {
git: "git@github.com:example/brand.git".to_string(),
git_ref: "main".to_string(),
commit: "abc123def456".to_string(),
hash: "deadbeef".to_string(),
},
);
let serialized = toml::to_string_pretty(&lockfile).unwrap();
let deserialized: Lockfile = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.kits.len(), 1);
let entry = &deserialized.kits["brand"];
assert_eq!(entry.git, "git@github.com:example/brand.git");
assert_eq!(entry.git_ref, "main");
assert_eq!(entry.commit, "abc123def456");
assert_eq!(entry.hash, "deadbeef");
}
fn kit_config(git_ref: &str, vendored: bool) -> KitConfig {
KitConfig {
git_url: "git@github.com:example/brand.git".to_string(),
git_ref: git_ref.to_string(),
dest: None,
path: None,
vendored,
}
}
fn lockfile_entry(git_ref: &str, hash: &str) -> LockfileEntry {
LockfileEntry {
git: "git@github.com:example/brand.git".to_string(),
git_ref: git_ref.to_string(),
commit: "abc123".to_string(),
hash: hash.to_string(),
}
}
#[tokio::test]
async fn drift_detection_catches_ref_change() {
let temp = tempfile::tempdir().unwrap();
let mut kits = BTreeMap::new();
kits.insert("brand".to_string(), kit_config("v2.0", true));
let mut lockfile = Lockfile::default();
lockfile
.kits
.insert("brand".to_string(), lockfile_entry("v1.0", "deadbeef"));
let result = check_lockfile_drift(&kits, Some(&lockfile), temp.path()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("has changed since last refresh"),
"got: {}",
err
);
}
#[tokio::test]
async fn drift_detection_catches_missing_vendored() {
let temp = tempfile::tempdir().unwrap();
let mut kits = BTreeMap::new();
kits.insert("brand".to_string(), kit_config("main", true));
let lockfile = Lockfile::default();
let result = check_lockfile_drift(&kits, Some(&lockfile), temp.path()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("missing from lockfile"), "got: {}", err);
}
#[tokio::test]
async fn drift_detection_ok_for_matching_lockfile() {
let temp = tempfile::tempdir().unwrap();
let vendored_dir = temp.path().join(VENDORED_KITS_DIR).join("brand");
fs::create_dir_all(&vendored_dir).await.unwrap();
fs::write(vendored_dir.join("test.txt"), "hello")
.await
.unwrap();
let expected_hash = hash_directory(&vendored_dir).await.unwrap();
let mut kits = BTreeMap::new();
kits.insert("brand".to_string(), kit_config("main", true));
let mut lockfile = Lockfile::default();
lockfile
.kits
.insert("brand".to_string(), lockfile_entry("main", &expected_hash));
let result = check_lockfile_drift(&kits, Some(&lockfile), temp.path()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn drift_detection_catches_file_modification() {
let temp = tempfile::tempdir().unwrap();
let vendored_dir = temp.path().join(VENDORED_KITS_DIR).join("brand");
fs::create_dir_all(&vendored_dir).await.unwrap();
fs::write(vendored_dir.join("test.txt"), "hello")
.await
.unwrap();
let original_hash = hash_directory(&vendored_dir).await.unwrap();
fs::write(vendored_dir.join("test.txt"), "modified")
.await
.unwrap();
let mut kits = BTreeMap::new();
kits.insert("brand".to_string(), kit_config("main", true));
let mut lockfile = Lockfile::default();
lockfile
.kits
.insert("brand".to_string(), lockfile_entry("main", &original_hash));
let result = check_lockfile_drift(&kits, Some(&lockfile), temp.path()).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("vendored files have been modified"),
"got: {}",
err
);
}
#[tokio::test]
async fn drift_detection_ok_for_non_vendored_without_lockfile() {
let temp = tempfile::tempdir().unwrap();
let mut kits = BTreeMap::new();
kits.insert("brand".to_string(), kit_config("main", false));
let result = check_lockfile_drift(&kits, None, temp.path()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn ensure_aer_directory_is_idempotent() {
let temp = tempfile::tempdir().unwrap();
ensure_aer_directory(temp.path()).await.unwrap();
assert!(
fs::try_exists(temp.path().join(".aer/kits/cached"))
.await
.unwrap()
);
assert!(
fs::try_exists(temp.path().join(".aer/kits/vendored"))
.await
.unwrap()
);
let gitignore = fs::read_to_string(temp.path().join(".aer/.gitignore"))
.await
.unwrap();
assert_eq!(gitignore, GITIGNORE_CONTENT);
let gitattributes = fs::read_to_string(temp.path().join(".aer/.gitattributes"))
.await
.unwrap();
assert_eq!(gitattributes, GITATTRIBUTES_CONTENT);
ensure_aer_directory(temp.path()).await.unwrap();
}
}