const RETAINED_ENDED_RUNS: usize = 100;
pub(crate) fn run_registry_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))?;
Some(base.join("rhei").join("runs"))
}
pub(crate) fn run_registry_path(id: &str) -> Option<PathBuf> {
Some(run_registry_dir()?.join(format!("{id}.json")))
}
pub(crate) struct UndecidedRun {
pub(crate) path: PathBuf,
pub(crate) descriptor: Option<RunDescriptor>,
pub(crate) reason: String,
}
impl UndecidedRun {
pub(crate) fn summary_line(&self) -> String {
match &self.descriptor {
Some(descriptor) => descriptor.summary_line(),
None => format!("(unreadable entry) {}", self.path.display()),
}
}
}
#[derive(Default)]
pub(crate) struct RegistrySweep {
pub(crate) live: Vec<RunDescriptor>,
pub(crate) ended: Vec<RunDescriptor>,
pub(crate) undecided: Vec<UndecidedRun>,
}
impl RegistrySweep {
pub(crate) fn not_known_to_have_ended(&self) -> Vec<&RunDescriptor> {
self.live
.iter()
.chain(self.undecided.iter().filter_map(|entry| entry.descriptor.as_ref()))
.collect()
}
}
pub(crate) fn sweep_run_registry() -> RegistrySweep {
classify_run_registry(Pruning::Prune)
}
pub(crate) fn read_run_registry() -> RegistrySweep {
classify_run_registry(Pruning::Keep)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Pruning {
Prune,
Keep,
}
fn classify_run_registry(pruning: Pruning) -> RegistrySweep {
let mut sweep = RegistrySweep::default();
let Some(dir) = run_registry_dir() else {
return sweep;
};
let Ok(entries) = fs::read_dir(&dir) else {
return sweep;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let descriptor = match read_descriptor_result(&path) {
DescriptorRead::Loaded(descriptor) => *descriptor,
DescriptorRead::Missing => continue,
DescriptorRead::Unreadable(why) => {
sweep.undecided.push(UndecidedRun {
path,
descriptor: None,
reason: format!("the entry itself could not be read: {why}"),
});
continue;
}
};
match descriptor.liveness() {
Liveness::Live => sweep.live.push(descriptor),
Liveness::Ended => sweep.ended.push(descriptor),
Liveness::Gone => {
if pruning == Pruning::Prune {
let _ = fs::remove_file(&path);
}
}
Liveness::Unknown(reason) => {
sweep.undecided.push(UndecidedRun {
path,
descriptor: Some(descriptor),
reason,
});
}
}
}
newest_first(&mut sweep.live);
newest_first(&mut sweep.ended);
cap_ended_entries(&mut sweep.ended, pruning);
sweep
}
fn newest_first(runs: &mut [RunDescriptor]) {
runs.sort_by(|a, b| b.started_at.cmp(&a.started_at).then_with(|| a.id.cmp(&b.id)));
}
fn cap_ended_entries(ended: &mut Vec<RunDescriptor>, pruning: Pruning) {
if ended.len() <= RETAINED_ENDED_RUNS {
return;
}
for descriptor in ended.drain(RETAINED_ENDED_RUNS..) {
let Some(path) = run_registry_path(&descriptor.id) else {
continue;
};
if pruning == Pruning::Keep {
continue;
}
if read_descriptor(&path)
.is_some_and(|entry| entry.id == descriptor.id && entry.pid == descriptor.pid)
{
let _ = fs::remove_file(path);
}
}
}
pub(crate) fn resolve_run(reference: Option<&str>) -> MietteResult<RunDescriptor> {
let Some(reference) = reference else {
return descriptor_for_path(Path::new("."));
};
let sweep = sweep_run_registry();
let current = sweep.not_known_to_have_ended();
if let Some(exact) = current.iter().find(|run| run.id == reference) {
return Ok((*exact).clone());
}
let as_path = Path::new(reference);
if as_path.exists() {
return descriptor_for_path(as_path);
}
match prefix_matches(¤t, reference).as_slice() {
[] => {}
[only] => return Ok((*only).clone()),
ambiguous => return Err(ambiguous_reference(reference, ambiguous, "runs")),
}
let ended = sweep.ended.iter().collect::<Vec<_>>();
if let Some(exact) = ended.iter().find(|run| run.id == reference) {
return Ok((*exact).clone());
}
match prefix_matches(&ended, reference).as_slice() {
[] => {}
[only] => return Ok((*only).clone()),
ambiguous => return Err(ambiguous_reference(reference, ambiguous, "runs that have ended")),
}
Err(miette!(
help = "`rhei runs` lists what is live; a run that has ended resolves by its own id \
until it falls out of the 100 the registry keeps",
"no run matches '{reference}'"
))
}
fn prefix_matches<'a>(runs: &[&'a RunDescriptor], reference: &str) -> Vec<&'a RunDescriptor> {
runs.iter().copied().filter(|run| run.id.starts_with(reference)).collect()
}
const LISTED_AMBIGUOUS_MATCHES: usize = 10;
fn ambiguous_reference(
reference: &str,
matches: &[&RunDescriptor],
what: &str,
) -> miette::Report {
let mut listed = matches
.iter()
.take(LISTED_AMBIGUOUS_MATCHES)
.map(|run| run.summary_line())
.collect::<Vec<_>>();
if let Some(rest) = matches.len().checked_sub(LISTED_AMBIGUOUS_MATCHES).filter(|n| *n > 0) {
listed.push(format!("... and {rest} more"));
}
miette!(
help = "name one of these runs in full",
"'{reference}' matches {} {what}:\n {}",
matches.len(),
listed.join("\n ")
)
}
fn descriptor_for_path(path: &Path) -> MietteResult<RunDescriptor> {
let workspace = execution_workspace_root(&normalize_workspace_input(path));
let descriptor_path = run_descriptor_path(&workspace);
read_descriptor(&descriptor_path).ok_or_else(|| {
miette!(
help = format!(
"start one with `rhei run --headless {}`, or list live runs with `rhei runs`",
shell_quote(&path.display().to_string())
),
"no run has been recorded for {} (looked for {})",
workspace.display(),
descriptor_path.display()
)
})
}