use std::{
collections::HashSet,
io::Read as _,
path::{Path, PathBuf},
sync::{LazyLock, Mutex, MutexGuard, PoisonError},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use scv_client::Layout;
use scv_protocol::{QueueEntry, RestorableSession};
use scv_tools::delegation::ProcessIdentity;
use serde::{Deserialize, Serialize};
const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
const ACTIVE_CHARS: usize = 200;
pub(crate) const KEEP: Duration = Duration::from_secs(7 * 24 * 3600);
static RESTORING: LazyLock<Mutex<HashSet<PathBuf>>> = LazyLock::new(Default::default);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct SavedQueue {
pub(crate) v: u32,
pub(crate) session: String,
pub(crate) label: String,
pub(crate) workspace: PathBuf,
pub(crate) owner: ProcessIdentity,
pub(crate) saved_unix: u64,
#[serde(default)]
pub(crate) paused: bool,
#[serde(default)]
pub(crate) entries: Vec<QueueEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) active: Option<String>,
}
impl SavedQueue {
pub(crate) fn is_empty(&self) -> bool {
self.entries.is_empty() && self.active.is_none()
}
}
pub(crate) fn save(layout: &Layout, queue: &SavedQueue) {
if !valid_session(&queue.session) {
return;
}
if queue.is_empty() {
remove(layout, &queue.session);
return;
}
let dir = layout.session_queues();
let written = (|| -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
std::fs::create_dir_all(&dir)?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
let bytes = serde_json::to_vec(queue).map_err(std::io::Error::other)?;
scv_client::fs::replace_private(&dir.join(format!("{}.json", queue.session)), &bytes)
})();
if let Err(error) = written {
tracing::debug!(session = %queue.session, %error, "could not save a session's queue");
}
}
pub(crate) fn remove(layout: &Layout, session: &str) {
if valid_session(session) {
let _ = std::fs::remove_file(queue_file(layout, session));
}
}
pub(crate) fn load(layout: &Layout, session: &str) -> Option<SavedQueue> {
valid_session(session)
.then(|| read(&queue_file(layout, session)))
.flatten()
}
pub(crate) struct Claim {
path: PathBuf,
}
pub(crate) fn claim(layout: &Layout, session: &str) -> Option<Claim> {
if !valid_session(session) {
return None;
}
let path = queue_file(layout, session);
if restoring().insert(path.clone()) {
Some(Claim { path })
} else {
None
}
}
impl Claim {
pub(crate) fn remove(self) {
let _ = std::fs::remove_file(&self.path);
}
}
impl Drop for Claim {
fn drop(&mut self) {
restoring().remove(&self.path);
}
}
fn restoring() -> MutexGuard<'static, HashSet<PathBuf>> {
RESTORING.lock().unwrap_or_else(PoisonError::into_inner)
}
fn queue_file(layout: &Layout, session: &str) -> PathBuf {
layout.session_queues().join(format!("{session}.json"))
}
fn read(path: &Path) -> Option<SavedQueue> {
use std::os::unix::fs::OpenOptionsExt as _;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.ok()?;
let mut bytes = Vec::new();
file.take(MAX_FILE_BYTES).read_to_end(&mut bytes).ok()?;
let queue: SavedQueue = serde_json::from_slice(&bytes).ok()?;
(path.file_stem()?.to_str()? == queue.session).then_some(queue)
}
pub(crate) fn stopped(queue: &SavedQueue) -> bool {
!queue.owner.is_alive()
}
pub(crate) fn restorable(layout: &Layout) -> Vec<RestorableSession> {
let Ok(entries) = std::fs::read_dir(layout.session_queues()) else {
return Vec::new();
};
let now = unix_now();
let mut found = Vec::new();
for entry in entries.filter_map(Result::ok) {
let Some(queue) = read(&entry.path()) else {
continue;
};
if !stopped(&queue) {
continue;
}
if now.saturating_sub(queue.saved_unix) > KEEP.as_secs() {
let _ = std::fs::remove_file(entry.path());
continue;
}
found.push(RestorableSession {
id: queue.session,
label: queue.label,
workspace: queue.workspace.display().to_string(),
queued: u32::try_from(queue.entries.len()).unwrap_or(u32::MAX),
interrupted: queue.active,
saved_unix_seconds: queue.saved_unix,
});
}
found.sort_by_key(|session| std::cmp::Reverse(session.saved_unix_seconds));
found
}
pub(crate) fn active_line(prompt: &str) -> String {
let trimmed = prompt.trim();
let first = trimmed.lines().next().unwrap_or_default();
let mut line: String = first.chars().take(ACTIVE_CHARS).collect();
if line.len() < trimmed.len() {
line.push('…');
}
line
}
pub(crate) fn valid_session(session: &str) -> bool {
!session.is_empty()
&& session.len() <= 64
&& session
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
pub(crate) fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs())
}
#[cfg(test)]
mod tests;