md_cli_test/
cmd.rs

1use std::fs;
2use std::path::{Component, Path, PathBuf};
3use std::sync::LazyLock;
4
5use regex::Regex;
6
7use crate::error::{self, TestError};
8
9pub fn split_command_parts(command_line: &str) -> Vec<&str> {
10    static REGEX: LazyLock<Regex> =
11        LazyLock::new(|| Regex::new(r##"r?#"(?:.|\n)*"#|r?"(?:[^"]+)"|\S+"##).expect("regex must be correct"));
12
13    REGEX
14        .find_iter(command_line)
15        .map(|found| {
16            found
17                .as_str()
18                .trim_start_matches("r#\"")
19                .trim_matches('#')
20                .trim_matches('"')
21        })
22        .collect()
23}
24
25#[derive(Debug)]
26pub enum Cmd {
27    Cd(PathBuf),
28    Ls(PathBuf),
29    Mkdir(Vec<PathBuf>),
30    Rm(Vec<PathBuf>),
31    Echo(String, Option<PathBuf>),
32    Cat(PathBuf, Option<PathBuf>),
33}
34
35pub enum CmdResponse {
36    Success,
37    ChangeDirTo(PathBuf),
38    Output(String),
39}
40
41impl Cmd {
42    pub fn parse(root_dir: impl AsRef<Path>, source: &str) -> Result<Self, Vec<&str>> {
43        let root_dir = root_dir.as_ref();
44        let parts = split_command_parts(source);
45
46        let cmd = match &parts[..] {
47            ["cd", path] => Self::Cd(checked_join(root_dir, path)),
48            ["ls", path] => Self::Ls(checked_join(root_dir, path)),
49            ["mkdir", pathes @ ..] => Self::Mkdir(pathes.iter().map(|path| checked_join(root_dir, path)).collect()),
50            ["rm", pathes @ ..] => Self::Rm(pathes.iter().map(|path| checked_join(root_dir, path)).collect()),
51            ["echo", text @ .., ">", path] => Self::Echo(text.to_vec().join(" "), Some(checked_join(root_dir, path))),
52            ["echo", text @ ..] => Self::Echo(text.to_vec().join(" "), None),
53            ["cat", from_path, ">", to_path] => {
54                Self::Cat(checked_join(root_dir, from_path), Some(checked_join(root_dir, to_path)))
55            },
56            ["cat", path] => Self::Cat(checked_join(root_dir, path), None),
57            _ => return Err(parts),
58        };
59        Ok(cmd)
60    }
61
62    pub fn run(self) -> error::Result<CmdResponse> {
63        match self {
64            Self::Cd(path) => cd(path),
65            Self::Ls(path) => ls(path),
66            Self::Mkdir(pathes) => mkdir(pathes),
67            Self::Rm(pathes) => rm(pathes),
68            Self::Echo(text, path) => echo(text, path),
69            Self::Cat(from, to) => cat(from, to),
70        }
71    }
72}
73
74fn checked_join(root: impl AsRef<Path>, subpath: impl AsRef<Path>) -> PathBuf {
75    let root = root.as_ref();
76    let path = normalize_path(root.join(subpath));
77
78    if path.starts_with(root) {
79        path
80    } else {
81        panic!("Path `{}` is not a subpath of `{}`", path.display(), root.display())
82    }
83}
84
85/// Normalize a path, removing things like `.` and `..`. This does not resolve symlinks (unlike
86/// `std::fs::canonicalize`) and does not checking that path exists.
87///
88/// Taken from:
89/// https://github.com/rust-lang/cargo/blob/e4162389d67c603d25ba6e25b0e9423fcb8daa64/crates/cargo-util/src/paths.rs#L84
90fn normalize_path(path: impl AsRef<Path>) -> PathBuf {
91    let mut components = path.as_ref().components().peekable();
92    let mut normalized = if let Some(comp @ Component::Prefix(..)) = components.peek().cloned() {
93        components.next();
94        PathBuf::from(comp.as_os_str())
95    } else {
96        PathBuf::new()
97    };
98
99    for component in components {
100        match component {
101            Component::Prefix(..) => unreachable!(),
102            Component::RootDir => {
103                normalized.push(Component::RootDir);
104            },
105            Component::CurDir => {},
106            Component::ParentDir => {
107                if normalized.ends_with(Component::ParentDir) {
108                    normalized.push(Component::ParentDir);
109                } else {
110                    let popped = normalized.pop();
111                    if !popped && !normalized.has_root() {
112                        normalized.push(Component::ParentDir);
113                    }
114                }
115            },
116            Component::Normal(chunk) => {
117                normalized.push(chunk);
118            },
119        }
120    }
121    normalized
122}
123
124fn cd(path: PathBuf) -> error::Result<CmdResponse> {
125    if path.is_dir() {
126        Ok(CmdResponse::ChangeDirTo(path))
127    } else {
128        Err(TestError::Command(format!("Path `{}` is not dir", path.display())))
129    }
130}
131
132fn ls(path: PathBuf) -> error::Result<CmdResponse> {
133    let mut entries = Vec::new();
134
135    for entry in fs::read_dir(&path)? {
136        let entry_path = entry?.path();
137        let entry = entry_path
138            .strip_prefix(&path)
139            .map_err(|_| {
140                TestError::Command(format!(
141                    "Could not strip prefix {} for path: {}",
142                    path.display(),
143                    entry_path.display()
144                ))
145            })?
146            .display()
147            .to_string();
148        entries.push(entry);
149    }
150
151    entries.sort();
152
153    let mut output = entries.join(" ");
154    output.push('\n');
155
156    Ok(CmdResponse::Output(output))
157}
158
159fn mkdir(pathes: Vec<PathBuf>) -> error::Result<CmdResponse> {
160    for path in pathes {
161        fs::create_dir_all(&path)
162            .map_err(|err| TestError::Command(format!("Failed to create directory `{}`: {err}", path.display())))?;
163    }
164    Ok(CmdResponse::Success)
165}
166
167fn rm(pathes: Vec<PathBuf>) -> error::Result<CmdResponse> {
168    for path in pathes {
169        if path.is_dir() {
170            fs::remove_dir_all(&path)
171                .map_err(|err| TestError::Command(format!("Failed to remove directory `{}`: {err}", path.display())))?;
172        } else {
173            fs::remove_file(&path)
174                .map_err(|err| TestError::Command(format!("Failed to remove file `{}`: {err}", path.display())))?;
175        }
176    }
177    Ok(CmdResponse::Success)
178}
179
180fn echo(text: String, path: Option<PathBuf>) -> error::Result<CmdResponse> {
181    if let Some(path) = path {
182        fs::write(&path, text)
183            .map_err(|err| TestError::Command(format!("Failed to write file `{}`: {err}", path.display())))?;
184        Ok(CmdResponse::Success)
185    } else {
186        Ok(CmdResponse::Output(text))
187    }
188}
189
190fn cat(from_path: PathBuf, to_path: Option<PathBuf>) -> error::Result<CmdResponse> {
191    let content = fs::read_to_string(&from_path)
192        .map_err(|err| TestError::Command(format!("Failed to read file `{}`: {err}", from_path.display())))?;
193    echo(content, to_path)
194}