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