1use anyhow::{Context, Result};
38use std::collections::BTreeSet;
39use std::path::{Path, PathBuf};
40
41pub const DEFAULT_KEEP: usize = 10;
48
49pub fn mecha_home() -> Result<PathBuf> {
54 if let Ok(dir) = std::env::var("MECHA_HOME") {
55 if !dir.is_empty() {
56 return Ok(PathBuf::from(dir));
57 }
58 }
59 let home = dirs::home_dir().context("cannot determine home directory")?;
60 Ok(home.join(".mecha"))
61}
62
63pub fn root() -> Result<PathBuf> {
65 Ok(mecha_home()?.join("work"))
66}
67
68pub fn bundles_root() -> Result<PathBuf> {
71 Ok(mecha_home()?.join("bundles"))
72}
73
74pub fn valid_producer(name: &str) -> Result<()> {
78 anyhow::ensure!(!name.is_empty(), "a producer needs a name");
79 anyhow::ensure!(
80 name.len() <= 64,
81 "producer name `{name}` is too long (64 characters max)"
82 );
83 anyhow::ensure!(
84 name.chars()
85 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'),
86 "producer name `{name}` may only contain lowercase letters, digits, `-` and `_`"
87 );
88 Ok(())
89}
90
91pub fn producer_dir(producer: &str) -> Result<PathBuf> {
93 valid_producer(producer)?;
94 Ok(root()?.join(producer))
95}
96
97pub fn ensure(producer: &str) -> Result<PathBuf> {
102 let dir = producer_dir(producer)?;
103 crate::create_private_dir(&dir).with_context(|| format!("creating {}", dir.display()))?;
104 Ok(dir)
105}
106
107pub fn ensure_outside_mecha_home(workspace: &Path) -> Result<()> {
120 let home = mecha_home()?;
121 let home = home.canonicalize().unwrap_or(home);
125 let workspace_c = workspace.canonicalize();
126 let ws = workspace_c.as_deref().unwrap_or(workspace);
127 if home.starts_with(ws) {
128 anyhow::bail!(
129 "workspace {} contains the mecha home ({}), so the path jail would \
130 cover the mail tokens, every session transcript and the learning \
131 store.\n\
132 Run from a project directory instead, or name one explicitly with \
133 `--workspace <dir>`.",
134 ws.display(),
135 home.display()
136 );
137 }
138 Ok(())
139}
140
141#[derive(Debug, Clone)]
143pub struct Producer {
144 pub name: String,
145 pub path: PathBuf,
146 pub entries: Vec<Entry>,
148 pub bytes: u64,
149}
150
151#[derive(Debug, Clone)]
155pub struct Entry {
156 pub path: PathBuf,
157 pub modified: std::time::SystemTime,
158 pub bytes: u64,
159 pub is_dir: bool,
160}
161
162pub fn list() -> Result<Vec<Producer>> {
164 let root = root()?;
165 if !root.is_dir() {
166 return Ok(Vec::new());
167 }
168 let mut out = Vec::new();
169 for dir_entry in std::fs::read_dir(&root)? {
170 let path = dir_entry?.path();
171 if !path.is_dir() {
172 continue;
173 }
174 let name = match path.file_name().and_then(|n| n.to_str()) {
175 Some(n) => n.to_string(),
176 None => continue,
177 };
178 let entries = entries_of(&path)?;
179 let bytes = entries.iter().map(|e| e.bytes).sum();
180 out.push(Producer {
181 name,
182 path,
183 entries,
184 bytes,
185 });
186 }
187 out.sort_by(|a, b| a.name.cmp(&b.name));
188 Ok(out)
189}
190
191fn entries_of(dir: &Path) -> Result<Vec<Entry>> {
193 let mut out = Vec::new();
194 for entry in std::fs::read_dir(dir)? {
195 let entry = entry?;
196 let path = entry.path();
197 let meta = entry.metadata()?;
198 let is_dir = meta.is_dir();
199 out.push(Entry {
200 modified: meta.modified().unwrap_or(std::time::UNIX_EPOCH),
201 bytes: if is_dir { dir_bytes(&path) } else { meta.len() },
202 path,
203 is_dir,
204 });
205 }
206 out.sort_by(|a, b| b.modified.cmp(&a.modified).then(a.path.cmp(&b.path)));
210 Ok(out)
211}
212
213fn dir_bytes(dir: &Path) -> u64 {
214 let mut total = 0;
215 let Ok(read) = std::fs::read_dir(dir) else {
216 return 0;
217 };
218 for entry in read.flatten() {
219 let Ok(meta) = entry.metadata() else { continue };
220 total += if meta.is_dir() {
221 dir_bytes(&entry.path())
222 } else {
223 meta.len()
224 };
225 }
226 total
227}
228
229pub fn protected_sources() -> Result<BTreeSet<PathBuf>> {
240 let mut out = BTreeSet::new();
241 let root = bundles_root()?;
242 if !root.is_dir() {
243 return Ok(out);
244 }
245 for bundle in std::fs::read_dir(&root)?.flatten() {
246 let Ok(versions) = std::fs::read_dir(bundle.path()) else {
247 continue;
248 };
249 for version in versions.flatten() {
250 let manifest = version.path().join("bundle.json");
251 let Ok(text) = std::fs::read_to_string(&manifest) else {
252 continue;
253 };
254 let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
255 tracing::warn!("unreadable bundle manifest {}", manifest.display());
256 continue;
257 };
258 let Some(sources) = value.get("sources").and_then(|s| s.as_array()) else {
259 continue;
260 };
261 for source in sources.iter().filter_map(|s| s.as_str()) {
262 let path = PathBuf::from(source);
263 out.insert(path.canonicalize().unwrap_or(path));
264 }
265 }
266 }
267 Ok(out)
268}
269
270#[derive(Debug, Default)]
272pub struct CleanReport {
273 pub removed: Vec<Entry>,
274 pub protected: Vec<Entry>,
278 pub dry_run: bool,
279}
280
281impl CleanReport {
282 pub fn bytes_removed(&self) -> u64 {
283 self.removed.iter().map(|e| e.bytes).sum()
284 }
285}
286
287pub fn clean(keep: usize, only: Option<&str>, dry_run: bool) -> Result<CleanReport> {
294 let protected = protected_sources()?;
295 let mut report = CleanReport {
296 dry_run,
297 ..Default::default()
298 };
299 for producer in list()? {
300 if only.is_some_and(|name| name != producer.name) {
301 continue;
302 }
303 for entry in producer.entries.into_iter().skip(keep) {
304 let canonical = entry
305 .path
306 .canonicalize()
307 .unwrap_or_else(|_| entry.path.clone());
308 if protected.contains(&canonical) {
309 report.protected.push(entry);
310 continue;
311 }
312 if !dry_run {
313 let removed = if entry.is_dir {
314 std::fs::remove_dir_all(&entry.path)
315 } else {
316 std::fs::remove_file(&entry.path)
317 };
318 removed.with_context(|| format!("removing {}", entry.path.display()))?;
319 }
320 report.removed.push(entry);
321 }
322 }
323 Ok(report)
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
334
335 struct HomeGuard {
336 _lock: std::sync::MutexGuard<'static, ()>,
337 previous: Option<String>,
338 dir: PathBuf,
339 }
340
341 impl HomeGuard {
342 fn new() -> Self {
343 let lock = ENV.lock().unwrap_or_else(|e| e.into_inner());
344 let previous = std::env::var("MECHA_HOME").ok();
345 let dir = std::env::temp_dir().join(format!("mecha-work-{}", uuid::Uuid::new_v4()));
346 std::fs::create_dir_all(&dir).unwrap();
347 std::env::set_var("MECHA_HOME", &dir);
348 HomeGuard {
349 _lock: lock,
350 previous,
351 dir,
352 }
353 }
354 }
355
356 impl Drop for HomeGuard {
357 fn drop(&mut self) {
358 match &self.previous {
359 Some(v) => std::env::set_var("MECHA_HOME", v),
360 None => std::env::remove_var("MECHA_HOME"),
361 }
362 let _ = std::fs::remove_dir_all(&self.dir);
363 }
364 }
365
366 fn write_aged(dir: &Path, name: &str, age: i64) {
371 use std::os::unix::ffi::OsStrExt;
372 let path = dir.join(name);
373 std::fs::write(&path, name).unwrap();
374 let c = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
375 let when = libc::timeval {
376 tv_sec: 1_700_000_000 - age,
377 tv_usec: 0,
378 };
379 let times = [when, when];
380 assert_eq!(unsafe { libc::utimes(c.as_ptr(), times.as_ptr()) }, 0);
383 }
384
385 #[test]
386 fn a_producer_directory_is_stable_and_private() {
387 let home = HomeGuard::new();
388 let first = ensure("morning").unwrap();
389 let second = ensure("morning").unwrap();
390 assert_eq!(first, second, "the same producer gets the same directory");
391 assert_eq!(first, home.dir.join("work").join("morning"));
392 #[cfg(unix)]
393 {
394 use std::os::unix::fs::PermissionsExt;
395 let mode = std::fs::metadata(&first).unwrap().permissions().mode();
396 assert_eq!(mode & 0o777, 0o700, "owner-only, like every ~/.mecha leaf");
397 }
398 }
399
400 #[test]
401 fn a_producer_name_that_is_not_a_safe_directory_name_is_refused() {
402 let _home = HomeGuard::new();
403 for bad in ["", "../escape", "has space", "Upper", "a/b"] {
404 assert!(
405 producer_dir(bad).is_err(),
406 "`{bad}` should not be a producer name"
407 );
408 }
409 assert!(producer_dir("morning-brief_2").is_ok());
410 }
411
412 #[test]
416 fn a_workspace_containing_the_mecha_home_is_refused() {
417 let home = HomeGuard::new();
418 let parent = home.dir.parent().unwrap();
419
420 let err = ensure_outside_mecha_home(parent).unwrap_err().to_string();
421 assert!(
422 err.contains("contains the mecha home"),
423 "unexpected message: {err}"
424 );
425 assert!(
426 err.contains("--workspace"),
427 "the message names the fix: {err}"
428 );
429
430 assert!(ensure_outside_mecha_home(&home.dir).is_err());
432 }
433
434 #[test]
437 fn a_workspace_inside_the_mecha_home_is_allowed() {
438 let _home = HomeGuard::new();
439 let work = ensure("morning").unwrap();
440 ensure_outside_mecha_home(&work).unwrap();
441 }
442
443 #[test]
444 fn clean_keeps_the_newest_n_per_producer_and_reports_what_it_removed() {
445 let _home = HomeGuard::new();
446 let morning = ensure("morning").unwrap();
447 let evening = ensure("evening").unwrap();
448 for (i, name) in ["a.md", "b.md", "c.md", "d.md"].iter().enumerate() {
449 write_aged(&morning, name, i as i64 * 100);
450 write_aged(&evening, name, i as i64 * 100);
451 }
452
453 let preview = clean(2, None, true).unwrap();
454 assert_eq!(preview.removed.len(), 4, "two producers, two stale each");
455 assert!(
456 morning.join("d.md").exists(),
457 "a dry run removes nothing at all"
458 );
459
460 let report = clean(2, Some("morning"), false).unwrap();
461 let removed: Vec<_> = report
462 .removed
463 .iter()
464 .map(|e| e.path.file_name().unwrap().to_str().unwrap())
465 .collect();
466 assert_eq!(removed, ["c.md", "d.md"], "the two oldest, newest kept");
467 assert!(morning.join("a.md").exists());
468 assert!(morning.join("b.md").exists());
469 assert!(
470 evening.join("d.md").exists(),
471 "`--producer` restricts the sweep"
472 );
473 assert!(morning.is_dir(), "the producer directory itself survives");
474 }
475
476 #[test]
479 fn clean_never_removes_a_published_bundles_source() {
480 let home = HomeGuard::new();
481 let work = ensure("morning").unwrap();
482 for (i, name) in ["new.md", "old.md"].iter().enumerate() {
483 write_aged(&work, name, i as i64 * 100);
484 }
485 let source = work.join("old.md").canonicalize().unwrap();
486
487 let version = home.dir.join("bundles").join("brief").join("3");
488 std::fs::create_dir_all(&version).unwrap();
489 std::fs::write(
490 version.join("bundle.json"),
491 serde_json::json!({ "sources": [source] }).to_string(),
492 )
493 .unwrap();
494
495 let report = clean(1, None, false).unwrap();
496 assert!(
497 report.removed.is_empty(),
498 "nothing was eligible but the source"
499 );
500 assert_eq!(report.protected.len(), 1);
501 assert!(work.join("old.md").exists());
502 }
503
504 #[test]
507 fn no_bundle_mirror_means_no_protected_sources() {
508 let _home = HomeGuard::new();
509 assert!(protected_sources().unwrap().is_empty());
510 }
511}