use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::config::{PrunedDir, Registry};
use crate::engine;
use crate::output;
pub fn run(path_str: &str) -> Result<()> {
let path = Path::new(path_str)
.canonicalize()
.with_context(|| format!("Path not found: {path_str}"))?;
output::print_header("dev-prune restore");
output::print_info(&format!(
"Restoring dependencies in {}",
output::clean_path(&path)
));
let (global_depth, timeout_secs) = crate::config::Registry::load()
.map(|r| (r.settings.scan_depth, r.settings.command_timeout_secs))
.unwrap_or((
crate::constants::DEFAULT_SCAN_DEPTH,
crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
));
let timeout = std::time::Duration::from_secs(timeout_secs);
let results = engine::restore_project_to_depth(&path, global_depth, timeout)?;
let mut failed = 0usize;
for (adapter_name, result) in &results {
match result {
Ok(()) => {
output::print_success(&format!("{adapter_name}: dependencies restored"));
}
Err(e) => {
output::print_error(&format!("{adapter_name}: {e}"));
failed += 1;
}
}
}
if failed > 0 {
anyhow::bail!(
"{failed} of {} {} failed to restore — see the errors above",
results.len(),
output::plural(results.len(), "adapter", "adapters")
);
}
output::print_success(&format!(
"Restored {} {}.",
results.len(),
output::plural(results.len(), "adapter", "adapters")
));
Ok(())
}
pub fn run_last_run() -> Result<()> {
let registry = Registry::load()?;
let Some(last) = registry.last_prune.as_ref() else {
anyhow::bail!(
"No prune pass has been recorded yet, so there is nothing to put back.\n \
`devp restore <path>` restores a project you name."
);
};
let total: u64 = last.dirs.iter().map(|d| d.size_freed).sum();
output::print_header("dev-prune restore --last-run");
output::print_info(&format!(
"Putting back {} {} deleted on {} ({}).",
last.dirs.len(),
output::plural(last.dirs.len(), "directory", "directories"),
last.at.format("%Y-%m-%d %H:%M UTC"),
output::format_bytes(total)
));
let dropped = settle_runtimes(&last.dirs)?;
let mut by_repo: BTreeMap<PathBuf, Vec<PrunedDir>> = BTreeMap::new();
for dir in &last.dirs {
let mut dir = dir.clone();
if dir
.runtime
.as_deref()
.is_some_and(|t| dropped.iter().any(|d| d == t))
{
dir.runtime = None;
}
by_repo.entry(dir.repo_path.clone()).or_default().push(dir);
}
let global_depth = registry.settings.scan_depth;
let timeout = std::time::Duration::from_secs(registry.settings.command_timeout_secs);
let mut attempted = 0usize;
let mut failed = 0usize;
let mut measured: Vec<(String, u64, u64)> = Vec::new();
for (repo_path, deleted) in &by_repo {
println!();
output::print_info(&output::clean_path(repo_path));
if !repo_path.exists() {
for dir in deleted {
attempted += 1;
failed += 1;
output::print_error(&format!(
" {} ({}): the repository no longer exists at this path",
dir.adapter, dir.bloat_dir
));
}
continue;
}
for outcome in engine::restore_deleted(repo_path, deleted, global_depth, timeout) {
attempted += 1;
match &outcome.result {
Ok(()) => {
measured.push((
outcome.adapter.clone(),
outcome.bytes,
outcome.elapsed.as_millis().min(u128::from(u64::MAX)) as u64,
));
output::print_success(&format!(" {}: restored", outcome.label));
}
Err(e) => {
failed += 1;
output::print_error(&format!(" {}: {e}", outcome.label));
}
}
}
}
record_restore_rates(measured);
println!();
if failed > 0 {
anyhow::bail!(
"{failed} of {attempted} {} failed to restore — see the errors above",
output::plural(attempted, "directory", "directories")
);
}
output::print_success(&format!(
"Restored {attempted} {} across {} {}.",
output::plural(attempted, "directory", "directories"),
by_repo.len(),
output::plural(by_repo.len(), "repository", "repositories")
));
Ok(())
}
fn record_restore_rates(measured: Vec<(String, u64, u64)>) {
if measured.is_empty() {
return;
}
let Ok(mut registry) = Registry::load() else {
return;
};
for (adapter, bytes, millis) in measured {
registry.record_restore(&adapter, bytes, millis);
}
let _ = registry.save();
}
#[derive(Debug, PartialEq, Eq)]
struct RuntimePlan {
honoured: Vec<String>,
missing: Vec<String>,
}
impl RuntimePlan {
fn build(dirs: &[PrunedDir], available: impl Fn(&str) -> bool) -> Self {
let mut honoured = Vec::new();
let mut missing = Vec::new();
for tag in dirs.iter().filter_map(|d| d.runtime.as_deref()) {
if honoured.iter().any(|t| t == tag) || missing.iter().any(|t| t == tag) {
continue;
}
if available(tag) {
honoured.push(tag.to_string());
} else {
missing.push(tag.to_string());
}
}
honoured.sort();
missing.sort();
Self { honoured, missing }
}
}
fn settle_runtimes(dirs: &[PrunedDir]) -> Result<Vec<String>> {
let plan = RuntimePlan::build(dirs, crate::adapters::python_runtime_available);
for tag in &plan.honoured {
output::print_info(&format!(
"Python {tag} environments will be rebuilt on Python {tag}, as recorded."
));
}
if plan.missing.is_empty() {
return Ok(Vec::new());
}
let versions = plan.missing.join(", ");
output::print_warning(&format!(
"Python {versions} {} recorded for some of these environments, but not installed \
here. Rebuilding them means rebuilding on whatever `python` resolves to, and \
pinned wheels may not exist for it.",
output::plural(plan.missing.len(), "was", "were"),
));
for tag in &plan.missing {
output::print_info(&format!(" Install it first: uv python install {tag}"));
}
if !confirm_other_interpreter() {
anyhow::bail!(
"Nothing was restored. Install the recorded {} and run `devp restore \
--last-run` again, or answer yes to rebuild on the interpreter you have.",
output::plural(plan.missing.len(), "interpreter", "interpreters"),
);
}
Ok(plan.missing)
}
fn confirm_other_interpreter() -> bool {
use std::io::{IsTerminal, Write};
if !std::io::stdin().is_terminal() {
output::print_info(
"Not running in a terminal, so this is not being answered for you — install \
the recorded interpreter, or re-run this where the question can be asked.",
);
return false;
}
eprint!("Rebuild them on the interpreter you have? [y/N]: ");
if std::io::stderr().flush().is_err() {
return false;
}
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
#[cfg(test)]
mod tests {
use super::*;
fn dir_with(runtime: Option<&str>) -> PrunedDir {
PrunedDir {
repo_path: PathBuf::from("/repo"),
bloat_dir: ".venv".to_string(),
adapter: "venv".to_string(),
size_freed: 1,
runtime: runtime.map(str::to_string),
}
}
#[test]
fn a_recorded_interpreter_that_is_installed_is_used_without_asking() {
let dirs = [dir_with(Some("3.12")), dir_with(None)];
let plan = RuntimePlan::build(&dirs, |_| true);
assert_eq!(plan.honoured, vec!["3.12".to_string()]);
assert!(plan.missing.is_empty(), "nothing to ask about");
}
#[test]
fn each_version_is_probed_once_however_many_directories_recorded_it() {
let dirs: Vec<PrunedDir> = (0..40).map(|_| dir_with(Some("3.12"))).collect();
let probes = std::cell::Cell::new(0);
let plan = RuntimePlan::build(&dirs, |_| {
probes.set(probes.get() + 1);
true
});
assert_eq!(probes.get(), 1);
assert_eq!(plan.honoured.len(), 1);
}
#[test]
fn an_interpreter_this_machine_does_not_have_is_what_gets_asked_about() {
let dirs = [dir_with(Some("3.12")), dir_with(Some("3.9"))];
let plan = RuntimePlan::build(&dirs, |tag| tag == "3.12");
assert_eq!(plan.honoured, vec!["3.12".to_string()]);
assert_eq!(plan.missing, vec!["3.9".to_string()]);
}
#[test]
fn a_pass_that_recorded_nothing_asks_nothing() {
let dirs = [dir_with(None), dir_with(None)];
let plan = RuntimePlan::build(&dirs, |_| unreachable!("nothing to probe"));
assert!(plan.honoured.is_empty() && plan.missing.is_empty());
}
}