rmr/main.rs
1//! Remove files or directories passed as CLI args (recursive for dirs).
2//!
3//! Strips trailing metadata suffix beginning at the first ':' in each argument (colon and suffix removed) before
4//! deletion. Useful when piping annotated paths (e.g. from linters or search tools emitting `path:line:col`).
5//!
6//! # Arguments
7//! - `<paths...>` One or more filesystem paths (files, symlinks, or directories). Optional trailing `:...` suffix is
8//! removed.
9//!
10//! # Returns
11//! - Exit code 0 if all provided paths were deleted successfully (or no paths given).
12//! - Exit code 1 if any path failed to delete or did not exist.
13//!
14//! # Errors
15//! - Initialization failure from [`color_eyre::install`].
16//! - I/O errors from [`std::fs::remove_file`] or [`std::fs::remove_dir_all`]. These are reported individually and
17//! contribute to a non-zero exit code.
18//!
19//! # Rationale
20//! - Eliminates need for ad-hoc shell loops to mass-delete mixed file & directory sets while handling `tool:line:col`
21//! style suffixes.
22//! - Colorized error reporting highlights problematic paths quickly.
23//!
24//! # Performance
25//! - One reverse byte scan per argument to locate last ':' (no allocation).
26//! - Single `symlink_metadata` call per path (branches on [`std::fs::FileType`]), minimizing metadata syscalls.
27//! - Sequential deletions avoid contention; for huge argument lists, parallelism could help but increases complexity
28//! (ordering, error aggregation).
29//!
30//! # Future Work
31//! - Add `--dry-run` flag for previewing deletions.
32//! - Add parallel deletion (configurable) for large batches.
33//! - Accept glob patterns expanded internally (on platforms without shell globbing).
34
35use std::fs::Metadata;
36use std::path::Path;
37
38use color_eyre::Report;
39use color_eyre::eyre::bail;
40use color_eyre::owo_colors::OwoColorize;
41use ytil_sys::cli::Args;
42
43/// Deletes one path after stripping the first ':' suffix segment.
44///
45/// Performs metadata lookup, branches on filetype, and deletes a file, symlink,
46/// or directory. Emits colored error messages to stderr; caller aggregates
47/// failures.
48///
49/// # Errors
50/// - Metadata retrieval failure (permissions, not found, etc.).
51/// - Deletion failure (I/O error removing file or directory).
52/// - Unsupported path type (reported as "Not found").
53///
54/// # Performance
55/// - Single metadata syscall plus one deletion syscall on success.
56/// - No heap allocation besides error formatting.
57///
58/// # Future Work
59/// - Distinguish success via a dedicated return type (e.g. `Result<Deleted, DeleteError>`).
60fn process(file: &str) -> color_eyre::Result<()> {
61 let trimmed = before_first_colon(file);
62 let path = Path::new(&trimmed);
63
64 path.symlink_metadata()
65 .map_err(|error| {
66 eprintln!(
67 "Cannot read metadata of path={} error={}",
68 path.display(),
69 format!("{error:?}").red()
70 );
71 Report::from(error)
72 })
73 .and_then(|metadata: Metadata| -> color_eyre::Result<()> {
74 let ft = metadata.file_type();
75 if ft.is_file() || ft.is_symlink() {
76 std::fs::remove_file(path).inspect_err(|err| {
77 eprintln!(
78 "Cannot delete file={} error={}",
79 path.display(),
80 format!("{err:?}").red()
81 );
82 })?;
83 return Ok(());
84 }
85 if ft.is_dir() {
86 std::fs::remove_dir_all(path).inspect_err(|err| {
87 eprintln!(
88 "Cannot delete dir={} error={}",
89 path.display(),
90 format!("{err:?}").red()
91 );
92 })?;
93 return Ok(());
94 }
95 bail!("{}", format!("Not found path={}", path.display()).red())
96 })
97}
98
99/// Strips suffix beginning at first ':'; returns subslice before colon.
100///
101/// # Performance
102/// - Single forward traversal `O(n)`; avoids UTF-8 decoding (colon is ASCII).
103/// - Simple explicit loop similar in cost to `find(':')`.
104fn before_first_colon(s: &str) -> &str {
105 for (i, &b) in s.as_bytes().iter().enumerate() {
106 if b == b':' {
107 return &s[..i];
108 }
109 }
110 s
111}
112
113/// Remove files or directories passed as CLI args (recursive for dirs).
114fn main() -> color_eyre::Result<()> {
115 color_eyre::install()?;
116
117 let files = ytil_sys::cli::get();
118
119 if files.has_help() {
120 println!("{}", include_str!("../help.txt"));
121 return Ok(());
122 }
123
124 if files.is_empty() {
125 println!("Nothing done");
126 }
127
128 let mut any_errors = false;
129 for file in &files {
130 if process(file).is_err() {
131 any_errors = true;
132 }
133 }
134
135 if any_errors {
136 std::process::exit(1);
137 }
138
139 Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144 use rstest::rstest;
145
146 use super::*;
147
148 #[rstest]
149 #[case::no_colon("hello", "hello")]
150 #[case::single_colon_at_end("alpha:", "alpha")]
151 #[case::multiple_colons("one:two:three", "one")]
152 #[case::colon_at_start(":rest", "")]
153 #[case::only_colon(":", "")]
154 #[case::empty_string("", "")]
155 #[case::unicode_characters("\u{03C0}:\u{03C4}:\u{03C9}", "\u{03C0}")]
156 fn before_first_colon_when_various_inputs_strips_after_first_colon_returns_expected(
157 #[case] input: &str,
158 #[case] expected: &str,
159 ) {
160 pretty_assertions::assert_eq!(before_first_colon(input), expected);
161 }
162}