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}