use anyhow::{Context, Result};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
pub const DEFAULT_KEEP: usize = 10;
pub fn mecha_home() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("MECHA_HOME") {
if !dir.is_empty() {
return Ok(PathBuf::from(dir));
}
}
let home = dirs::home_dir().context("cannot determine home directory")?;
Ok(home.join(".mecha"))
}
pub fn root() -> Result<PathBuf> {
Ok(mecha_home()?.join("work"))
}
pub fn bundles_root() -> Result<PathBuf> {
Ok(mecha_home()?.join("bundles"))
}
pub fn valid_producer(name: &str) -> Result<()> {
anyhow::ensure!(!name.is_empty(), "a producer needs a name");
anyhow::ensure!(
name.len() <= 64,
"producer name `{name}` is too long (64 characters max)"
);
anyhow::ensure!(
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'),
"producer name `{name}` may only contain lowercase letters, digits, `-` and `_`"
);
Ok(())
}
pub fn producer_dir(producer: &str) -> Result<PathBuf> {
valid_producer(producer)?;
Ok(root()?.join(producer))
}
pub fn ensure(producer: &str) -> Result<PathBuf> {
let dir = producer_dir(producer)?;
crate::create_private_dir(&dir).with_context(|| format!("creating {}", dir.display()))?;
Ok(dir)
}
pub fn ensure_outside_mecha_home(workspace: &Path) -> Result<()> {
let home = mecha_home()?;
let home = home.canonicalize().unwrap_or(home);
let workspace_c = workspace.canonicalize();
let ws = workspace_c.as_deref().unwrap_or(workspace);
if home.starts_with(ws) {
anyhow::bail!(
"workspace {} contains the mecha home ({}), so the path jail would \
cover the mail tokens, every session transcript and the learning \
store.\n\
Run from a project directory instead, or name one explicitly with \
`--workspace <dir>`.",
ws.display(),
home.display()
);
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct Producer {
pub name: String,
pub path: PathBuf,
pub entries: Vec<Entry>,
pub bytes: u64,
}
#[derive(Debug, Clone)]
pub struct Entry {
pub path: PathBuf,
pub modified: std::time::SystemTime,
pub bytes: u64,
pub is_dir: bool,
}
pub fn list() -> Result<Vec<Producer>> {
let root = root()?;
if !root.is_dir() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for dir_entry in std::fs::read_dir(&root)? {
let path = dir_entry?.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let entries = entries_of(&path)?;
let bytes = entries.iter().map(|e| e.bytes).sum();
out.push(Producer {
name,
path,
entries,
bytes,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn entries_of(dir: &Path) -> Result<Vec<Entry>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let meta = entry.metadata()?;
let is_dir = meta.is_dir();
out.push(Entry {
modified: meta.modified().unwrap_or(std::time::UNIX_EPOCH),
bytes: if is_dir { dir_bytes(&path) } else { meta.len() },
path,
is_dir,
});
}
out.sort_by(|a, b| b.modified.cmp(&a.modified).then(a.path.cmp(&b.path)));
Ok(out)
}
fn dir_bytes(dir: &Path) -> u64 {
let mut total = 0;
let Ok(read) = std::fs::read_dir(dir) else {
return 0;
};
for entry in read.flatten() {
let Ok(meta) = entry.metadata() else { continue };
total += if meta.is_dir() {
dir_bytes(&entry.path())
} else {
meta.len()
};
}
total
}
pub fn protected_sources() -> Result<BTreeSet<PathBuf>> {
let mut out = BTreeSet::new();
let root = bundles_root()?;
if !root.is_dir() {
return Ok(out);
}
for bundle in std::fs::read_dir(&root)?.flatten() {
let Ok(versions) = std::fs::read_dir(bundle.path()) else {
continue;
};
for version in versions.flatten() {
let manifest = version.path().join("bundle.json");
let Ok(text) = std::fs::read_to_string(&manifest) else {
continue;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
tracing::warn!("unreadable bundle manifest {}", manifest.display());
continue;
};
let Some(sources) = value.get("sources").and_then(|s| s.as_array()) else {
continue;
};
for source in sources.iter().filter_map(|s| s.as_str()) {
let path = PathBuf::from(source);
out.insert(path.canonicalize().unwrap_or(path));
}
}
}
Ok(out)
}
#[derive(Debug, Default)]
pub struct CleanReport {
pub removed: Vec<Entry>,
pub protected: Vec<Entry>,
pub dry_run: bool,
}
impl CleanReport {
pub fn bytes_removed(&self) -> u64 {
self.removed.iter().map(|e| e.bytes).sum()
}
}
pub fn clean(keep: usize, only: Option<&str>, dry_run: bool) -> Result<CleanReport> {
let protected = protected_sources()?;
let mut report = CleanReport {
dry_run,
..Default::default()
};
for producer in list()? {
if only.is_some_and(|name| name != producer.name) {
continue;
}
for entry in producer.entries.into_iter().skip(keep) {
let canonical = entry
.path
.canonicalize()
.unwrap_or_else(|_| entry.path.clone());
if protected.contains(&canonical) {
report.protected.push(entry);
continue;
}
if !dry_run {
let removed = if entry.is_dir {
std::fs::remove_dir_all(&entry.path)
} else {
std::fs::remove_file(&entry.path)
};
removed.with_context(|| format!("removing {}", entry.path.display()))?;
}
report.removed.push(entry);
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct HomeGuard {
_lock: std::sync::MutexGuard<'static, ()>,
previous: Option<String>,
dir: PathBuf,
}
impl HomeGuard {
fn new() -> Self {
let lock = ENV.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("MECHA_HOME").ok();
let dir = std::env::temp_dir().join(format!("mecha-work-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("MECHA_HOME", &dir);
HomeGuard {
_lock: lock,
previous,
dir,
}
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match &self.previous {
Some(v) => std::env::set_var("MECHA_HOME", v),
None => std::env::remove_var("MECHA_HOME"),
}
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn write_aged(dir: &Path, name: &str, age: i64) {
use std::os::unix::ffi::OsStrExt;
let path = dir.join(name);
std::fs::write(&path, name).unwrap();
let c = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
let when = libc::timeval {
tv_sec: 1_700_000_000 - age,
tv_usec: 0,
};
let times = [when, when];
assert_eq!(unsafe { libc::utimes(c.as_ptr(), times.as_ptr()) }, 0);
}
#[test]
fn a_producer_directory_is_stable_and_private() {
let home = HomeGuard::new();
let first = ensure("morning").unwrap();
let second = ensure("morning").unwrap();
assert_eq!(first, second, "the same producer gets the same directory");
assert_eq!(first, home.dir.join("work").join("morning"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&first).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "owner-only, like every ~/.mecha leaf");
}
}
#[test]
fn a_producer_name_that_is_not_a_safe_directory_name_is_refused() {
let _home = HomeGuard::new();
for bad in ["", "../escape", "has space", "Upper", "a/b"] {
assert!(
producer_dir(bad).is_err(),
"`{bad}` should not be a producer name"
);
}
assert!(producer_dir("morning-brief_2").is_ok());
}
#[test]
fn a_workspace_containing_the_mecha_home_is_refused() {
let home = HomeGuard::new();
let parent = home.dir.parent().unwrap();
let err = ensure_outside_mecha_home(parent).unwrap_err().to_string();
assert!(
err.contains("contains the mecha home"),
"unexpected message: {err}"
);
assert!(
err.contains("--workspace"),
"the message names the fix: {err}"
);
assert!(ensure_outside_mecha_home(&home.dir).is_err());
}
#[test]
fn a_workspace_inside_the_mecha_home_is_allowed() {
let _home = HomeGuard::new();
let work = ensure("morning").unwrap();
ensure_outside_mecha_home(&work).unwrap();
}
#[test]
fn clean_keeps_the_newest_n_per_producer_and_reports_what_it_removed() {
let _home = HomeGuard::new();
let morning = ensure("morning").unwrap();
let evening = ensure("evening").unwrap();
for (i, name) in ["a.md", "b.md", "c.md", "d.md"].iter().enumerate() {
write_aged(&morning, name, i as i64 * 100);
write_aged(&evening, name, i as i64 * 100);
}
let preview = clean(2, None, true).unwrap();
assert_eq!(preview.removed.len(), 4, "two producers, two stale each");
assert!(
morning.join("d.md").exists(),
"a dry run removes nothing at all"
);
let report = clean(2, Some("morning"), false).unwrap();
let removed: Vec<_> = report
.removed
.iter()
.map(|e| e.path.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(removed, ["c.md", "d.md"], "the two oldest, newest kept");
assert!(morning.join("a.md").exists());
assert!(morning.join("b.md").exists());
assert!(
evening.join("d.md").exists(),
"`--producer` restricts the sweep"
);
assert!(morning.is_dir(), "the producer directory itself survives");
}
#[test]
fn clean_never_removes_a_published_bundles_source() {
let home = HomeGuard::new();
let work = ensure("morning").unwrap();
for (i, name) in ["new.md", "old.md"].iter().enumerate() {
write_aged(&work, name, i as i64 * 100);
}
let source = work.join("old.md").canonicalize().unwrap();
let version = home.dir.join("bundles").join("brief").join("3");
std::fs::create_dir_all(&version).unwrap();
std::fs::write(
version.join("bundle.json"),
serde_json::json!({ "sources": [source] }).to_string(),
)
.unwrap();
let report = clean(1, None, false).unwrap();
assert!(
report.removed.is_empty(),
"nothing was eligible but the source"
);
assert_eq!(report.protected.len(), 1);
assert!(work.join("old.md").exists());
}
#[test]
fn no_bundle_mirror_means_no_protected_sources() {
let _home = HomeGuard::new();
assert!(protected_sources().unwrap().is_empty());
}
}