use super::{
error::{ElenchusError, Result},
process::command_text,
};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
num::NonZeroUsize,
path::{Path, PathBuf},
};
pub(super) const ARTIFACT_DIR: &str = ".helen/elenchus";
pub(super) const ATTEMPT_DIR: &str = ".helen/elenchus/attempts";
pub(super) const REVIEW_CACHE_DIR: &str = ".helen/elenchus/reviews";
const DEFAULT_RETAINED_ATTEMPTS: usize = 12;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) struct RetainedAttemptCount {
count: NonZeroUsize,
}
impl RetainedAttemptCount {
pub(super) fn new(count: usize) -> Option<Self> {
NonZeroUsize::new(count).map(|count| Self { count })
}
pub(super) fn default_count() -> Self {
Self::new(DEFAULT_RETAINED_ATTEMPTS).expect("default retention count is non-zero")
}
const fn get(self) -> usize {
self.count.get()
}
}
pub(super) fn create_artifact_dirs() -> Result<()> {
for dir in [ATTEMPT_DIR, REVIEW_CACHE_DIR] {
fs::create_dir_all(dir).map_err(|error| {
ElenchusError::failure(format!(
"error: failed to create elenchus artifact directory {dir}: {error}"
))
})?;
}
Ok(())
}
#[derive(Clone, Debug)]
pub(super) struct AttemptPaths {
pub(super) stamp: String,
pub(super) diff: PathBuf,
pub(super) post_checks_diff: PathBuf,
pub(super) review_prompt: PathBuf,
pub(super) review_out: PathBuf,
pub(super) review_transcript: PathBuf,
pub(super) codex_exec_review_help: PathBuf,
pub(super) codex_exec_help: PathBuf,
pub(super) post_review_diff: PathBuf,
pub(super) summary: PathBuf,
}
impl AttemptPaths {
pub(super) fn new() -> Result<Self> {
let stamp = command_text("date", ["-u", "+%Y%m%dT%H%M%SZ"])?;
let root = PathBuf::from(ATTEMPT_DIR);
Ok(Self {
diff: root.join(format!("{stamp}.diff")),
post_checks_diff: root.join(format!("{stamp}-after-checks.diff")),
review_prompt: root.join(format!("{stamp}-review-prompt.md")),
review_out: root.join(format!("{stamp}-review.md")),
review_transcript: root.join(format!("{stamp}-review-transcript.txt")),
codex_exec_review_help: root.join(format!("{stamp}-codex-exec-review-help.txt")),
codex_exec_help: root.join(format!("{stamp}-codex-exec-help.txt")),
post_review_diff: root.join(format!("{stamp}-after-review.diff")),
summary: root.join(format!("{stamp}-summary.md")),
stamp,
})
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) struct ArchiveReport {
pub(super) attempts: usize,
pub(super) files: usize,
}
impl ArchiveReport {
pub(super) const fn archived_any(self) -> bool {
self.files > 0
}
}
pub(super) fn archive_old_attempt_artifacts(
log_dir: &Path,
current_stamp: &str,
retained_attempts: RetainedAttemptCount,
) -> Result<ArchiveReport> {
if !log_dir.is_dir() {
return Ok(ArchiveReport::default());
}
let current_stamp = AttemptStamp::new(current_stamp).ok_or_else(|| {
super::error::ElenchusError::failure(format!(
"error: invalid current elenchus stamp: {current_stamp}"
))
})?;
let groups = attempt_groups(log_dir)?;
if groups.is_empty() {
return Ok(ArchiveReport::default());
}
let mut retained = groups
.keys()
.rev()
.take(retained_attempts.get())
.cloned()
.collect::<BTreeSet<_>>();
let _inserted = retained.insert(current_stamp);
let mut report = ArchiveReport::default();
for (stamp, files) in groups {
if retained.contains(&stamp) {
continue;
}
let archive_dir = log_dir.join("archive").join(stamp.archive_month());
fs::create_dir_all(&archive_dir).map_err(|error| {
super::error::ElenchusError::failure(format!(
"error: failed to create elenchus archive {}: {error}",
archive_dir.display()
))
})?;
for file in files {
let Some(name) = file.file_name() else {
continue;
};
let target = unique_archive_path(&archive_dir.join(name));
fs::rename(&file, &target).map_err(|error| {
super::error::ElenchusError::failure(format!(
"error: failed to archive elenchus artifact {} to {}: {error}",
file.display(),
target.display()
))
})?;
report.files += 1;
}
report.attempts += 1;
}
Ok(report)
}
fn attempt_groups(log_dir: &Path) -> Result<BTreeMap<AttemptStamp, Vec<PathBuf>>> {
let mut groups = BTreeMap::<AttemptStamp, Vec<PathBuf>>::new();
let entries = fs::read_dir(log_dir).map_err(|error| {
super::error::ElenchusError::failure(format!(
"error: failed to read elenchus log directory {}: {error}",
log_dir.display()
))
})?;
for entry in entries {
let entry = entry.map_err(|error| {
super::error::ElenchusError::failure(format!(
"error: failed to read elenchus log entry: {error}"
))
})?;
let file_type = entry.file_type().map_err(|error| {
super::error::ElenchusError::failure(format!(
"error: failed to inspect elenchus log entry {}: {error}",
entry.path().display()
))
})?;
if !file_type.is_file() {
continue;
}
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Some(stamp) = AttemptStamp::from_artifact_name(name) else {
continue;
};
groups.entry(stamp).or_default().push(entry.path());
}
for files in groups.values_mut() {
files.sort();
}
Ok(groups)
}
fn unique_archive_path(path: &Path) -> PathBuf {
if !path.exists() {
return path.to_path_buf();
}
for index in 1.. {
let candidate = PathBuf::from(format!("{}.{}", path.display(), index));
if !candidate.exists() {
return candidate;
}
}
unreachable!("unbounded unique archive suffix search should always return")
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct AttemptStamp(String);
impl AttemptStamp {
fn new(stamp: &str) -> Option<Self> {
is_elenchus_stamp(stamp).then(|| Self(stamp.to_owned()))
}
fn from_artifact_name(name: &str) -> Option<Self> {
let stamp = name.get(..16)?;
let suffix = name.get(16..)?;
if !(suffix.starts_with('-') || suffix.starts_with('.')) {
return None;
}
Self::new(stamp)
}
fn archive_month(&self) -> &str {
&self.0[..6]
}
}
fn is_elenchus_stamp(stamp: &str) -> bool {
let bytes = stamp.as_bytes();
stamp.len() == 16
&& bytes[8] == b'T'
&& bytes[15] == b'Z'
&& bytes[..8].iter().all(u8::is_ascii_digit)
&& bytes[9..15].iter().all(u8::is_ascii_digit)
}
#[derive(Clone, Debug)]
pub(super) struct ReviewCachePaths {
pub(super) review: PathBuf,
pub(super) meta: PathBuf,
}
impl ReviewCachePaths {
pub(super) fn new(diff_fingerprint: &str) -> Self {
let root = PathBuf::from(REVIEW_CACHE_DIR).join(diff_fingerprint);
Self {
review: root.join("review.md"),
meta: root.join("meta"),
}
}
pub(super) fn create_dir(&self) -> Result<()> {
let Some(dir) = self.review.parent() else {
return Err(ElenchusError::failure(
"error: invalid elenchus review cache path",
));
};
fs::create_dir_all(dir).map_err(|error| {
ElenchusError::failure(format!(
"error: failed to create elenchus review cache directory {}: {error}",
dir.display()
))
})
}
}
#[cfg(test)]
mod tests {
use super::{RetainedAttemptCount, archive_old_attempt_artifacts};
use std::{
fs,
path::{Path, PathBuf},
process,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
static NEXT_TEMP_ROOT: AtomicU64 = AtomicU64::new(0);
#[test]
fn archive_moves_old_attempt_groups_and_keeps_non_attempt_files() {
let root = temp_root("archive-old-attempts");
let log_dir = root.join("attempts");
fs::create_dir_all(&log_dir).expect("create log dir");
write_file(&log_dir.join("20260501T000000Z.diff"));
write_file(&log_dir.join("20260501T000000Z-review.md"));
write_file(&log_dir.join("20260502T000000Z.diff"));
write_file(&log_dir.join("notes.txt"));
let report = archive_old_attempt_artifacts(
&log_dir,
"20260502T000000Z",
RetainedAttemptCount::new(1).expect("retention count"),
)
.expect("archive old artifacts");
assert_eq!(report.attempts, 1);
assert_eq!(report.files, 2);
assert!(!log_dir.join("20260501T000000Z.diff").exists());
assert!(log_dir.join("20260502T000000Z.diff").exists());
assert!(log_dir.join("notes.txt").exists());
assert!(
log_dir
.join("archive/202605/20260501T000000Z.diff")
.exists()
);
assert!(
log_dir
.join("archive/202605/20260501T000000Z-review.md")
.exists()
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn archive_never_moves_current_attempt_even_when_newer_stamps_exist() {
let root = temp_root("archive-current-attempt");
let log_dir = root.join("attempts");
fs::create_dir_all(&log_dir).expect("create log dir");
write_file(&log_dir.join("20260501T000000Z.diff"));
write_file(&log_dir.join("20260502T000000Z.diff"));
write_file(&log_dir.join("20260503T000000Z.diff"));
let report = archive_old_attempt_artifacts(
&log_dir,
"20260501T000000Z",
RetainedAttemptCount::new(1).expect("retention count"),
)
.expect("archive old artifacts");
assert_eq!(report.attempts, 1);
assert!(log_dir.join("20260501T000000Z.diff").exists());
assert!(log_dir.join("20260503T000000Z.diff").exists());
assert!(
log_dir
.join("archive/202605/20260502T000000Z.diff")
.exists()
);
fs::remove_dir_all(root).expect("remove temp root");
}
fn write_file(path: &Path) {
fs::write(path, "artifact\n").expect("write artifact");
}
fn temp_root(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos();
let sequence = NEXT_TEMP_ROOT.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"alma-elenchus-{label}-{}-{nanos}-{sequence}",
process::id()
))
}
}