use crate::capped::{self, Entry};
pub use crate::capped::{human_bytes, EntryKind, RemovedEntry, SweepReport, Usage};
use crate::prelude::*;
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
pub mod op_log;
pub const DEFAULT_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
pub const MIN_MAX_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Stream {
Ops,
Test,
Daemon,
}
impl Stream {
pub const ALL: [Stream; 3] = [Stream::Ops, Stream::Test, Stream::Daemon];
pub fn dir_name(self) -> &'static str {
match self {
Stream::Ops => "ops",
Stream::Test => "test",
Stream::Daemon => "daemon",
}
}
pub fn entry_kind(self) -> EntryKind {
match self {
Stream::Ops => EntryKind::File,
Stream::Test => EntryKind::Directory,
Stream::Daemon => EntryKind::File,
}
}
pub fn describe(self) -> &'static str {
match self {
Stream::Ops => "operations started from the workbench",
Stream::Test => "test sessions",
Stream::Daemon => "the daemon's own log",
}
}
}
pub fn root(worktree_root: &Path) -> Result<PathBuf> {
Ok(crate::local_state::LocalState::of(worktree_root)?.logs())
}
pub fn stream_dir(logs_root: &Path, stream: Stream) -> PathBuf {
logs_root.join(stream.dir_name())
}
pub fn ensure_stream_dir(logs_root: &Path, stream: Stream) -> Result<PathBuf, String> {
let dir = stream_dir(logs_root, stream);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("cannot create the log directory {}: {e}", dir.display()))?;
Ok(dir)
}
pub fn op_log_path(
logs_root: &Path,
kind: &str,
id: &uuid::Uuid,
started_at: DateTime<Utc>,
) -> PathBuf {
let local = started_at.with_timezone(&chrono::Local);
let short = id.simple().to_string();
stream_dir(logs_root, Stream::Ops)
.join(local.format("%Y-%m-%d").to_string())
.join(format!(
"{}-{}-{}.jsonl",
local.format("%H%M%S"),
slug(kind),
&short[..8.min(short.len())]
))
}
pub fn find_op_log(logs_root: &Path, id: &uuid::Uuid) -> Option<PathBuf> {
let short = id.simple().to_string();
let short = &short[..8.min(short.len())];
let suffix = format!("-{short}.jsonl");
let ops = stream_dir(logs_root, Stream::Ops);
let days = std::fs::read_dir(&ops).ok()?;
let mut dirs: Vec<PathBuf> = days
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect();
dirs.sort();
for day in dirs.into_iter().rev() {
let Ok(files) = std::fs::read_dir(&day) else {
continue;
};
for file in files.flatten() {
let path = file.path();
if !path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(&suffix))
{
continue;
}
if let Ok((header, _)) = op_log::read_envelope(&path) {
if &header.id == id {
return Some(path);
}
}
}
}
None
}
fn slug(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut last_dash = true;
for ch in text.chars() {
if ch.is_ascii_alphanumeric() {
out.extend(ch.to_lowercase());
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
"op".to_string()
} else {
out
}
}
pub fn entries(logs_root: &Path) -> Vec<Entry> {
let mut found = Vec::new();
for stream in Stream::ALL {
let dir = stream_dir(logs_root, stream);
match stream.entry_kind() {
EntryKind::File if stream == Stream::Ops => {
for day in capped::children(&dir) {
if day.is_dir() {
for file in capped::children(&day) {
if file.is_file() {
push_entry(&mut found, file, stream);
}
}
} else if day.is_file() {
push_entry(&mut found, day, stream);
}
}
}
EntryKind::File => {
for file in capped::children(&dir) {
if file.is_file() {
push_entry(&mut found, file, stream);
}
}
}
EntryKind::Directory => {
for session in capped::children(&dir) {
push_entry(&mut found, session, stream);
}
}
}
}
found
}
fn push_entry(into: &mut Vec<Entry>, path: PathBuf, stream: Stream) {
into.push(capped::entry(path, stream.dir_name()));
}
pub fn usage(logs_root: &Path) -> Usage {
let found = entries(logs_root);
let covered: Vec<PathBuf> = Stream::ALL
.iter()
.map(|stream| stream_dir(logs_root, *stream))
.collect();
let mut usage = capped::usage_of(logs_root, &found, &covered);
for stream in Stream::ALL {
usage.by_group.entry(stream.dir_name().to_string()).or_default();
}
usage
}
pub fn sweep(logs_root: &Path, max_bytes: u64, protect: &[PathBuf], dry_run: bool) -> SweepReport {
let found = entries(logs_root);
let covered: Vec<PathBuf> = Stream::ALL
.iter()
.map(|stream| stream_dir(logs_root, *stream))
.collect();
let unreachable = capped::usage_of(logs_root, &found, &covered).unmanaged_bytes;
capped::sweep_entries(
found,
max_bytes,
MIN_MAX_BYTES,
0,
unreachable,
protect,
dry_run,
)
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;