cargo_cleaner/
lib.rs

1pub mod notify_rw_lock;
2pub mod tui;
3
4use crate::notify_rw_lock::{NotifyRwLock, NotifySender};
5use cargo_toml::Manifest;
6use crossbeam_channel::{unbounded, Receiver, Sender};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::SystemTime;
10use uuid::Uuid;
11
12/// Job for the threaded project finder. First the path to be searched, second the sender to create
13/// new jobs for recursively searching the dirs
14struct Job(PathBuf, Sender<Job>);
15
16pub struct Progress {
17    pub total: usize,
18    pub scanned: usize,
19}
20
21/// Recursively scan the given path for cargo projects using the specified number of threads.
22///
23/// When the number of threads is 0, use as many threads as virtual CPU cores.
24pub fn find_cargo_projects(
25    path: &Path,
26    mut num_threads: usize,
27    notify_tx: NotifySender,
28) -> (
29    Receiver<anyhow::Result<ProjectTargetAnalysis>>,
30    Arc<NotifyRwLock<Progress>>,
31) {
32    let progress = Arc::new(NotifyRwLock::new(
33        notify_tx,
34        Progress {
35            total: 1, // 最初に入っているディレクトリは必ずスキャンする
36            scanned: 0,
37        },
38    ));
39    if num_threads == 0 {
40        num_threads = num_cpus::get();
41    }
42
43    let (result_tx, result_rx) = unbounded();
44    let path = path.to_owned();
45    std::thread::spawn({
46        let progress = progress.clone();
47        move || {
48            std::thread::scope(move |scope| {
49                let (job_tx, job_rx) = unbounded();
50
51                (0..num_threads)
52                    .map(|_| (job_rx.clone(), result_tx.clone()))
53                    .for_each(|(job_rx, result_tx)| {
54                        scope.spawn({
55                            let progress = progress.clone();
56                            || {
57                                job_rx.into_iter().for_each(move |job| {
58                                    find_cargo_projects_task(
59                                        job,
60                                        result_tx.clone(),
61                                        progress.clone(),
62                                    )
63                                })
64                            }
65                        });
66                    });
67
68                job_tx.clone().send(Job(path, job_tx)).unwrap();
69            });
70        }
71    });
72
73    (result_rx, progress)
74}
75
76/// Scan the given directory and report to the results Sender if the directory contains a
77/// Cargo.toml . Detected subdirectories should be queued as a new job in with the job_sender.
78///
79/// This function is supposed to be called by the threadpool in find_cargo_projects
80fn find_cargo_projects_task(
81    job: Job,
82    results: Sender<anyhow::Result<ProjectTargetAnalysis>>,
83    progress: Arc<NotifyRwLock<Progress>>,
84) {
85    let path = job.0;
86    let job_sender = job.1;
87
88    let read_dir = match path.read_dir() {
89        Ok(it) => it,
90        Err(_e) => {
91            progress.write().scanned += 1;
92            return;
93        }
94    };
95
96    let (dirs, files): (Vec<_>, Vec<_>) = read_dir
97        .filter_map(|it| it.ok())
98        .partition(|it| it.file_type().is_ok_and(|t| t.is_dir()));
99    let dirs: Vec<_> = dirs.iter().map(|it| it.path()).collect();
100    let files: Vec<_> = files.iter().map(|it| it.path()).collect();
101
102    let has_cargo_toml = files
103        .iter()
104        .any(|it| it.file_name().unwrap_or_default().to_string_lossy() == "Cargo.toml");
105
106    // Iterate through the subdirectories of path, ignoring entries that caused errors
107    for it in dirs {
108        let filename = it.file_name().unwrap_or_default().to_string_lossy();
109        match filename.as_ref() {
110            // No need to search .git directories for cargo projects. Also skip .cargo directories
111            // as there shouldn't be any target dirs in there. Even if there are valid target dirs,
112            // they should probably not be deleted. See issue #2 (https://github.com/dnlmlr/cargo-clean-all/issues/2)
113            ".git" | ".cargo" => (),
114            // For directories queue a new job to search it with the threadpool
115            _ => {
116                job_sender
117                    .send(Job(it.to_path_buf(), job_sender.clone()))
118                    .unwrap();
119                progress.write().total += 1;
120            }
121        }
122    }
123
124    // If path contains a Cargo.toml, it is a project directory
125    if has_cargo_toml {
126        results.send(ProjectTargetAnalysis::analyze(&path)).unwrap();
127    }
128    progress.write().scanned += 1;
129}
130
131#[derive(Clone, Debug)]
132pub struct ProjectTargetAnalysis {
133    pub id: Uuid,
134    /// The path of the project without the `target` directory suffix
135    pub project_path: PathBuf,
136    /// Cargo project name
137    pub project_name: Option<String>,
138    /// The size in bytes that the target directory takes up
139    pub size: u64,
140    /// The timestamp of the last recently modified file in the target directory
141    pub last_modified: SystemTime,
142    /// Indicate that this target directory should be cleaned
143    pub selected_for_cleanup: bool,
144}
145
146impl ProjectTargetAnalysis {
147    /// Analyze a given project directories target directory
148    pub fn analyze(path: &Path) -> anyhow::Result<Self> {
149        let (size, last_modified) = Self::recursive_scan_target(path.join("target"));
150        let cargo_manifest = Manifest::from_path(path.join("Cargo.toml"))?;
151        Ok(Self {
152            id: Uuid::new_v4(),
153            project_path: path.to_owned(),
154            project_name: cargo_manifest.package.map(|p| p.name),
155            size,
156            last_modified,
157            selected_for_cleanup: false,
158        })
159    }
160
161    // Recursively sum up the file sizes and find the last modified timestamp
162    fn recursive_scan_target<T: AsRef<Path>>(path: T) -> (u64, SystemTime) {
163        let path = path.as_ref();
164
165        let default = (0, SystemTime::UNIX_EPOCH);
166
167        if !path.exists() {
168            return default;
169        }
170
171        match (path.is_file(), path.metadata()) {
172            (true, Ok(md)) => (md.len(), md.modified().unwrap_or(default.1)),
173            _ => path
174                .read_dir()
175                .map(|rd| {
176                    rd.filter_map(|it| it.ok().map(|it| it.path()))
177                        .map(Self::recursive_scan_target)
178                        .fold(default, |a, b| (a.0 + b.0, a.1.max(b.1)))
179                })
180                .unwrap_or(default),
181        }
182    }
183}