1use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12
13use crate::config::{PrunedDir, Registry};
14use crate::engine;
15use crate::output;
16
17pub fn run(path_str: &str) -> Result<()> {
19 let path = Path::new(path_str)
20 .canonicalize()
21 .with_context(|| format!("Path not found: {path_str}"))?;
22
23 output::print_header("dev-prune restore");
24 output::print_info(&format!(
25 "Restoring dependencies in {}",
26 output::clean_path(&path)
27 ));
28
29 let (global_depth, timeout_secs) = crate::config::Registry::load()
34 .map(|r| (r.settings.scan_depth, r.settings.command_timeout_secs))
35 .unwrap_or((
36 crate::constants::DEFAULT_SCAN_DEPTH,
37 crate::constants::DEFAULT_COMMAND_TIMEOUT_SECS,
38 ));
39 let timeout = crate::adapters::command_timeout(timeout_secs);
40 let results = engine::restore_project_to_depth(&path, global_depth, timeout)?;
41
42 let mut failed = 0usize;
43
44 for (adapter_name, result) in &results {
45 match result {
46 Ok(()) => {
47 output::print_success(&format!("{adapter_name}: dependencies restored"));
48 }
49 Err(e) => {
50 output::print_error(&format!("{adapter_name}: {e}"));
51 failed += 1;
52 }
53 }
54 }
55
56 if failed > 0 {
60 anyhow::bail!(
61 "{failed} of {} {} failed to restore — see the errors above",
62 results.len(),
63 output::plural(results.len(), "adapter", "adapters")
64 );
65 }
66
67 output::print_success(&format!(
68 "Restored {} {}.",
69 results.len(),
70 output::plural(results.len(), "adapter", "adapters")
71 ));
72
73 Ok(())
74}
75
76pub fn run_last_run() -> Result<()> {
87 let registry = Registry::load()?;
88
89 let Some(last) = registry.last_prune.as_ref() else {
90 anyhow::bail!(
91 "No prune pass has been recorded yet, so there is nothing to put back.\n \
92 `devp restore <path>` restores a project you name."
93 );
94 };
95
96 let total: u64 = last.dirs.iter().map(|d| d.size_freed).sum();
97
98 output::print_header("dev-prune restore --last-run");
99 output::print_info(&format!(
100 "Putting back {} {} deleted on {} ({}).",
101 last.dirs.len(),
102 output::plural(last.dirs.len(), "directory", "directories"),
103 last.at.format("%Y-%m-%d %H:%M UTC"),
104 output::format_bytes(total)
105 ));
106
107 let dropped = settle_runtimes(&last.dirs)?;
110
111 let mut by_repo: BTreeMap<PathBuf, Vec<PrunedDir>> = BTreeMap::new();
114 for dir in &last.dirs {
115 let mut dir = dir.clone();
116 if dir
117 .runtime
118 .as_deref()
119 .is_some_and(|t| dropped.iter().any(|d| d == t))
120 {
121 dir.runtime = None;
122 }
123 by_repo.entry(dir.repo_path.clone()).or_default().push(dir);
124 }
125
126 let global_depth = registry.settings.scan_depth;
127 let timeout = crate::adapters::command_timeout(registry.settings.command_timeout_secs);
128 let mut attempted = 0usize;
129 let mut failed = 0usize;
130 let mut measured: Vec<(String, u64, u64)> = Vec::new();
134
135 for (repo_path, deleted) in &by_repo {
136 println!();
137 output::print_info(&output::clean_path(repo_path));
138
139 if !repo_path.exists() {
142 for dir in deleted {
143 attempted += 1;
144 failed += 1;
145 output::print_error(&format!(
146 " {} ({}): the repository no longer exists at this path",
147 dir.adapter, dir.bloat_dir
148 ));
149 }
150 continue;
151 }
152
153 for outcome in engine::restore_deleted(repo_path, deleted, global_depth, timeout) {
154 attempted += 1;
155 match &outcome.result {
156 Ok(()) => {
157 measured.push((
161 outcome.adapter.clone(),
162 outcome.bytes,
163 outcome.elapsed.as_millis().min(u128::from(u64::MAX)) as u64,
164 ));
165 output::print_success(&format!(" {}: restored", outcome.label));
166 }
167 Err(e) => {
168 failed += 1;
169 output::print_error(&format!(" {}: {e}", outcome.label));
170 }
171 }
172 }
173 }
174
175 record_restore_rates(measured);
181
182 println!();
183 if failed > 0 {
184 anyhow::bail!(
185 "{failed} of {attempted} {} failed to restore — see the errors above",
186 output::plural(attempted, "directory", "directories")
187 );
188 }
189
190 output::print_success(&format!(
191 "Restored {attempted} {} across {} {}.",
192 output::plural(attempted, "directory", "directories"),
193 by_repo.len(),
194 output::plural(by_repo.len(), "repository", "repositories")
195 ));
196
197 Ok(())
198}
199
200fn record_restore_rates(measured: Vec<(String, u64, u64)>) {
206 if measured.is_empty() {
207 return;
208 }
209 let Ok(mut registry) = Registry::load() else {
210 return;
211 };
212 for (adapter, bytes, millis) in measured {
213 registry.record_restore(&adapter, bytes, millis);
214 }
215 let _ = registry.save();
216}
217
218#[derive(Debug, PartialEq, Eq)]
224struct RuntimePlan {
225 honoured: Vec<String>,
227 missing: Vec<String>,
230}
231
232impl RuntimePlan {
233 fn build(dirs: &[PrunedDir], available: impl Fn(&str) -> bool) -> Self {
236 let mut honoured = Vec::new();
237 let mut missing = Vec::new();
238 for tag in dirs
242 .iter()
243 .filter(|d| d.adapter != crate::constants::DECLARED_ADAPTER_NAME)
244 .filter_map(|d| d.runtime.as_deref())
245 {
246 if honoured.iter().any(|t| t == tag) || missing.iter().any(|t| t == tag) {
247 continue;
248 }
249 if available(tag) {
250 honoured.push(tag.to_string());
251 } else {
252 missing.push(tag.to_string());
253 }
254 }
255 honoured.sort();
256 missing.sort();
257 Self { honoured, missing }
258 }
259}
260
261fn settle_runtimes(dirs: &[PrunedDir]) -> Result<Vec<String>> {
269 let plan = RuntimePlan::build(dirs, crate::adapters::python_runtime_available);
270
271 for tag in &plan.honoured {
272 output::print_info(&format!(
273 "Python {tag} environments will be rebuilt on Python {tag}, as recorded."
274 ));
275 }
276 if plan.missing.is_empty() {
277 return Ok(Vec::new());
278 }
279
280 let versions = plan.missing.join(", ");
281 output::print_warning(&format!(
282 "Python {versions} {} recorded for some of these environments, but not installed \
283 here. Rebuilding them means rebuilding on whatever `python` resolves to, and \
284 pinned wheels may not exist for it.",
285 output::plural(plan.missing.len(), "was", "were"),
286 ));
287 for tag in &plan.missing {
288 output::print_info(&format!(" Install it first: uv python install {tag}"));
289 }
290
291 if !confirm_other_interpreter() {
292 anyhow::bail!(
293 "Nothing was restored. Install the recorded {} and run `devp restore \
294 --last-run` again, or answer yes to rebuild on the interpreter you have.",
295 output::plural(plan.missing.len(), "interpreter", "interpreters"),
296 );
297 }
298 Ok(plan.missing)
299}
300
301fn confirm_other_interpreter() -> bool {
306 use std::io::{IsTerminal, Write};
307 if !std::io::stdin().is_terminal() {
308 output::print_info(
309 "Not running in a terminal, so this is not being answered for you — install \
310 the recorded interpreter, or re-run this where the question can be asked.",
311 );
312 return false;
313 }
314 eprint!("Rebuild them on the interpreter you have? [y/N]: ");
315 if std::io::stderr().flush().is_err() {
316 return false;
317 }
318 let mut input = String::new();
319 if std::io::stdin().read_line(&mut input).is_err() {
320 return false;
321 }
322 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn dir_with(runtime: Option<&str>) -> PrunedDir {
330 PrunedDir {
331 repo_path: PathBuf::from("/repo"),
332 bloat_dir: ".venv".to_string(),
333 adapter: "venv".to_string(),
334 size_freed: 1,
335 runtime: runtime.map(str::to_string),
336 }
337 }
338
339 #[test]
340 fn a_recorded_interpreter_that_is_installed_is_used_without_asking() {
341 let dirs = [dir_with(Some("3.12")), dir_with(None)];
342 let plan = RuntimePlan::build(&dirs, |_| true);
343 assert_eq!(plan.honoured, vec!["3.12".to_string()]);
344 assert!(plan.missing.is_empty(), "nothing to ask about");
345 }
346
347 #[test]
348 fn each_version_is_probed_once_however_many_directories_recorded_it() {
349 let dirs: Vec<PrunedDir> = (0..40).map(|_| dir_with(Some("3.12"))).collect();
351 let probes = std::cell::Cell::new(0);
352 let plan = RuntimePlan::build(&dirs, |_| {
353 probes.set(probes.get() + 1);
354 true
355 });
356 assert_eq!(probes.get(), 1);
357 assert_eq!(plan.honoured.len(), 1);
358 }
359
360 #[test]
361 fn an_interpreter_this_machine_does_not_have_is_what_gets_asked_about() {
362 let dirs = [dir_with(Some("3.12")), dir_with(Some("3.9"))];
363 let plan = RuntimePlan::build(&dirs, |tag| tag == "3.12");
364 assert_eq!(plan.honoured, vec!["3.12".to_string()]);
365 assert_eq!(plan.missing, vec!["3.9".to_string()]);
366 }
367
368 #[test]
369 fn a_declared_directory_never_reaches_the_interpreter_question() {
370 let mut declared = dir_with(Some("npm run build"));
374 declared.adapter = crate::constants::DECLARED_ADAPTER_NAME.to_string();
375 let dirs = [declared, dir_with(Some("3.12"))];
376 let plan = RuntimePlan::build(&dirs, |tag| tag == "3.12");
377 assert_eq!(plan.honoured, vec!["3.12".to_string()]);
378 assert!(plan.missing.is_empty(), "{:?}", plan.missing);
379 }
380
381 #[test]
382 fn a_pass_that_recorded_nothing_asks_nothing() {
383 let dirs = [dir_with(None), dir_with(None)];
386 let plan = RuntimePlan::build(&dirs, |_| unreachable!("nothing to probe"));
387 assert!(plan.honoured.is_empty() && plan.missing.is_empty());
388 }
389}