ad_editor/
cli.rs

1//! CLI parser
2//! See main.rs for the usage of the parsed arguments
3use lexopt::{Parser, prelude::*};
4use std::{fs, path::PathBuf};
5
6pub const USAGE: &str = "\
7usage: ad [options] [file ...]     Edit file(s)
8
9options:
10  -e, --expression <script>        Execute edit script on file(s)
11  -f, --script-file <script-file>  Execute edit script loaded from a script-file on file(s)
12  -9p [-A aname] read [path]       Read the contents of a file on a 9p file server
13  -9p [-A aname] write [path]      Write the contents of stdin to a file on a 9p file server
14  -9p [-A aname] ls [path]         List the contents of a directory on a 9p file server
15  -l, --list-sessions              List the current open editor 9p sessions
16  --rm-sockets                     Remove all unresponsive ad 9p sockets from the default namespace directory
17  -c, --config <path>              Load config from the specified path
18  --default-config                 Force default config instead of loading the user config file
19  -h, --help                       Print this help message
20  -v, --version                    Print version information
21";
22
23#[derive(Debug)]
24pub enum CliAction {
25    OpenEditor {
26        files: Vec<PathBuf>,
27    },
28    RunScript {
29        script: String,
30        files: Vec<PathBuf>,
31    },
32    NineP {
33        aname: String,
34        cmd: Cmd9p,
35        path: String,
36    },
37    ListSessions,
38    RmSockets,
39    ShowHelp,
40    ShowVersion,
41}
42
43#[derive(Debug)]
44pub enum Cmd9p {
45    Read,
46    Write,
47    List,
48}
49
50#[derive(Debug)]
51pub struct ParsedArgs {
52    pub action: CliAction,
53    pub config_source: ConfigSource,
54}
55
56#[derive(Debug)]
57pub enum ConfigSource {
58    Default,
59    User,
60    Custom(PathBuf),
61}
62
63impl ParsedArgs {
64    pub fn try_parse() -> Result<Self, String> {
65        let mut parser = Parser::from_env();
66
67        Self::try_from_parser(&mut parser).map_err(|e| e.to_string())
68    }
69
70    fn try_from_parser(parser: &mut Parser) -> Result<Self, lexopt::Error> {
71        let mut action: Option<CliAction> = None;
72        let mut config_source = ConfigSource::User;
73        let mut config_set = false;
74
75        loop {
76            // If we've already parsed a valid action and there are arguments remaining then the
77            // command line as a whole is invalid.
78            if action.is_some()
79                && let Some(raw_args) = parser.try_raw_args()
80            {
81                match raw_args.peek() {
82                    Some(arg) => return Err(lexopt::Error::UnexpectedArgument(arg.to_os_string())),
83                    None => break,
84                }
85            }
86
87            if let Some(true) = is_9p_option(parser) {
88                action = Some(parse_9p(parser)?);
89                continue;
90            }
91
92            match parser.next()? {
93                Some(arg) => match arg {
94                    Short('e') | Long("expression") => {
95                        let script = parser
96                            .value()?
97                            .into_string()
98                            .map_err(lexopt::Error::NonUnicodeValue)?;
99                        let files: Vec<PathBuf> = match parser.values() {
100                            Ok(vals) => vals.map(PathBuf::from).collect(),
101                            Err(_) => Vec::new(),
102                        };
103                        action = Some(CliAction::RunScript { script, files });
104                    }
105
106                    Short('f') | Long("script-file") => {
107                        let fname = parser.value()?;
108                        match fs::read_to_string(&fname) {
109                            Ok(script) => {
110                                let files: Vec<PathBuf> = match parser.values() {
111                                    Ok(vals) => vals.map(PathBuf::from).collect(),
112                                    Err(_) => Vec::new(),
113                                };
114                                action = Some(CliAction::RunScript { script, files });
115                            }
116
117                            Err(e) => {
118                                return Err(lexopt::Error::from(format!(
119                                    "unable to load script file from {}: {e}",
120                                    fname.to_string_lossy()
121                                )));
122                            }
123                        }
124                    }
125
126                    Short('l') | Long("list-sessions") => action = Some(CliAction::ListSessions),
127                    Short('v') | Long("version") => action = Some(CliAction::ShowVersion),
128                    Short('h') | Long("help") => action = Some(CliAction::ShowHelp),
129                    Long("rm-sockets") => action = Some(CliAction::RmSockets),
130
131                    Short('c') | Long("config") => {
132                        if config_set {
133                            return Err(lexopt::Error::from("config source already specified"));
134                        }
135                        let path = PathBuf::from(parser.value()?);
136                        if !path.exists() {
137                            return Err(lexopt::Error::from(format!(
138                                "config path does not exist: {}",
139                                path.display()
140                            )));
141                        } else if !path.is_file() {
142                            return Err(lexopt::Error::from(format!(
143                                "config path is not a file: {}",
144                                path.display()
145                            )));
146                        }
147                        config_source = ConfigSource::Custom(path);
148                        config_set = true;
149                    }
150
151                    Long("default-config") => {
152                        if config_set {
153                            return Err(lexopt::Error::from("config source already specified"));
154                        }
155                        config_source = ConfigSource::Default;
156                        config_set = true;
157                    }
158
159                    Value(fname) if action.is_none() => {
160                        let files: Vec<PathBuf> = match parser.values() {
161                            Ok(vals) => std::iter::once(fname)
162                                .chain(vals)
163                                .map(PathBuf::from)
164                                .collect(),
165                            Err(_) => vec![PathBuf::from(fname)],
166                        };
167
168                        action = Some(CliAction::OpenEditor { files });
169                    }
170
171                    _ => return Err(arg.unexpected()),
172                },
173
174                None => break,
175            }
176        }
177
178        Ok(ParsedArgs {
179            action: action.unwrap_or_else(|| CliAction::OpenEditor { files: Vec::new() }),
180            config_source,
181        })
182    }
183}
184
185fn is_9p_option(parser: &mut Parser) -> Option<bool> {
186    let mut raw = parser.try_raw_args()?;
187    let arg = raw.peek()?.to_str()?;
188
189    if arg == "-9p" {
190        raw.next(); // consume the -9p arg
191        Some(true)
192    } else {
193        Some(false)
194    }
195}
196
197fn parse_9p(parser: &mut Parser) -> Result<CliAction, lexopt::Error> {
198    let arg = parser.next()?.ok_or(lexopt::Error::MissingValue {
199        option: Some("9p".into()),
200    })?;
201
202    let mut aname = String::new();
203
204    let next = match arg {
205        Short('A') => {
206            aname = parser
207                .value()?
208                .into_string()
209                .map_err(lexopt::Error::NonUnicodeValue)?;
210            parser.next()?
211        }
212        Value(val) => Some(Value(val)),
213        _ => return Err(arg.unexpected()),
214    };
215
216    let cmd = match next {
217        Some(arg) => match arg {
218            Value(cmd) => match cmd.to_str() {
219                Some("read") => Cmd9p::Read,
220                Some("write") => Cmd9p::Write,
221                Some("ls") => Cmd9p::List,
222                _ => return Err(Value(cmd).unexpected()),
223            },
224            _ => return Err(arg.unexpected()),
225        },
226        None => return Err(lexopt::Error::from("no command provided for -9p")),
227    };
228
229    let path = match parser.next()? {
230        Some(Value(s)) => s.into_string().map_err(lexopt::Error::NonUnicodeValue)?,
231        Some(arg) => return Err(arg.unexpected()),
232        None => return Err(lexopt::Error::from("no path provided for -9p")),
233    };
234
235    Ok(CliAction::NineP { aname, cmd, path })
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use simple_test_case::test_case;
242
243    #[test_case(""; "no args at all")]
244    #[test_case("foo.txt"; "single file")]
245    #[test_case("foo.txt bar.json"; "multiple files")]
246    #[test_case("-e 'script' foo.txt"; "edit script")]
247    #[test_case("-e 'script'"; "edit script with no files")]
248    #[test_case("--expression 'script' foo.txt"; "edit script long")]
249    #[test_case("--expression 'script'"; "edit script with no files long")]
250    #[test_case("-f README.md foo.txt"; "script file")] // needs to be a real file
251    #[test_case("-f README.md"; "script file with no files")]
252    #[test_case("--script-file README.md foo.txt"; "script file long")] // needs to be a real file
253    #[test_case("--script-file README.md"; "script file with no files long")]
254    #[test_case("-9p read ad/buffers/index"; "9p read")]
255    #[test_case("-9p write ad/buffers/1/dot"; "9p write")]
256    #[test_case("-9p ls ad/buffers"; "9p ls")]
257    #[test_case("-9p -A foo read ad/buffers/index"; "9p read with aname")]
258    #[test_case("-9p -A foo write ad/buffers/1/dot"; "9p write with aname")]
259    #[test_case("-9p -A foo ls ad/buffers"; "9p ls with aname")]
260    #[test_case("-h"; "short help")]
261    #[test_case("--help"; "long help")]
262    #[test_case("-v"; "short version")]
263    #[test_case("--version"; "long version")]
264    #[test_case("-c README.md"; "config short")]
265    #[test_case("--config README.md"; "config long")]
266    #[test_case("--default-config"; "default config")]
267    #[test_case("-c README.md foo.txt"; "config with file")]
268    #[test_case("--default-config foo.txt"; "default config with file")]
269    #[test]
270    fn valid_args(cmd_line: &str) {
271        let it = cmd_line.split_whitespace().map(|s| s.to_string());
272        let mut parser = Parser::from_args(it);
273        let res = ParsedArgs::try_from_parser(&mut parser);
274
275        assert!(res.is_ok(), "{res:?}");
276    }
277
278    // actually invalid argument cases
279    #[test_case("-e"; "edit script with no script")]
280    #[test_case("--expression"; "edit script with no script long")]
281    #[test_case("-f"; "script file with no script file")]
282    #[test_case("--script-file"; "script file with no script file long")]
283    #[test_case("-f foo.txt"; "script file with unknown script file")]
284    #[test_case("--script-file foo.txt"; "script file with unknown script file long")]
285    #[test_case("-c"; "config with no path")]
286    #[test_case("--config"; "config with no path long")]
287    #[test_case("-c nonexistent.toml"; "config with nonexistent path")]
288    #[test_case("--config nonexistent.toml"; "config with nonexistent path long")]
289    #[test_case("--default-config -c README.md"; "both default and custom config")]
290    #[test_case("-c README.md --default-config"; "both custom and default config")]
291    #[test_case("-c README.md -c README.md"; "duplicate config flag")]
292    #[test]
293    fn invalid_args(cmd_line: &str) {
294        let it = cmd_line.split_whitespace().map(|s| s.to_string());
295        let mut parser = Parser::from_args(it);
296        let res = ParsedArgs::try_from_parser(&mut parser);
297
298        assert!(res.is_err(), "{res:?}");
299    }
300}