Skip to main content

fm/common/
utils.rs

1use std::borrow::Borrow;
2use std::borrow::Cow;
3use std::collections::HashSet;
4use std::env;
5use std::fs::{metadata, read_to_string, File};
6use std::io::{BufRead, Write};
7use std::os::unix::fs::MetadataExt;
8use std::path::{Path, PathBuf};
9use std::str::FromStr;
10
11use anyhow::bail;
12use anyhow::{anyhow, Context, Result};
13use copypasta::{ClipboardContext, ClipboardProvider};
14use sysinfo::Disk;
15use sysinfo::Disks;
16use unicode_segmentation::UnicodeSegmentation;
17
18use crate::common::{CONFIG_FOLDER, ZOXIDE};
19use crate::config::IS_LOGGING;
20use crate::event::build_input_socket_filepath;
21use crate::io::execute_without_output;
22use crate::io::Extension;
23use crate::modes::{human_size, nvim_open, ContentWindow, Users};
24use crate::{log_info, log_line};
25
26/// The mount point of a path
27pub trait MountPoint<'a> {
28    /// Returns the mount point of a path.
29    fn mount_point(&self, mount_points: &'a HashSet<&'a Path>) -> Option<&Self>;
30}
31
32impl<'a> MountPoint<'a> for Path {
33    fn mount_point(&self, mount_points: &'a HashSet<&'a Path>) -> Option<&Self> {
34        let mut current = self;
35        while !mount_points.contains(current) {
36            current = current.parent()?;
37        }
38        Some(current)
39    }
40}
41
42/// Returns the disk owning a path.
43/// None if the path can't be found.
44///
45/// We sort the disks by descending mount point size, then
46/// we return the first disk whose mount point match the path.
47fn disk_used_by_path<'a>(disks: &'a Disks, path: &Path) -> Option<&'a Disk> {
48    let mut disks: Vec<&'a Disk> = disks.list().iter().collect();
49    disks.sort_by_key(|disk| usize::MAX - disk.mount_point().components().count());
50    disks
51        .iter()
52        .find(|&disk| path.starts_with(disk.mount_point()))
53        .map(|disk| &**disk)
54}
55
56fn disk_space_used(disk: Option<&Disk>) -> String {
57    match disk {
58        None => "".to_owned(),
59        Some(disk) => human_size(disk.available_space()),
60    }
61}
62
63/// Returns the disk space of the disk holding this path.
64/// We can't be sure what's the disk of a given path, so we have to look
65/// if the mount point is a parent of given path.
66/// This solution is ugly but... for a lack of a better one...
67pub fn disk_space(disks: &Disks, path: &Path) -> String {
68    if path.as_os_str().is_empty() {
69        return "".to_owned();
70    }
71    disk_space_used(disk_used_by_path(disks, path))
72}
73
74/// Print the final path & save it to a temporary file.
75/// Must be called last since we erase temporary with similar name
76/// before leaving.
77pub fn save_final_path(final_path: &str) {
78    log_info!("print on quit {final_path}");
79    println!("{final_path}");
80    let Ok(mut file) = File::create("/tmp/fm_output.txt") else {
81        log_info!("Couldn't save {final_path} to /tmp/fm_output.txt");
82        return;
83    };
84    writeln!(file, "{final_path}").expect("Failed to write to file");
85}
86
87/// Returns the buffered lines from a text file.
88pub fn read_lines<P>(
89    filename: P,
90) -> std::io::Result<std::io::Lines<std::io::BufReader<std::fs::File>>>
91where
92    P: AsRef<std::path::Path>,
93{
94    let file = std::fs::File::open(filename)?;
95    Ok(std::io::BufReader::new(file).lines())
96}
97
98/// Extract a filename from a path reference.
99/// May fail if the filename isn't utf-8 compliant.
100pub fn filename_from_path(path: &std::path::Path) -> Result<&str> {
101    path.file_name()
102        .unwrap_or_default()
103        .to_str()
104        .context("couldn't parse the filename")
105}
106
107/// Uid of the current user.
108/// Read from `/proc/self`.
109/// Should never fail.
110pub fn current_uid() -> Result<u32> {
111    Ok(metadata("/proc/self").map(|metadata| metadata.uid())?)
112}
113
114/// Get the current username as a String.
115/// Read from `/proc/self` and then `/etc/passwd` and should never fail.
116pub fn current_username() -> Result<String> {
117    Users::only_users()
118        .get_user_by_uid(current_uid()?)
119        .context("Couldn't read my own name")
120        .cloned()
121}
122
123/// True if the program is given by an absolute path which exists or
124/// if the command is available in $PATH.
125pub fn is_in_path<S>(program: S) -> bool
126where
127    S: Into<String> + std::fmt::Display + AsRef<Path>,
128{
129    let p = program.to_string();
130    let Some(program) = p.split_whitespace().next() else {
131        return false;
132    };
133    if Path::new(program).exists() {
134        return true;
135    }
136    if let Ok(path) = std::env::var("PATH") {
137        for p in path.split(':') {
138            let p_str = &format!("{p}/{program}");
139            if std::path::Path::new(p_str).exists() {
140                return true;
141            }
142        }
143    }
144    false
145}
146
147/// Extract the lines of a string
148pub fn extract_lines(content: String) -> Vec<String> {
149    content.lines().map(|line| line.to_string()).collect()
150}
151
152/// Returns the clipboard content if it's set
153pub fn get_clipboard() -> Option<String> {
154    let Ok(mut ctx) = ClipboardContext::new() else {
155        return None;
156    };
157    ctx.get_contents().ok()
158}
159
160/// Sets the clipboard content.
161pub fn set_clipboard(content: String) {
162    log_info!("copied to clipboard: {}", content);
163    let Ok(mut ctx) = ClipboardContext::new() else {
164        return;
165    };
166    let Ok(_) = ctx.set_contents(content) else {
167        return;
168    };
169    // For some reason, it's not writen if you don't read it back...
170    let _ = ctx.get_contents();
171}
172
173/// Copy the filename to the clipboard. Only the filename.
174pub fn content_to_clipboard(path: &std::path::Path) {
175    let Some(extension) = path.extension() else {
176        return;
177    };
178    if !matches!(
179        Extension::matcher(&extension.to_string_lossy()),
180        Extension::Text
181    ) {
182        return;
183    }
184    let Ok(content) = read_to_string(path) else {
185        return;
186    };
187    set_clipboard(content);
188    log_line!("Copied {path} content to clipboard", path = path.display());
189}
190
191/// Copy the filename to the clipboard. Only the filename.
192pub fn filename_to_clipboard(path: &std::path::Path) {
193    let Some(filename) = path.file_name() else {
194        return;
195    };
196    let filename = filename.to_string_lossy().to_string();
197    set_clipboard(filename)
198}
199
200/// Copy the filepath to the clipboard. The absolute path.
201pub fn filepath_to_clipboard(path: &std::path::Path) {
202    let path = path.to_string_lossy().to_string();
203    set_clipboard(path)
204}
205
206/// Convert a row into a `crate::fm::ContentWindow` index.
207/// Just remove the header rows.
208pub fn row_to_window_index(row: u16) -> usize {
209    row as usize - ContentWindow::HEADER_ROWS
210}
211
212/// Convert a string into a valid, expanded and canonicalized path.
213/// Doesn't check if the path exists.
214pub fn string_to_path(path_string: &str) -> Result<std::path::PathBuf> {
215    let expanded_cow_path = tilde(path_string);
216    let expanded_target: &str = expanded_cow_path.borrow();
217    Ok(std::fs::canonicalize(expanded_target)?)
218}
219
220/// True if the executable is "sudo"
221pub fn is_sudo_command(executable: &str) -> bool {
222    matches!(executable, "sudo")
223}
224
225/// Open the path in neovim.
226pub fn open_in_current_neovim(path: &Path, nvim_server: &str) {
227    log_info!(
228        "open_in_current_neovim {nvim_server} {path}",
229        path = path.display()
230    );
231    match nvim_open(nvim_server, path) {
232        Ok(()) => log_line!("Opened {path} in neovim", path = path.display()),
233        Err(error) => log_line!(
234            "Couldn't open {path} in neovim. Error {error:?}",
235            path = path.display()
236        ),
237    }
238}
239
240/// Creates a random string.
241/// The string starts with `fm-` and contains 7 random alphanumeric characters.
242pub fn random_name() -> String {
243    let mut rand_str = String::with_capacity(10);
244    rand_str.push_str("fm-");
245    crate::common::random_alpha_chars()
246        .take(7)
247        .for_each(|ch| rand_str.push(ch));
248    rand_str.push_str(".txt");
249    rand_str
250}
251
252/// Clear the temporary file used by fm for previewing.
253pub fn clear_tmp_files() {
254    let Ok(read_dir) = std::fs::read_dir("/tmp") else {
255        return;
256    };
257    read_dir
258        .filter_map(|e| e.ok())
259        .filter(|e| e.file_name().to_string_lossy().starts_with("fm_thumbnail"))
260        .for_each(|e| std::fs::remove_file(e.path()).unwrap_or_default())
261}
262
263pub fn clear_input_socket_files() -> Result<()> {
264    let input_socket_filepath = build_input_socket_filepath();
265    if std::path::Path::new(&input_socket_filepath).exists() {
266        std::fs::remove_file(&input_socket_filepath)?;
267    }
268    Ok(())
269}
270
271/// True if the directory is empty,
272/// False if it's not.
273/// Err if the path doesn't exists or isn't accessible by
274/// the user.
275pub fn is_dir_empty(path: &std::path::Path) -> Result<bool> {
276    Ok(path.read_dir()?.next().is_none())
277}
278
279/// Converts a [`std::path::Path`] to `String`.
280pub fn path_to_string<P>(path: &P) -> String
281where
282    P: AsRef<std::path::Path>,
283{
284    path.as_ref().to_string_lossy().into_owned()
285}
286
287/// True iff the last modification of given path happened less than `seconds` ago.
288/// If the path has a modified time in future (ie. poorly configured iso file) it
289/// will log an error and returns false.
290pub fn has_last_modification_happened_less_than<P>(path: P, seconds: u64) -> Result<bool>
291where
292    P: AsRef<std::path::Path>,
293{
294    let modified = path.as_ref().metadata()?.modified()?;
295    if let Ok(elapsed) = modified.elapsed() {
296        let need_refresh = elapsed < std::time::Duration::new(seconds, 0);
297        Ok(need_refresh)
298    } else {
299        let dt: chrono::DateTime<chrono::offset::Utc> = modified.into();
300        let fmt = dt.format("%Y/%m/%d %T");
301        log_info!(
302            "Error for {path} modified datetime {fmt} is in future",
303            path = path.as_ref().display(),
304        );
305        Ok(false)
306    }
307}
308
309/// Rename a file giving it a new **file name**.
310/// It uses `std::fs::rename` and `std::fs:create_dir_all` and has same limitations.
311/// If the new name contains intermediate slash (`'/'`) like: `"a/b/d"`,
312/// all intermediate folders will be created in the parent folder of `old_path` if needed.
313///
314/// # Errors
315///
316/// It may fail for the same reasons as [`std::fs::rename`] and [`std::fs::create_dir_all`].
317/// See those for more details.
318pub fn rename_filename<P, Q>(old_path: P, new_name: Q) -> Result<std::path::PathBuf>
319where
320    P: AsRef<std::path::Path>,
321    Q: AsRef<std::path::Path>,
322{
323    let Some(old_parent) = old_path.as_ref().parent() else {
324        return Err(anyhow!(
325            "no parent for {old_path}",
326            old_path = old_path.as_ref().display()
327        ));
328    };
329    let new_path = old_parent.join(new_name);
330    if old_path.as_ref() == new_path {
331        log_info!(
332            "Path didn't change for {new_path}.",
333            new_path = new_path.display()
334        );
335        return Ok(new_path);
336    }
337    if new_path.exists() {
338        log_line!(
339            "File already exists {new_path}",
340            new_path = new_path.display()
341        );
342        bail!(
343            "File already exists {new_path}",
344            new_path = new_path.display()
345        );
346    }
347    let Some(new_parent) = new_path.parent() else {
348        bail!("no parent for {new_path}", new_path = new_path.display());
349    };
350
351    log_info!(
352        "renaming: {} -> {}",
353        old_path.as_ref().display(),
354        new_path.display()
355    );
356    log_line!(
357        "renaming: {} -> {}",
358        old_path.as_ref().display(),
359        new_path.display()
360    );
361
362    std::fs::create_dir_all(new_parent)?;
363    std::fs::rename(old_path, &new_path)?;
364    Ok(new_path)
365}
366
367/// Rename a file giving it a new **full path**.
368/// It uses `std::fs::rename` and `std::fs:create_dir_all` and has same limitations.
369/// If the new name contains intermediate slash (`'/'`) like: `"a/b/d"`,
370/// all intermediate folders will be created if needed.
371///
372/// # Errors
373///
374/// It may fail for the same reasons as [`std::fs::rename`] and [`std::fs::create_dir_all`].
375/// See those for more details.
376pub fn rename_fullpath<P, Q>(old_path: P, new_path: Q) -> Result<()>
377where
378    P: AsRef<std::path::Path>,
379    Q: AsRef<std::path::Path>,
380{
381    let new_path = new_path.as_ref();
382    if new_path.exists() {
383        return Err(anyhow!(
384            "File already exists {new_path}",
385            new_path = new_path.display()
386        ));
387    }
388    let Some(new_parent) = new_path.parent() else {
389        return Err(anyhow!(
390            "no parent for {new_path}",
391            new_path = new_path.display()
392        ));
393    };
394
395    log_info!(
396        "renaming: {} -> {}",
397        old_path.as_ref().display(),
398        new_path.display()
399    );
400    log_line!(
401        "renaming: {} -> {}",
402        old_path.as_ref().display(),
403        new_path.display()
404    );
405
406    std::fs::create_dir_all(new_parent)?;
407    std::fs::rename(old_path, new_path)?;
408    Ok(())
409}
410
411/// This trait `UtfWidth` is defined with a single
412/// method `utf_width` that returns the width of
413/// a string in Unicode code points.
414/// The implementation for `String` and `&str`
415/// types are provided. They calculate the
416/// number of graphemes.
417/// This method allows for easy calculation of
418/// the horizontal space required to display
419/// a text, which can be useful for layout purposes.
420pub trait UtfWidth {
421    /// Number of graphemes in the string.
422    /// Used to know the necessary width to print this text.
423    fn utf_width(&self) -> usize;
424    /// Number of graphemes in the string as a, u16.
425    /// Used to know the necessary width to print this text.
426    fn utf_width_u16(&self) -> u16;
427}
428
429impl UtfWidth for String {
430    fn utf_width(&self) -> usize {
431        self.as_str().utf_width()
432    }
433
434    fn utf_width_u16(&self) -> u16 {
435        self.utf_width() as u16
436    }
437}
438
439impl UtfWidth for &str {
440    fn utf_width(&self) -> usize {
441        self.graphemes(true)
442            .map(|s| s.to_string())
443            .collect::<Vec<String>>()
444            .len()
445    }
446
447    fn utf_width_u16(&self) -> u16 {
448        self.utf_width() as u16
449    }
450}
451
452/// Index of a character counted from letter 'a'.
453/// `None` if the character code-point is below 'a'.
454///
455/// # Examples
456///
457/// ```rust
458///  assert_eq!(index_from_a('a'), Some(0));
459///  assert_eq!(index_from_a('e'), Some(4));
460///  assert_eq!(index_from_a('T'), None);
461/// ```
462pub fn index_from_a(letter: char) -> Option<usize> {
463    (letter as usize).checked_sub('a' as usize)
464}
465
466/// A PathBuf of the current config folder.
467pub fn path_to_config_folder() -> Result<PathBuf> {
468    Ok(std::path::PathBuf::from_str(tilde(CONFIG_FOLDER).borrow())?)
469}
470
471fn home_dir() -> Option<PathBuf> {
472    std::env::var_os("HOME")
473        .and_then(|h| if h.is_empty() { None } else { Some(h) })
474        .map(PathBuf::from)
475}
476
477/// Expand ~/Downloads to /home/user/Downloads where user is the current user.
478/// Copied from <https://gitlab.com/ijackson/rust-shellexpand/-/blob/main/src/funcs.rs?ref_type=heads#L673>
479pub fn tilde(input_str: &str) -> Cow<'_, str> {
480    if let Some(input_after_tilde) = input_str.strip_prefix('~') {
481        if input_after_tilde.is_empty() || input_after_tilde.starts_with('/') {
482            if let Some(hd) = home_dir() {
483                let result = format!("{}{}", hd.display(), input_after_tilde);
484                result.into()
485            } else {
486                // home dir is not available
487                input_str.into()
488            }
489        } else {
490            // we cannot handle `~otheruser/` paths yet
491            input_str.into()
492        }
493    } else {
494        // input doesn't start with tilde
495        input_str.into()
496    }
497}
498
499/// Sets the current working directory environment
500pub fn set_current_dir<P: AsRef<Path>>(path: P) -> Result<()> {
501    Ok(env::set_current_dir(path.as_ref())?)
502}
503
504/// Update zoxide database.
505///
506/// Nothing is done if logging isn't enabled.
507///
508/// # Errors
509///
510/// May fail if zoxide command failed.
511pub fn update_zoxide<P: AsRef<Path>>(path: P) -> Result<()> {
512    let Some(is_logging) = IS_LOGGING.get() else {
513        return Ok(());
514    };
515    if *is_logging && is_in_path(ZOXIDE) {
516        execute_without_output(ZOXIDE, &["add", path.as_ref().to_string_lossy().as_ref()])?;
517    }
518    Ok(())
519}
520
521/// Append source filename to dest.
522pub fn build_dest_path(source: &Path, dest: &Path) -> Option<PathBuf> {
523    let mut dest = dest.to_path_buf();
524    let filename = source.file_name()?;
525    dest.push(filename);
526    Some(dest)
527}