1use 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 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(); 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")] #[test_case("-f README.md"; "script file with no files")]
252 #[test_case("--script-file README.md foo.txt"; "script file long")] #[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 #[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}