use std::collections::HashMap;
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
const LOCK_FILE: &str = ".lock";
const SCRATCH_SUBDIR: &str = "scratchpad";
pub const RETENTION_DAYS: u64 = 7;
const MAX_COMPONENT_LEN: usize = 96;
const MAX_LIST_ENTRIES: usize = 100;
static HELD_LOCKS: OnceLock<Mutex<HashMap<PathBuf, File>>> = OnceLock::new();
fn scratch_root_in(temp: &Path) -> PathBuf {
#[cfg(unix)]
{
temp.join(format!("mermaid-{}", rustix::process::getuid().as_raw()))
}
#[cfg(not(unix))]
{
temp.join("mermaid")
}
}
fn scratch_root() -> PathBuf {
scratch_root_in(&std::env::temp_dir())
}
pub fn project_slug(project: &Path) -> String {
sanitize_component(&project.display().to_string(), "project")
}
fn sanitize_component(raw: &str, fallback: &str) -> String {
let mut out: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
out.truncate(MAX_COMPONENT_LEN);
if out.is_empty() {
fallback.to_string()
} else {
out
}
}
pub fn session_dir(root: &Path, project: &Path, session_id: &str) -> PathBuf {
root.join(project_slug(project))
.join(sanitize_component(session_id, "session"))
}
pub fn ensure(project: &Path, session_id: &str) -> io::Result<PathBuf> {
ensure_in(&scratch_root(), project, session_id)
}
fn ensure_in(root: &Path, project: &Path, session_id: &str) -> io::Result<PathBuf> {
let dir = session_dir(root, project, session_id);
let scratch = dir.join(SCRATCH_SUBDIR);
std::fs::create_dir_all(&scratch)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for level in [root, dir.parent().unwrap_or(root), &dir, &scratch] {
let _ = std::fs::set_permissions(level, std::fs::Permissions::from_mode(0o700));
}
}
let held = HELD_LOCKS.get_or_init(Mutex::default);
let mut held = held.lock().expect("scratchpad lock registry poisoned");
if !held.contains_key(&dir) {
let lock = File::create(dir.join(LOCK_FILE))?;
match lock.try_lock() {
Ok(()) => {
held.insert(dir.clone(), lock);
},
Err(std::fs::TryLockError::WouldBlock) => {},
Err(std::fs::TryLockError::Error(err)) => return Err(err),
}
}
Ok(scratch)
}
fn lock_is_held(dir: &Path) -> bool {
if let Ok(held) = HELD_LOCKS.get_or_init(Mutex::default).lock()
&& held.contains_key(dir)
{
return true;
}
let Ok(lock) = File::open(dir.join(LOCK_FILE)) else {
return false;
};
match lock.try_lock() {
Err(std::fs::TryLockError::WouldBlock) => true,
_ => false,
}
}
pub fn sweep_stale(retention_days: u64) -> io::Result<u64> {
sweep_stale_in(&scratch_root(), retention_days)
}
pub fn sweep_stale_in(root: &Path, retention_days: u64) -> io::Result<u64> {
if !root.exists() {
return Ok(0);
}
let cutoff = Duration::from_secs(retention_days * 24 * 60 * 60);
let mut removed = 0u64;
for project in std::fs::read_dir(root)? {
let project = project?;
if !project.file_type()?.is_dir() {
continue;
}
for session in std::fs::read_dir(project.path())? {
let session = session?;
let dir = session.path();
if !session.file_type()?.is_dir() {
continue;
}
if lock_is_held(&dir) {
continue;
}
let stale = session
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|mtime| mtime.elapsed().ok())
.is_some_and(|age| age >= cutoff);
if stale && std::fs::remove_dir_all(&dir).is_ok() {
removed += 1;
}
}
let _ = std::fs::remove_dir(project.path());
}
Ok(removed)
}
pub fn remove(project: &Path, session_id: &str) -> io::Result<()> {
remove_in(&scratch_root(), project, session_id)
}
fn remove_in(root: &Path, project: &Path, session_id: &str) -> io::Result<()> {
let dir = session_dir(root, project, session_id);
if !dir.exists() || lock_is_held(&dir) {
return Ok(());
}
std::fs::remove_dir_all(&dir)
}
pub fn list_text(dir: &Path) -> String {
let mut out = format!("Scratchpad: {}", dir.display());
let mut entries = Vec::new();
let mut truncated = false;
collect_entries(dir, "", &mut entries, &mut truncated);
if entries.is_empty() {
out.push_str("\n (empty)");
return out;
}
for line in &entries {
out.push_str("\n ");
out.push_str(line);
}
if truncated {
out.push_str("\n ... (listing capped)");
}
out
}
fn collect_entries(dir: &Path, rel: &str, entries: &mut Vec<String>, truncated: &mut bool) {
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
let mut children: Vec<_> = read.flatten().map(|e| e.path()).collect();
children.sort();
for path in children {
if entries.len() >= MAX_LIST_ENTRIES {
*truncated = true;
return;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let child_rel = if rel.is_empty() {
name.to_string()
} else {
format!("{rel}/{name}")
};
if path.is_dir() {
entries.push(format!("{child_rel}/"));
collect_entries(&path, &child_rel, entries, truncated);
} else {
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
entries.push(format!("{child_rel} ({})", human_size(size)));
}
}
}
fn human_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"mermaid_scratchpad_{}_{}",
name,
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn release_lock(session_dir: &Path) {
HELD_LOCKS
.get_or_init(Mutex::default)
.lock()
.expect("registry")
.remove(session_dir);
}
#[test]
fn scratch_root_is_per_user_under_system_temp() {
let root = scratch_root_in(Path::new("/tmp"));
#[cfg(unix)]
assert_eq!(
root,
Path::new("/tmp").join(format!("mermaid-{}", rustix::process::getuid().as_raw()))
);
#[cfg(not(unix))]
assert_eq!(root, Path::new("/tmp").join("mermaid"));
}
#[test]
fn project_slug_table() {
let cases: &[(&str, &str)] = &[
("/home/user/my proj", "-home-user-my-proj"),
("/", "-"),
("relative-dir", "relative-dir"),
("under_score", "under_score"),
("", "project"),
];
for (input, expected) in cases {
assert_eq!(
project_slug(Path::new(input)),
*expected,
"slug of {input:?}"
);
}
}
#[test]
fn project_slug_truncates_long_paths() {
let long = "/a".repeat(200);
let slug = project_slug(Path::new(&long));
assert_eq!(slug.len(), MAX_COMPONENT_LEN);
}
#[test]
fn session_dir_confines_a_hostile_session_id() {
let root = Path::new("/data/scratchpad");
let dir = session_dir(root, Path::new("/proj"), "../../evil");
assert!(dir.starts_with(root.join("-proj")));
assert!(
dir.components()
.all(|c| !matches!(c, std::path::Component::ParentDir))
);
}
#[test]
fn ensure_creates_the_advertised_child_and_holds_the_lock() {
let root = temp_root("ensure");
let scratch = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
assert!(scratch.is_dir());
assert!(
scratch.ends_with(SCRATCH_SUBDIR),
"advertised path is the scratchpad child: {}",
scratch.display()
);
let session = scratch.parent().expect("session dir");
assert!(
session.join(LOCK_FILE).is_file(),
"lock sits OUTSIDE the advertised dir"
);
assert!(lock_is_held(session), "ensure holds the liveness lock");
let again = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
assert_eq!(scratch, again);
release_lock(session);
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn ensure_tightens_perms_to_owner_only() {
use std::os::unix::fs::PermissionsExt;
let root = temp_root("perms");
let scratch = ensure_in(&root, Path::new("/proj"), "20260710_120000_000").expect("ensure");
let session = scratch.parent().expect("session dir").to_path_buf();
for level in [&root, &session, &scratch] {
let mode = std::fs::metadata(level).expect("meta").permissions().mode();
assert_eq!(mode & 0o777, 0o700, "mode of {}", level.display());
}
release_lock(&session);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sweep_lock_table() {
let root = temp_root("sweep");
let live = ensure_in(&root, Path::new("/proj"), "live").expect("ensure");
let live_session = live.parent().expect("session").to_path_buf();
let abandoned = ensure_in(&root, Path::new("/proj"), "abandoned").expect("ensure");
let abandoned_session = abandoned.parent().expect("session").to_path_buf();
release_lock(&abandoned_session); let lockless = ensure_in(&root, Path::new("/proj"), "lockless").expect("ensure");
let lockless_session = lockless.parent().expect("session").to_path_buf();
release_lock(&lockless_session);
std::fs::remove_file(lockless_session.join(LOCK_FILE)).expect("drop lock file");
let removed = sweep_stale_in(&root, 0).expect("sweep");
assert_eq!(removed, 2, "released + lockless reaped");
assert!(live.is_dir(), "a held lock protects the dir");
assert!(!abandoned_session.exists());
assert!(!lockless_session.exists());
let fresh = ensure_in(&root, Path::new("/proj"), "fresh").expect("ensure");
let fresh_session = fresh.parent().expect("session").to_path_buf();
release_lock(&fresh_session);
let removed = sweep_stale_in(&root, RETENTION_DAYS).expect("sweep");
assert_eq!(removed, 0);
assert!(fresh.is_dir(), "young dirs are kept even without a lock");
release_lock(&live_session);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sweep_of_a_missing_root_is_a_noop() {
let root = temp_root("missing");
assert_eq!(sweep_stale_in(&root, 0).expect("sweep"), 0);
}
#[test]
fn remove_cascades_unheld_dirs_but_spares_live_ones() {
let root = temp_root("remove");
let live = ensure_in(&root, Path::new("/proj"), "live").expect("ensure");
let live_session = live.parent().expect("session").to_path_buf();
remove_in(&root, Path::new("/proj"), "live").expect("remove");
assert!(live.is_dir(), "a held lock protects the dir from cascade");
let gone = ensure_in(&root, Path::new("/proj"), "gone").expect("ensure");
let gone_session = gone.parent().expect("session").to_path_buf();
release_lock(&gone_session);
remove_in(&root, Path::new("/proj"), "gone").expect("remove");
assert!(!gone_session.exists());
remove_in(&root, Path::new("/proj"), "never-existed").expect("remove");
release_lock(&live_session);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn list_text_is_sorted_and_bounded() {
let root = temp_root("list");
let dir = ensure_in(&root, Path::new("/proj"), "s").expect("ensure");
let session = dir.parent().expect("session").to_path_buf();
assert_eq!(
list_text(&dir),
format!("Scratchpad: {}\n (empty)", dir.display()),
"a fresh scratchpad reads as empty (the lock is outside it)"
);
std::fs::write(dir.join("b.txt"), b"hello").expect("write");
std::fs::create_dir(dir.join("a")).expect("mkdir");
std::fs::write(dir.join("a").join("nested.log"), vec![0u8; 2048]).expect("write");
let text = list_text(&dir);
assert!(text.is_ascii(), "listing must be pure ASCII");
assert_eq!(
text,
format!(
"Scratchpad: {}\n a/\n a/nested.log (2.0 KB)\n b.txt (5 B)",
dir.display()
)
);
for i in 0..(MAX_LIST_ENTRIES + 10) {
std::fs::write(dir.join(format!("f{i:04}.tmp")), b"x").expect("write");
}
let text = list_text(&dir);
assert_eq!(
text.lines().count(),
1 + MAX_LIST_ENTRIES + 1,
"header + cap + marker"
);
assert!(text.ends_with("... (listing capped)"));
release_lock(&session);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn human_size_units() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(1023), "1023 B");
assert_eq!(human_size(1536), "1.5 KB");
assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB");
}
}