Skip to main content

xil/cmd/
removal.rs

1//! Shared pieces of `xil remove-show` and `xil remove-episode`: the removal
2//! item model, size formatting, and deletion.
3
4use std::fs;
5use std::io::{self, BufRead, Write};
6use std::path::{Path, PathBuf};
7
8use xil_core::log;
9
10#[derive(Clone, Copy, PartialEq, Eq, Debug)]
11pub enum Kind {
12    Dir,
13    File,
14}
15
16#[derive(Clone, Debug)]
17pub struct Item {
18    pub kind: Kind,
19    pub path: PathBuf,
20    pub label: &'static str,
21}
22
23impl Item {
24    pub fn dir(path: PathBuf, label: &'static str) -> Item {
25        Item {
26            kind: Kind::Dir,
27            path,
28            label,
29        }
30    }
31    pub fn file(path: PathBuf, label: &'static str) -> Item {
32        Item {
33            kind: Kind::File,
34            path,
35            label,
36        }
37    }
38
39    /// Files under a directory (`rglob("*")`, dotfiles included), or 1/0 for a file.
40    pub fn file_count(&self) -> u64 {
41        if !self.path.exists() {
42            return 0;
43        }
44        match self.kind {
45            Kind::File => 1,
46            Kind::Dir => walk_files(&self.path).len() as u64,
47        }
48    }
49
50    pub fn total_bytes(&self) -> u64 {
51        if !self.path.exists() {
52            return 0;
53        }
54        match self.kind {
55            Kind::File => fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0),
56            Kind::Dir => walk_files(&self.path)
57                .iter()
58                .filter_map(|p| fs::metadata(p).ok())
59                .map(|m| m.len())
60                .sum(),
61        }
62    }
63}
64
65fn walk_files(dir: &Path) -> Vec<PathBuf> {
66    let mut out = Vec::new();
67    let mut stack = vec![dir.to_path_buf()];
68    while let Some(d) = stack.pop() {
69        let Ok(rd) = fs::read_dir(&d) else { continue };
70        for e in rd.filter_map(Result::ok) {
71            let p = e.path();
72            if p.is_dir() {
73                stack.push(p);
74            } else if p.is_file() {
75                out.push(p);
76            }
77        }
78    }
79    out
80}
81
82/// `_fmt_bytes`: B / KB / MB / GB with Python's `.1f` / `.2f` rounding.
83pub fn fmt_bytes(n: u64) -> String {
84    const KB: f64 = 1024.0;
85    if n < 1024 {
86        format!("{n} B")
87    } else if n < 1024u64.pow(2) {
88        format!("{:.1} KB", n as f64 / KB)
89    } else if n < 1024u64.pow(3) {
90        format!("{:.1} MB", n as f64 / (KB * KB))
91    } else {
92        format!("{:.2} GB", n as f64 / (KB * KB * KB))
93    }
94}
95
96/// `item.path.relative_to(root)` with the same fallback to the full path.
97pub fn rel_display(path: &Path, root: &Path) -> String {
98    path.strip_prefix(root)
99        .map(|r| r.display().to_string())
100        .unwrap_or_else(|_| path.display().to_string())
101}
102
103/// Delete every existing item, logging each removal. Returns files removed.
104pub fn delete(items: &[Item]) -> anyhow::Result<u64> {
105    let mut removed = 0;
106    for item in items {
107        if !item.path.exists() {
108            continue;
109        }
110        match item.kind {
111            Kind::Dir => {
112                let fc = item.file_count();
113                fs::remove_dir_all(&item.path)?;
114                removed += fc;
115            }
116            Kind::File => {
117                fs::remove_file(&item.path)?;
118                removed += 1;
119            }
120        }
121        log::info(&format!("  removed {}", item.path.display()));
122    }
123    Ok(removed)
124}
125
126/// Python `input(prompt).strip()`: prompt to stdout without a newline, one
127/// line from stdin. Empty string on EOF.
128pub fn input(prompt: &str) -> String {
129    let mut so = io::stdout().lock();
130    let _ = so.write_all(prompt.as_bytes());
131    let _ = so.flush();
132    let mut line = String::new();
133    let _ = io::stdin().lock().read_line(&mut line);
134    line.trim().to_string()
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn fmt_bytes_matches_python_rounding() {
143        assert_eq!(fmt_bytes(0), "0 B");
144        assert_eq!(fmt_bytes(1023), "1023 B");
145        assert_eq!(fmt_bytes(1024), "1.0 KB");
146        assert_eq!(fmt_bytes(1280), "1.2 KB"); // 1.25 rounds half to even
147        assert_eq!(fmt_bytes(1536), "1.5 KB");
148        assert_eq!(fmt_bytes(1024 * 1024), "1.0 MB");
149        assert_eq!(fmt_bytes(3 * 1024 * 1024 * 1024), "3.00 GB");
150    }
151
152    #[test]
153    fn counts_and_bytes_walk_recursively() {
154        let tmp = tempfile::tempdir().unwrap();
155        let d = tmp.path().join("d");
156        fs::create_dir_all(d.join("sub")).unwrap();
157        fs::write(d.join("a"), "12345").unwrap();
158        fs::write(d.join("sub/.hidden"), "123").unwrap();
159        let item = Item::dir(d.clone(), "");
160        assert_eq!(item.file_count(), 2);
161        assert_eq!(item.total_bytes(), 8);
162        let f = Item::file(d.join("a"), "");
163        assert_eq!(f.file_count(), 1);
164        assert_eq!(f.total_bytes(), 5);
165        assert_eq!(Item::file(d.join("missing"), "").file_count(), 0);
166    }
167}