Skip to main content

cba/
bath.rs

1//! Path manipulation
2
3use crate::StringError;
4use std::path::{Component, Path, PathBuf};
5
6/// Split path around last '.'
7pub fn split_ext(p: &str) -> [&str; 2] {
8    match p.rfind('.') {
9        Some(0) | None => [p, ""],
10        Some(idx) if idx + 1 < p.len() => [&p[..idx], &p[idx + 1..]],
11        Some(idx) => [&p[..idx], ""], // dot is last character
12    }
13}
14
15pub fn root_dir() -> PathBuf {
16    PathBuf::from(std::path::MAIN_SEPARATOR_STR)
17}
18
19#[easy_ext::ext(PathExt)]
20pub impl<T: AsRef<Path>> T {
21    /// Get the owned (lossy) basename of a valid path (for display purposes).
22    /// Returns the original if path has no filename.
23    fn basename(&self) -> String {
24        let path = self.as_ref();
25        match path.file_name() {
26            Some(s) => s.to_string_lossy(),
27            None => path.to_string_lossy(),
28        }
29        .to_string()
30    }
31
32    /// Get the (lossy) basename of a valid path.
33    /// Returns empty string + log::warn! if path has no filename.
34    fn filename(&self) -> Cow<'_, str> {
35        let path = self.as_ref();
36
37        match path.file_name() {
38            Some(s) => s.to_string_lossy(),
39            None => {
40                log::warn!("Failed to determine basename of {path:?}");
41                "".into()
42            }
43        }
44    }
45
46    /// Convert to str, or provide useful error.
47    fn _filename(&self) -> Result<&str, StringError> {
48        let path = self.as_ref();
49
50        let err_prefix = format!("Failed to determine basename of {path:?}");
51        path.file_name()
52            .ok_or(StringError(err_prefix.clone()))?
53            .to_str()
54            .ok_or(StringError(err_prefix))
55    }
56
57    fn display_short(&self, home_dir: &Path) -> String {
58        let path = self.as_ref();
59        if let Ok(stripped) = path.strip_prefix(home_dir) {
60            PathBuf::from("~").join(stripped).to_string_lossy().into()
61        } else {
62            path.to_string_lossy().into()
63        }
64    }
65
66    fn len(&self) -> usize {
67        self.as_ref().normalize().iter().count()
68    }
69
70    /// Robustly determine whether a file is hidden
71    fn is_hidden(&self) -> bool {
72        let mut skip = 0;
73
74        for c in self.as_ref().components().rev() {
75            match c {
76                Component::ParentDir => {
77                    skip += 1;
78                }
79                Component::CurDir => {}
80                Component::Normal(name) => {
81                    if skip > 0 {
82                        skip -= 1;
83                        continue;
84                    }
85                    return name.as_encoded_bytes().first() == Some(&b'.');
86                }
87                _ => {}
88            }
89        }
90
91        false
92    }
93
94    /// Prepend base to current path then normalize.
95    ///
96    /// # Example
97    /// ```rust
98    /// use std::path::Path;
99    /// use cba::{bog::{BogOkExt, BogUnwrapExt}, bath::PathExt, bait::OptionExt};
100    ///
101    /// let path = Path::new("");
102    /// path.abs(std::env::current_dir()._ebog().or_exit());
103    /// ```
104    fn abs(&self, base: impl AsRef<Path>) -> PathBuf {
105        let path = self.as_ref();
106        let base = base.as_ref();
107
108        base.join(path).normalize()
109    }
110
111    fn is_empty(&self) -> bool {
112        let path = self.as_ref();
113        path.components().next().is_none()
114    }
115
116    /// clean path logically (so that all components are [`Component::Normal`])
117    fn normalize(&self) -> PathBuf {
118        let path = self.as_ref();
119        let mut components = path.components().peekable();
120        // keep the prefix
121        let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
122            components.next();
123            PathBuf::from(c.as_os_str())
124        } else {
125            PathBuf::new()
126        };
127
128        for component in components {
129            match component {
130                Component::Prefix(..) => unreachable!(),
131                Component::RootDir => {
132                    ret.push(component.as_os_str());
133                }
134                Component::CurDir => {}
135                Component::ParentDir => {
136                    ret.pop();
137                }
138                Component::Normal(c) => {
139                    ret.push(c);
140                }
141            }
142        }
143
144        ret
145    }
146
147    /// Quotes the path.
148    /// Returns None if not Windows or Unix or not UTF-8.
149    fn shell_quote(&self) -> Option<String> {
150        let Some(s) = self.as_ref().to_str() else {
151            return None;
152        };
153
154        if cfg!(windows) {
155            // Windows CMD: wrap in double quotes, escape internal quotes by doubling them
156            // e.g., C:\Path "With" Quotes -> "C:\Path ""With"" Quotes"
157            let escaped = s.replace('"', "\"\"");
158            Some(format!("\"{}\"", escaped))
159        } else if cfg!(unix) {
160            // Unix shells: wrap in single quotes, escape internal single quotes
161            // e.g., /path/it's/here -> '/path/it'\''s/here'
162            let escaped = s.replace('\'', r"'\''");
163            Some(format!("'{}'", escaped))
164        } else {
165            None
166        }
167    }
168}
169
170pub fn shell_quote_impl(s: &str) -> String {
171    if cfg!(windows) {
172        // Windows CMD: wrap in double quotes, escape internal quotes by doubling them
173        // e.g., C:\Path "With" Quotes -> "C:\Path ""With"" Quotes"
174        let escaped = s.replace('"', "\"\"");
175        format!("\"{}\"", escaped)
176    } else if cfg!(unix) {
177        // Unix shells: wrap in single quotes, escape internal single quotes
178        // e.g., /path/it's/here -> '/path/it'\''s/here'
179        let escaped = s.replace('\'', r"'\''");
180        format!("'{}'", escaped)
181    } else {
182        s.to_string()
183    }
184}
185
186// ----------------------
187
188/// Cache the expression into a fn() -> &'static Path
189#[macro_export]
190macro_rules! expr_as_path_fn {
191    ($fn_name:ident, $expr:expr) => {
192        pub fn $fn_name() -> &'static std::path::Path {
193            static VALUE: std::sync::LazyLock<std::path::PathBuf> =
194                std::sync::LazyLock::new(|| $expr.into());
195            &VALUE
196        }
197    };
198}
199
200// ----------------------
201
202use std::borrow::Cow;
203use std::ffi::{OsStr, OsString};
204
205/// not sure if as_encoded_bytes is better
206#[cfg(unix)]
207pub fn os_str_to_bytes(string: &OsStr) -> Cow<'_, [u8]> {
208    use std::os::unix::ffi::OsStrExt;
209    Cow::Borrowed(string.as_bytes())
210}
211
212#[cfg(windows)]
213pub fn os_str_to_bytes(string: &OsStr) -> Cow<'_, [u8]> {
214    use std::os::windows::ffi::OsStrExt;
215    let bytes = string.encode_wide().flat_map(u16::to_le_bytes).collect();
216    Cow::Owned(bytes)
217}
218
219#[cfg(unix)]
220pub fn bytes_to_os_string(bytes: Vec<u8>) -> OsString {
221    use std::os::unix::ffi::OsStringExt;
222    OsString::from_vec(bytes)
223}
224
225#[cfg(windows)]
226pub fn bytes_to_os_string(bytes: Vec<u8>) -> OsString {
227    use std::os::windows::ffi::OsStringExt;
228
229    debug_assert!(bytes.len() % 2 == 0, "invalid UTF-16 byte length");
230
231    let wide: Vec<u16> = bytes
232        .chunks_exact(2)
233        .map(|c| u16::from_le_bytes([c[0], c[1]]))
234        .collect();
235
236    OsString::from_wide(&wide)
237}
238
239/// to_string_lossy for any type AsRef<OsStr>.
240/// Note: (Intent is that it's possibly useful for macros).
241pub fn to_string_lossy(s: &impl AsRef<std::ffi::OsStr>) -> std::borrow::Cow<'_, str> {
242    s.as_ref().to_string_lossy()
243}
244
245// ----------------------
246#[derive(Debug, Clone, PartialEq, Eq)]
247#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
248pub enum RenamePolicy {
249    WrappedInc(String, String),
250    // WrappedSuffix(&'static str, &'static str),
251    // RepeatedPrefix,
252    Replace, // don't check
253}
254
255// impl RenamePolicy {
256//     pub const DEFAULT: Self = Self::WrappedInc("_", "");
257// }
258
259impl Default for RenamePolicy {
260    fn default() -> Self {
261        Self::WrappedInc("_".into(), "".into())
262    }
263}
264
265// Requires: src is a normalized path with a filename
266// If dest ends with a slash, target becomes dest/src_name
267pub fn auto_dest_for_src(
268    src: impl AsRef<Path>,
269    dest: impl AsRef<OsStr>,
270    method: &RenamePolicy,
271) -> PathBuf {
272    let src = src.as_ref();
273    let dest = dest.as_ref();
274
275    let put_into_dest =
276        dest.is_empty() || dest.to_string_lossy().ends_with(std::path::MAIN_SEPARATOR);
277    let dest_path = Path::new(dest).normalize();
278
279    let initial_dest = if put_into_dest || dest_path.file_name().is_none() {
280        let name = src
281            .file_name()
282            .expect("Could not determine a valid destination: missing file_name.");
283        dest_path.join(name)
284    } else {
285        dest_path
286    };
287
288    match method {
289        RenamePolicy::Replace => {
290            return initial_dest;
291        }
292        RenamePolicy::WrappedInc(prefix, suffix) => {
293            if !initial_dest.exists() {
294                return initial_dest;
295            }
296
297            let parent = initial_dest.parent().unwrap_or(&initial_dest);
298            let s = initial_dest.filename();
299            let [stem, ext] = split_ext(&s);
300
301            for i in 1usize.. {
302                let candidate: PathBuf = parent.join(if ext.is_empty() {
303                    format!("{stem}{prefix}{i}{suffix}")
304                } else {
305                    format!("{stem}{prefix}{i}{suffix}.{ext}")
306                });
307
308                if !candidate.exists() {
309                    return candidate;
310                }
311            }
312            unreachable!()
313        }
314    }
315}