1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
use std::{
    fs::Permissions,
    os::unix::fs::PermissionsExt,
    path::{Path, PathBuf},
};

/// All errors triggered by the find script feature
#[derive(Debug, thiserror::Error)]
pub enum FindScriptError {
    /// Multiple scripts were found
    #[error("Multiple({1}) scripts found with name '{0}'")]
    MultipleFound(String, usize),
    /// List scripts error
    #[error(transparent)]
    ListScriptsError(#[from] ListScriptsError),
}

/// All errors triggered by the list scripts feature
#[derive(Debug, thiserror::Error)]
pub enum ListScriptsError {
    /// Path is not a directory
    #[error("Path '{0:?}' is not a directory")]
    NotADir(PathBuf),
    /// Error reading the path
    #[error("Error reading directory: {0}")]
    ReadDirFailure(std::io::Error),
}

/// Finds a script from its name and the root directory
///
/// Returns the path to the script if found
///
/// # Errors
///
/// - Error reading any sub-directory;
/// - Passed-in path is not a directory;
/// - More than one script was found;
pub fn find_script(root: &Path, name: String) -> Result<Option<PathBuf>, FindScriptError> {
    let all_scripts = list_scripts(root)?;
    let matching_scripts = all_scripts
        .into_iter()
        .filter(|(n, _)| *n == name)
        .collect::<Vec<_>>();
    match matching_scripts.as_slice() {
        [(_, path)] => Ok(Some(path.to_path_buf())),
        [] => Ok(None),
        multiple => Err(FindScriptError::MultipleFound(name, multiple.len())),
    }
}

/// Lists all available scripts in a directory and its sub-directories
///
/// Returns the name of the script and its full path
///
/// Transverse the passed-in directory and find all files that:
///   - Have the [`BASH_EXTENSION`] extension;
///   - Are executable;
///
///
/// # Notes
///
/// - Ignores files that fail to be read
///
/// # Errors
///
/// - Error reading any sub-directory;
/// - Passed-in path is not a directory;
pub fn list_scripts(root: &Path) -> Result<Vec<(String, PathBuf)>, ListScriptsError> {
    let mut found_scripts = Vec::new();
    let sub_dirs = root
        .read_dir()
        .map_err(ListScriptsError::ReadDirFailure)?
        .filter_map(|f| f.ok())
        .filter(|f| f.path().is_dir())
        .collect::<Vec<_>>();
    for sub_dir in sub_dirs {
        let sub_scripts = list_scripts(&sub_dir.path())?;
        found_scripts.extend(sub_scripts);
    }
    found_scripts.extend(list_scripts_at_directory(root)?);

    Ok(found_scripts)
}

/// Lists all scripts in the given directory
///
/// Ignores files that cannot be read
fn list_scripts_at_directory(dir: &Path) -> Result<Vec<(String, PathBuf)>, ListScriptsError> {
    if !dir.is_dir() {
        return Err(ListScriptsError::NotADir(dir.to_path_buf()));
    }
    let scripts = dir
        .read_dir()
        .map_err(ListScriptsError::ReadDirFailure)?
        .filter_map(|f| f.ok())
        .filter(|f| f.path().is_file())
        .filter(|f| {
            let Ok(m) = f.metadata() else {
                return false;
            };
            is_path_executable(m.permissions())
        })
        .filter(|f| {
            let Some(e) = f
                .path()
                .extension()
                .map(|e| e.to_string_lossy().to_string())
            else {
                return false;
            };
            e == super::BASH_EXTENSION
        })
        .filter_map(|f| {
            let name = f.path().file_stem()?.to_string_lossy().to_string();
            Some((name, f.path()))
        })
        .collect::<Vec<_>>();

    Ok(scripts)
}

/// Returns if the given path is executable
pub(crate) fn is_path_executable(permissions: Permissions) -> bool {
    permissions.mode() & 0o111 != 0
}