use std::fs;
use std::path::{Path, PathBuf};
mod ownership;
pub use ownership::is_scaffold_owned_path;
pub(crate) mod generation_record;
pub use generation_record::{record_inputs_hash, recorded_inputs_hash, stale_crate_names};
pub(super) const CACHE_DIR: &str = ".alef";
const PER_FILE_CACHE_NAME: &str = "sources_hash.cache";
pub fn read_alef_toml_bytes(config_path: &Path) -> Vec<u8> {
fs::read(config_path).unwrap_or_default()
}
pub fn sources_hash(sources: &[PathBuf]) -> anyhow::Result<String> {
let mut sorted: Vec<&PathBuf> = sources.iter().collect();
sorted.sort();
let memo = read_per_file_memo();
let mut current: Vec<(String, u64, u64)> = Vec::with_capacity(sorted.len());
let mut all_match = !memo.entries.is_empty() && memo.aggregate.is_some();
for source in &sorted {
let metadata =
fs::metadata(source).map_err(|e| anyhow::anyhow!("failed to stat source {}: {e}", source.display()))?;
let mtime_nanos = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let size = metadata.len();
let path_str = source.to_string_lossy().to_string();
if all_match {
match memo.entries.get(&path_str) {
Some((m, s)) if *m == mtime_nanos && *s == size => {}
_ => all_match = false,
}
}
current.push((path_str, mtime_nanos, size));
}
if all_match
&& current.len() == memo.entries.len()
&& let Some(agg) = memo.aggregate
{
return Ok(agg);
}
let aggregate = crate::core::hash::compute_sources_hash(sources)?;
let _ = write_per_file_memo(¤t, &aggregate);
Ok(aggregate)
}
struct PerFileMemo {
aggregate: Option<String>,
entries: std::collections::HashMap<String, (u64, u64)>,
}
fn read_per_file_memo() -> PerFileMemo {
let path = Path::new(CACHE_DIR).join(PER_FILE_CACHE_NAME);
let Ok(content) = fs::read_to_string(&path) else {
return PerFileMemo {
aggregate: None,
entries: std::collections::HashMap::new(),
};
};
let mut aggregate: Option<String> = None;
let mut entries = std::collections::HashMap::new();
for line in content.lines() {
if let Some(rest) = line.strip_prefix("aggregate\t") {
aggregate = Some(rest.to_string());
continue;
}
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() != 3 {
continue;
}
let mtime_nanos = parts[1].parse::<u64>().unwrap_or(0);
let size = parts[2].parse::<u64>().unwrap_or(0);
entries.insert(parts[0].to_string(), (mtime_nanos, size));
}
PerFileMemo { aggregate, entries }
}
fn write_per_file_memo(entries: &[(String, u64, u64)], aggregate: &str) -> anyhow::Result<()> {
let dir = Path::new(CACHE_DIR);
crate::core::cache_dir::ensure_cache_dir(dir)?;
let mut content = format!("aggregate\t{aggregate}\n");
for (path, mtime, size) in entries {
content.push_str(&format!("{path}\t{mtime}\t{size}\n"));
}
fs::write(dir.join(PER_FILE_CACHE_NAME), content)?;
Ok(())
}
pub fn validate_cache_crate_name(crate_name: &str) -> anyhow::Result<()> {
if crate_name.contains('\0') {
anyhow::bail!("invalid crate name for cache: NUL byte not allowed in {crate_name:?}");
}
if crate_name.contains('/') || crate_name.contains('\\') {
anyhow::bail!("invalid crate name for cache: path separator not allowed in {crate_name:?}");
}
if crate_name == ".." || crate_name == "." {
anyhow::bail!("invalid crate name for cache: {crate_name:?} is not a valid crate name");
}
Ok(())
}
fn ir_cache_dir(crate_name: &str) -> PathBuf {
Path::new(CACHE_DIR).join(crate_name)
}
pub fn is_ir_cached(crate_name: &str, cache_key: &CacheKey) -> bool {
let dir = ir_cache_dir(crate_name);
let hash_path = dir.join("ir.hash");
let ir_path = dir.join("ir.json");
if !ir_path.exists() {
return false;
}
match fs::read_to_string(&hash_path) {
Ok(cached) => cached.trim() == cache_key.as_str(),
Err(_) => false,
}
}
pub fn read_cached_ir(crate_name: &str) -> anyhow::Result<crate::core::ir::ApiSurface> {
let ir_path = ir_cache_dir(crate_name).join("ir.json");
let content = fs::read_to_string(&ir_path)?;
Ok(serde_json::from_str(&content)?)
}
pub fn write_ir_cache(crate_name: &str, api: &crate::core::ir::ApiSurface, cache_key: &CacheKey) -> anyhow::Result<()> {
let cache_dir = ir_cache_dir(crate_name);
crate::core::cache_dir::ensure_cache_dir_under(Path::new(CACHE_DIR), &cache_dir)?;
fs::write(cache_dir.join("ir.json"), serde_json::to_string_pretty(api)?)?;
fs::write(cache_dir.join("ir.hash"), cache_key.as_str())?;
Ok(())
}
pub use crate::cli::cache_identity::{CacheKey, compute_ir_key, compute_lang_hash, compute_stage_hash};
pub(crate) use crate::cli::cache_outputs::{outputs_exist, stamped_outputs_agree_with_disk};
fn hashes_dir(crate_name: &str) -> PathBuf {
ir_cache_dir(crate_name).join("hashes")
}
pub fn is_lang_cached(crate_name: &str, lang: &str, lang_hash: &CacheKey) -> bool {
let dir = hashes_dir(crate_name);
let hash_path = dir.join(format!("{lang}.hash"));
let manifest_path = dir.join(format!("{lang}.manifest"));
match fs::read_to_string(&hash_path) {
Ok(cached) => {
if cached.trim() != lang_hash.as_str() {
return false;
}
outputs_exist(&manifest_path) && stamped_outputs_agree_with_disk(&manifest_path)
}
Err(_) => false,
}
}
pub fn write_lang_hash(crate_name: &str, lang: &str, key: &CacheKey, output_paths: &[PathBuf]) -> anyhow::Result<()> {
let dir = hashes_dir(crate_name);
crate::core::cache_dir::ensure_cache_dir_under(Path::new(CACHE_DIR), &dir)?;
fs::write(dir.join(format!("{lang}.hash")), key.as_str())?;
write_manifest(&dir.join(format!("{lang}.manifest")), output_paths)?;
tracing::debug!(
crate_name,
lang,
paths = output_paths.len(),
"wrote language manifest via write_lang_hash"
);
Ok(())
}
pub fn write_lang_manifest(crate_name: &str, lang: &str, output_paths: &[PathBuf]) -> anyhow::Result<()> {
let dir = hashes_dir(crate_name);
crate::core::cache_dir::ensure_cache_dir_under(Path::new(CACHE_DIR), &dir)?;
write_manifest(&dir.join(format!("{lang}.manifest")), output_paths)?;
tracing::debug!(
crate_name,
lang,
paths = output_paths.len(),
"wrote language manifest via write_lang_manifest"
);
Ok(())
}
pub fn read_lang_manifest(crate_name: &str, lang: &str) -> Vec<PathBuf> {
let manifest_path = hashes_dir(crate_name).join(format!("{lang}.manifest"));
match fs::read_to_string(manifest_path) {
Ok(content) => content
.lines()
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect(),
Err(_) => Vec::new(),
}
}
pub fn write_scaffold_manifest(crate_name: &str, output_paths: &[PathBuf]) -> anyhow::Result<()> {
let dir = hashes_dir(crate_name);
crate::core::cache_dir::ensure_cache_dir_under(Path::new(CACHE_DIR), &dir)?;
write_manifest(&dir.join("scaffold-ownership.manifest"), output_paths)
}
pub fn read_scaffold_manifest(crate_name: &str) -> Vec<PathBuf> {
let manifest_path = hashes_dir(crate_name).join("scaffold-ownership.manifest");
match fs::read_to_string(manifest_path) {
Ok(content) => content
.lines()
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect(),
Err(_) => Vec::new(),
}
}
pub(super) const OWNERSHIP_MANIFEST: &str = ".alef-ownership.toml";
pub(super) const LEGACY_SCAFFOLD_OWNED_PATHS_MANIFEST: &str = "scaffold-owned-paths.manifest";
const OWNERSHIP_MANIFEST_HEADER: &str = "\
# alef ownership record -- COMMIT THIS FILE, do not add it to .gitignore.
#
# Lists the alef-generated paths whose format cannot carry an `alef:hash:`
# provenance marker (`package.json`, `*.jar`, ...). Every other format proves
# alef's ownership from the marker in the file itself and never appears here.
# Without this list committed, a fresh clone cannot tell an alef-generated
# `package.json` from a hand-written one and refuses to regenerate it.
#
# Ownership is a fact about history, not about content: a path lands here only
# because alef created the file, or because a human ran `alef adopt` on it.
# Nothing here is inferred by comparing bytes against generated output -- a
# hand-written file that happens to match must never be claimed. Do not hand-add
# entries; run `alef adopt <path>`, read the diff it prints, and let it write.
";
pub(super) fn scaffold_owned_path_key(base_dir: &Path, path: &Path) -> String {
path.strip_prefix(base_dir)
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}
#[derive(serde::Deserialize)]
struct OwnershipManifest {
#[serde(default)]
owned_paths: Vec<String>,
}
fn ownership_manifest_path(base_dir: &Path) -> PathBuf {
base_dir.join(OWNERSHIP_MANIFEST)
}
enum OwnedPathsRecord {
Absent,
Present(Vec<String>),
Unreadable(String),
}
fn read_owned_paths_record(base_dir: &Path) -> OwnedPathsRecord {
match fs::read_to_string(ownership_manifest_path(base_dir)) {
Ok(content) => match toml::from_str::<OwnershipManifest>(&content) {
Ok(manifest) => OwnedPathsRecord::Present(manifest.owned_paths),
Err(error) => OwnedPathsRecord::Unreadable(error.to_string()),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => OwnedPathsRecord::Absent,
Err(error) => OwnedPathsRecord::Unreadable(error.to_string()),
}
}
pub(super) fn read_committed_owned_paths(base_dir: &Path) -> Vec<String> {
match read_owned_paths_record(base_dir) {
OwnedPathsRecord::Present(paths) => paths,
OwnedPathsRecord::Absent => Vec::new(),
OwnedPathsRecord::Unreadable(reason) => {
tracing::warn!(
manifest = %OWNERSHIP_MANIFEST,
reason = %reason,
"the alef ownership record could not be read; treating every path in it as unowned, \
so writes to unmarkable files will be refused until it is repaired"
);
Vec::new()
}
}
}
pub(super) fn read_legacy_owned_paths(base_dir: &Path) -> Vec<String> {
let manifest_path = base_dir.join(CACHE_DIR).join(LEGACY_SCAFFOLD_OWNED_PATHS_MANIFEST);
fs::read_to_string(manifest_path)
.map(|content| {
content
.lines()
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
const RECORD_ARRAY_INDENT: &str = " ";
const RECORD_ARRAY_MAX_INLINE_WIDTH: usize = 120;
fn render_record_assignment(key: &str, values: &[String]) -> String {
let elements: Vec<String> = values
.iter()
.map(|value| toml_edit::Value::from(value.as_str()).to_string())
.collect();
let inline = format!("{key} = [{}]", elements.join(", "));
if inline.chars().count() <= RECORD_ARRAY_MAX_INLINE_WIDTH {
return inline;
}
let mut rendered = format!("{key} = [\n");
for element in &elements {
rendered.push_str(RECORD_ARRAY_INDENT);
rendered.push_str(element);
rendered.push_str(",\n");
}
rendered.push(']');
rendered
}
fn render_ownership_manifest(paths: &[String]) -> String {
format!(
"{OWNERSHIP_MANIFEST_HEADER}\n{}\n",
render_record_assignment("owned_paths", paths)
)
}
pub fn record_scaffold_owned_path(base_dir: &Path, path: &Path) -> anyhow::Result<()> {
record_scaffold_owned_paths(base_dir, std::slice::from_ref(&path))
}
pub fn record_scaffold_owned_paths(base_dir: &Path, paths: &[&Path]) -> anyhow::Result<()> {
static WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = WRITE_LOCK.lock().unwrap_or_else(|error| error.into_inner());
if paths.is_empty() {
return Ok(());
}
fs::create_dir_all(base_dir)?;
let record = read_owned_paths_record(base_dir);
let is_new_manifest = matches!(record, OwnedPathsRecord::Absent);
let mut recorded: std::collections::BTreeSet<String> = match record {
OwnedPathsRecord::Present(paths) => paths.into_iter().collect(),
OwnedPathsRecord::Absent => std::collections::BTreeSet::new(),
OwnedPathsRecord::Unreadable(reason) => anyhow::bail!(
"refusing to update the alef ownership record at {}: it exists but could not be read \
({reason}). Fix: repair or restore it (`git checkout -- {OWNERSHIP_MANIFEST}`), then re-run.",
ownership_manifest_path(base_dir).display()
),
};
let mut added = false;
for path in paths {
added |= recorded.insert(scaffold_owned_path_key(base_dir, path));
}
if !added {
return Ok(());
}
let ordered: Vec<String> = recorded.into_iter().collect();
fs::write(ownership_manifest_path(base_dir), render_ownership_manifest(&ordered))?;
if is_new_manifest {
tracing::info!(
manifest = %OWNERSHIP_MANIFEST,
"created the alef ownership record: commit it, or a fresh clone cannot regenerate \
the unmarkable files listed in it"
);
rearm_untracked_record_notice(base_dir);
}
note_untracked_required_records(base_dir);
Ok(())
}
const ALEF_RESERVED_NAME_PREFIX: &str = ".alef-";
const ALEF_DERIVED_OUTPUT_NAMES: &[&str] = &[crate::e2e::snippets::COVERAGE_MANIFEST];
pub fn is_alef_derived_output(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(ALEF_RESERVED_NAME_PREFIX) && ALEF_DERIVED_OUTPUT_NAMES.contains(&name))
}
const REQUIRED_COMMITTED_RECORDS: &[&str] = &[
OWNERSHIP_MANIFEST,
TOML_MERGE_PROVENANCE_MANIFEST,
generation_record::GENERATION_RECORD,
];
pub fn untracked_required_records(base_dir: &Path) -> Vec<&'static str> {
REQUIRED_COMMITTED_RECORDS
.iter()
.filter(|record| base_dir.join(record).is_file() && git_tracks(base_dir, record) == Some(false))
.copied()
.collect()
}
fn git_tracks(base_dir: &Path, relative: &str) -> Option<bool> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(base_dir)
.args(["ls-files", "--error-unmatch", "--", relative])
.output()
.ok()?;
if output.status.success() {
return Some(true);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not a git repository") || stderr.contains("this operation must be run in a work tree") {
return None;
}
Some(false)
}
static REPORTED_RECORD_TRACKING: std::sync::Mutex<Option<std::collections::BTreeSet<PathBuf>>> =
std::sync::Mutex::new(None);
pub(super) fn note_untracked_required_records(base_dir: &Path) {
{
let mut reported = REPORTED_RECORD_TRACKING
.lock()
.unwrap_or_else(|error| error.into_inner());
let seen = reported.get_or_insert_with(std::collections::BTreeSet::new);
if !seen.insert(base_dir.to_path_buf()) {
return;
}
}
for record in untracked_required_records(base_dir) {
tracing::warn!(
manifest = %record,
"alef depends on `{record}` but git does not track it. Fix: `git add {record}` and commit \
it, or a fresh clone and CI will refuse to regenerate everything it vouches for"
);
}
}
fn rearm_untracked_record_notice(base_dir: &Path) {
let mut reported = REPORTED_RECORD_TRACKING
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(seen) = reported.as_mut() {
seen.remove(base_dir);
}
}
const TOML_MERGE_PROVENANCE_MANIFEST: &str = ".alef-toml-merge-provenance.toml";
const TOML_MERGE_PROVENANCE_HEADER: &str = "\
# alef toml-merge provenance record -- COMMIT THIS FILE, do not add it to .gitignore.
#
# Records, per merge target and key path, the array values alef itself generated on
# the most recent `alef generate` run -- the baseline the poly.toml merge's prune step
# diffs against to tell \"alef proposed this and later stopped\" from \"the consumer
# wrote this by hand.\" Without this file committed, a fresh clone has no baseline, so
# a value alef stops generating can never be pruned there -- it accumulates forever.
#
# Nothing here is inferred by comparing bytes -- an entry is only ever a copy of
# alef's own past `generated` output for the given key path, captured before merging
# with consumer content. Do not hand-edit; it is rewritten on every `alef generate`.
";
#[derive(serde::Deserialize)]
struct TomlMergeProvenanceEntry {
relative_path: String,
key_path: String,
values: Vec<String>,
}
#[derive(Default, serde::Deserialize)]
struct TomlMergeProvenanceFile {
#[serde(default)]
entries: Vec<TomlMergeProvenanceEntry>,
}
type TomlMergeProvenance = std::collections::BTreeMap<String, std::collections::BTreeMap<String, Vec<String>>>;
fn toml_merge_provenance_path(base_dir: &Path) -> PathBuf {
base_dir.join(TOML_MERGE_PROVENANCE_MANIFEST)
}
fn read_toml_merge_provenance_file(base_dir: &Path) -> TomlMergeProvenance {
let Ok(content) = fs::read_to_string(toml_merge_provenance_path(base_dir)) else {
return TomlMergeProvenance::new();
};
let Ok(parsed) = toml::from_str::<TomlMergeProvenanceFile>(&content) else {
return TomlMergeProvenance::new();
};
let mut all = TomlMergeProvenance::new();
for entry in parsed.entries {
all.entry(entry.relative_path)
.or_default()
.insert(entry.key_path, entry.values);
}
all
}
pub fn read_toml_merge_provenance(
base_dir: &Path,
relative_path: &Path,
) -> std::collections::BTreeMap<String, Vec<String>> {
read_toml_merge_provenance_file(base_dir)
.remove(&relative_path.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn render_toml_merge_provenance(entries: &[TomlMergeProvenanceEntry]) -> String {
let mut body = String::new();
for entry in entries {
if !body.is_empty() {
body.push('\n');
}
body.push_str("[[entries]]\n");
for (key, value) in [("relative_path", &entry.relative_path), ("key_path", &entry.key_path)] {
body.push_str(key);
body.push_str(" = ");
body.push_str(&toml_edit::Value::from(value.as_str()).to_string());
body.push('\n');
}
body.push_str(&render_record_assignment("values", &entry.values));
body.push('\n');
}
body
}
pub fn write_toml_merge_provenance(
base_dir: &Path,
relative_path: &Path,
arrays_by_key_path: &std::collections::BTreeMap<String, Vec<String>>,
) -> anyhow::Result<()> {
let manifest_path = toml_merge_provenance_path(base_dir);
let is_new_manifest = !manifest_path.exists();
let mut all = read_toml_merge_provenance_file(base_dir);
all.insert(relative_path.to_string_lossy().into_owned(), arrays_by_key_path.clone());
let entries: Vec<TomlMergeProvenanceEntry> = all
.into_iter()
.flat_map(|(relative_path, by_key_path)| {
by_key_path
.into_iter()
.map(move |(key_path, values)| TomlMergeProvenanceEntry {
relative_path: relative_path.clone(),
key_path,
values,
})
})
.collect();
fs::create_dir_all(base_dir)?;
let body = render_toml_merge_provenance(&entries);
fs::write(&manifest_path, format!("{TOML_MERGE_PROVENANCE_HEADER}\n{body}"))?;
if is_new_manifest {
tracing::info!(
manifest = %TOML_MERGE_PROVENANCE_MANIFEST,
"created the alef toml-merge provenance record: commit it, or a fresh clone can never \
prune a value alef stops generating"
);
}
Ok(())
}
pub fn is_stage_cached(crate_name: &str, stage: &str, stage_hash: &CacheKey) -> bool {
let dir = hashes_dir(crate_name);
let hash_path = dir.join(format!("{stage}.hash"));
let manifest_path = dir.join(format!("{stage}.manifest"));
match fs::read_to_string(&hash_path) {
Ok(cached) => {
if cached.trim() != stage_hash.as_str() {
return false;
}
outputs_exist(&manifest_path) && stamped_outputs_agree_with_disk(&manifest_path)
}
Err(_) => false,
}
}
pub fn read_stage_paths(crate_name: &str, stage: &str) -> Vec<PathBuf> {
let dir = hashes_dir(crate_name);
let manifest_path = dir.join(format!("{stage}.manifest"));
match fs::read_to_string(&manifest_path) {
Ok(content) => content
.lines()
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect(),
Err(_) => Vec::new(),
}
}
pub fn write_stage_hash(
crate_name: &str,
stage: &str,
stage_hash: &str,
output_paths: &[PathBuf],
) -> anyhow::Result<()> {
let dir = hashes_dir(crate_name);
crate::core::cache_dir::ensure_cache_dir_under(Path::new(CACHE_DIR), &dir)?;
fs::write(dir.join(format!("{stage}.hash")), stage_hash)?;
write_manifest(&dir.join(format!("{stage}.manifest")), output_paths)?;
Ok(())
}
fn write_manifest(manifest_path: &Path, output_paths: &[PathBuf]) -> anyhow::Result<()> {
let mut paths: Vec<_> = output_paths.iter().map(|p| p.to_string_lossy()).collect();
paths.sort_unstable();
paths.dedup();
let mut content = paths.join("\n");
if !content.is_empty() {
content.push('\n');
}
fs::write(manifest_path, content)?;
Ok(())
}
pub fn hash_directory(dir: &Path) -> anyhow::Result<Vec<u8>> {
let mut hasher = blake3::Hasher::new();
if dir.exists() {
let mut entries: Vec<_> = walkdir(dir)?;
entries.sort();
for path in entries {
let content = fs::read(&path)?;
hasher.update(path.to_string_lossy().as_bytes());
hasher.update(&content);
}
}
Ok(hasher.finalize().as_bytes().to_vec())
}
fn walkdir(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
files.extend(walkdir(&path)?);
} else {
files.push(path);
}
}
Ok(files)
}
pub fn hash_content(content: &str) -> String {
blake3::hash(content.as_bytes()).to_hex().to_string()
}
pub fn write_generation_hashes(name: &str, hashes: &[(String, String)]) -> anyhow::Result<()> {
let dir = Path::new(CACHE_DIR).join("hashes");
crate::core::cache_dir::ensure_cache_dir_under(Path::new(CACHE_DIR), &dir)?;
let lines: Vec<String> = hashes.iter().map(|(p, h)| format!("{p}\t{h}")).collect();
fs::write(dir.join(format!("{name}.output_hashes")), lines.join("\n"))?;
Ok(())
}
pub fn read_generation_hashes(name: &str) -> anyhow::Result<std::collections::HashMap<String, String>> {
let path = Path::new(CACHE_DIR)
.join("hashes")
.join(format!("{name}.output_hashes"));
let content = fs::read_to_string(&path)?;
Ok(content
.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| l.split_once('\t'))
.map(|(p, h)| (p.to_string(), h.to_string()))
.collect())
}
pub fn clear_cache() -> anyhow::Result<()> {
let cache_dir = Path::new(CACHE_DIR);
if cache_dir.exists() {
fs::remove_dir_all(cache_dir)?;
}
Ok(())
}
pub fn show_status() {
let cache_dir = Path::new(CACHE_DIR);
if !cache_dir.exists() {
crate::bin_cli::output::line("No cache directory.");
return;
}
crate::bin_cli::output::line("Cache directory: .alef/");
let ir_path = cache_dir.join("ir.json");
if ir_path.exists() {
if let Ok(meta) = fs::metadata(&ir_path) {
crate::bin_cli::output::line(format!(" ir.json: {} bytes", meta.len()));
}
} else {
crate::bin_cli::output::line(" ir.json: not cached");
}
let hashes_dir = cache_dir.join("hashes");
if hashes_dir.exists() {
if let Ok(entries) = fs::read_dir(&hashes_dir) {
let langs: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| e.path().file_stem().and_then(|s| s.to_str().map(String::from)))
.collect();
if langs.is_empty() {
crate::bin_cli::output::line(" language hashes: none");
} else {
crate::bin_cli::output::line(format!(" language hashes: {}", langs.join(", ")));
}
}
} else {
crate::bin_cli::output::line(" language hashes: none");
}
}
#[cfg(test)]
#[path = "cache/tests.rs"]
mod tests;
#[cfg(test)]
#[path = "cache/committed_record_tests.rs"]
mod committed_record_tests;