1use std::ffi::OsString;
24use std::path::PathBuf;
25
26use crate::config::PanelSide;
27
28fn absolutize(path: PathBuf) -> PathBuf {
37 std::path::absolute(&path).unwrap_or(path)
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum WidthArg {
43 To(u32),
45 By(i32),
47}
48
49impl WidthArg {
50 fn parse(value: &str) -> Option<Self> {
53 let value = value.trim();
54 if value.starts_with(['+', '-']) {
55 value.parse::<i32>().ok().map(Self::By)
56 } else {
57 value.parse::<u32>().ok().map(Self::To)
58 }
59 }
60
61 fn encode(self) -> String {
62 match self {
63 Self::To(px) => px.to_string(),
64 Self::By(delta) if delta >= 0 => format!("+{delta}"),
65 Self::By(delta) => delta.to_string(),
66 }
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
72pub struct Command {
73 pub side: Option<PanelSide>,
75 pub roots: Vec<PathBuf>,
77 pub reveal: Vec<PathBuf>,
81 pub hidden: bool,
85 pub width: Option<WidthArg>,
88 pub key: Option<String>,
92}
93
94impl Command {
95 pub fn parse<I, S>(args: I) -> Self
100 where
101 I: IntoIterator<Item = S>,
102 S: Into<OsString>,
103 {
104 let mut side = None;
105 let mut roots: Vec<PathBuf> = Vec::new();
106 let mut reveal: Vec<PathBuf> = Vec::new();
107 let mut hidden = false;
108 let mut width = None;
109 let mut key = None;
110 let mut args = args.into_iter().map(Into::into);
111 while let Some(arg) = args.next() {
112 let text = arg.to_string_lossy();
113 let mut split = text.splitn(2, '=');
114 let (flag, inline_value) = (split.next().unwrap_or(""), split.next());
115 match flag {
116 "--side" | "-s" => {
117 let value = match inline_value {
118 Some(v) => Some(v.to_string()),
119 None => args.next().map(|v| v.to_string_lossy().into_owned()),
120 };
121 match value.as_deref() {
122 Some("left") => side = Some(PanelSide::Left),
123 Some("right") => side = Some(PanelSide::Right),
124 _ => {} }
126 }
127 "--hidden" | "--hide" | "-H" => hidden = true,
128 "--width" | "-w" => {
129 let value = match inline_value {
130 Some(v) => Some(v.to_string()),
131 None => args.next().map(|v| v.to_string_lossy().into_owned()),
132 };
133 if let Some(parsed) = value.as_deref().and_then(WidthArg::parse) {
134 width = Some(parsed);
135 }
136 }
137 "--key" | "-k" => {
138 let value = match inline_value {
141 Some(v) => Some(v.to_string()),
142 None => args.next().map(|v| v.to_string_lossy().into_owned()),
143 };
144 if let Some(value) = value
145 && !value.trim().is_empty()
146 {
147 key = Some(value.trim().to_owned());
148 }
149 }
150 "--select" => {
151 let value = match inline_value {
154 Some(v) => Some(v.to_string()),
155 None => args.next().map(|v| v.to_string_lossy().into_owned()),
156 };
157 if let Some(value) = value
158 && !value.is_empty()
159 {
160 reveal.push(absolutize(PathBuf::from(value)));
161 }
162 }
163 _ => {
164 if arg.to_str().is_some_and(|s| s.starts_with('-')) {
165 continue;
166 }
167 let path = absolutize(PathBuf::from(&arg));
168 if path.is_dir() {
169 roots.push(path);
170 } else {
171 reveal.push(path);
174 }
175 }
176 }
177 }
178 Command { side, roots, reveal, hidden, width, key }
179 }
180
181 pub fn encode(&self) -> String {
183 let mut parts: Vec<String> = Vec::new();
184 match self.side {
185 Some(PanelSide::Left) => parts.push("side=left".to_owned()),
186 Some(PanelSide::Right) => parts.push("side=right".to_owned()),
187 None => {}
188 }
189 for root in &self.roots {
190 parts.push(format!("root={}", root.to_string_lossy()));
191 }
192 for path in &self.reveal {
193 parts.push(format!("select={}", path.to_string_lossy()));
194 }
195 if self.hidden {
196 parts.push("hidden=1".to_owned());
197 }
198 if let Some(width) = self.width {
199 parts.push(format!("width={}", width.encode()));
200 }
201 if let Some(key) = &self.key {
202 parts.push(format!("key={key}"));
203 }
204 parts.join("\0")
205 }
206
207 pub fn decode(line: &str) -> Option<Command> {
210 let mut side = None;
211 let mut roots: Vec<PathBuf> = Vec::new();
212 let mut reveal: Vec<PathBuf> = Vec::new();
213 let mut hidden = false;
214 let mut width = None;
215 let mut key = None;
216 for token in line.split('\0') {
217 if token.is_empty() {
218 continue;
219 }
220 let (name, value) = token.split_once('=')?;
221 match name {
222 "side" => match value {
223 "left" => side = Some(PanelSide::Left),
224 "right" => side = Some(PanelSide::Right),
225 _ => return None,
226 },
227 "root" => roots.push(PathBuf::from(value)),
228 "select" => reveal.push(PathBuf::from(value)),
229 "hidden" => hidden = value == "1" || value.eq_ignore_ascii_case("true"),
230 "width" => width = Some(WidthArg::parse(value)?),
231 "key" => key = Some(value.to_owned()),
232 _ => return None,
233 }
234 }
235 Some(Command { side, roots, reveal, hidden, width, key })
236 }
237
238 pub fn is_toggle(&self) -> bool {
241 self.roots.is_empty()
242 && self.reveal.is_empty()
243 && self.side.is_none()
244 && !self.hidden
245 && self.width.is_none()
246 && self.key.is_none()
247 }
248
249 pub fn reveal_targets(&self) -> Vec<(PathBuf, Option<PathBuf>)> {
255 self.reveal
256 .iter()
257 .filter_map(|path| {
258 if path.is_dir() {
259 Some((path.clone(), None))
260 } else {
261 let parent = path.parent().filter(|p| !p.as_os_str().is_empty())?;
262 Some((parent.to_path_buf(), Some(path.clone())))
263 }
264 })
265 .collect()
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 fn parse_str(args: &[&str]) -> Command {
274 Command::parse(args.iter().map(|s| OsString::from(*s)))
275 }
276
277 #[test]
278 fn no_args_is_a_toggle() {
279 let cmd = parse_str(&[]);
280 assert!(cmd.side.is_none());
281 assert!(cmd.is_toggle());
282 }
283
284 #[test]
285 fn side_flag_parses_both_inline_and_adjacent() {
286 assert_eq!(parse_str(&["--side", "right"]).side, Some(PanelSide::Right));
287 assert_eq!(parse_str(&["--side=left"]).side, Some(PanelSide::Left));
288 assert_eq!(parse_str(&["-s", "right"]).side, Some(PanelSide::Right));
289 assert_eq!(parse_str(&["-s=left"]).side, Some(PanelSide::Left));
290 }
291
292 #[test]
293 fn invalid_side_value_is_ignored() {
294 assert!(parse_str(&["--side", "up"]).side.is_none());
295 assert!(parse_str(&["--side=diagonal"]).side.is_none());
296 }
297
298 #[test]
299 fn directories_become_roots_and_flags_are_skipped() {
300 let cmd = parse_str(&["--side", "left", "/etc", "--bogus", "/usr"]);
301 assert_eq!(cmd.side, Some(PanelSide::Left));
302 assert_eq!(cmd.roots, vec![PathBuf::from("/etc"), PathBuf::from("/usr")]);
303 }
304
305 #[test]
306 fn non_directory_arguments_become_reveals() {
307 let cmd = parse_str(&["/definitely/not/a/dir"]);
309 assert!(cmd.roots.is_empty());
310 assert_eq!(cmd.reveal, vec![PathBuf::from("/definitely/not/a/dir")]);
311 assert!(!cmd.is_toggle());
312 }
313
314 #[test]
315 fn relative_paths_are_made_absolute() {
316 let cmd = parse_str(&["."]);
319 assert_eq!(cmd.roots.len(), 1, "`.` should resolve to an existing dir");
320 assert!(cmd.roots[0].is_absolute(), "{:?} should be absolute", cmd.roots[0]);
321
322 let cmd = parse_str(&["--select", "."]);
325 assert!(cmd.reveal[0].is_absolute(), "{:?} should be absolute", cmd.reveal[0]);
326 }
327
328 #[test]
329 fn select_flag_parses_adjacent_inline_and_repeated() {
330 assert_eq!(
331 parse_str(&["--select", "/tmp/f.txt"]).reveal,
332 vec![PathBuf::from("/tmp/f.txt")]
333 );
334 assert_eq!(parse_str(&["--select=/tmp/f.txt"]).reveal, vec![PathBuf::from("/tmp/f.txt")]);
335 let two = parse_str(&["--select", "/a", "--select", "/b"]);
336 assert_eq!(two.reveal, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
337 assert!(parse_str(&["--select"]).reveal.is_empty());
339 }
340
341 #[test]
342 fn encode_decode_reveal() {
343 let cmd = Command {
344 reveal: vec![PathBuf::from("/home/eolu/notes.md")],
345 ..Command::default()
346 };
347 assert_eq!(cmd.encode(), "select=/home/eolu/notes.md");
348 assert_eq!(Command::decode(&cmd.encode()), Some(cmd));
349 }
350
351 #[test]
352 fn reveal_targets_root_at_the_parent_for_files() {
353 let dir = tempfile::tempdir().unwrap();
354 let file = dir.path().join("notes.md");
355 std::fs::write(&file, "").unwrap();
356 let sub = dir.path().join("photos");
357 std::fs::create_dir(&sub).unwrap();
358
359 let cmd = Command {
360 reveal: vec![file.clone(), sub.clone()],
361 ..Command::default()
362 };
363 let targets = cmd.reveal_targets();
364 assert_eq!(targets[0], (dir.path().to_path_buf(), Some(file)));
366 assert_eq!(targets[1], (sub, None));
368 }
369
370 #[test]
371 fn reveal_targets_skip_rootless_files() {
372 let cmd = Command {
375 reveal: vec![PathBuf::from("notes.md")],
376 ..Command::default()
377 };
378 assert!(cmd.reveal_targets().is_empty());
379 }
380
381 #[test]
382 fn hidden_flag_parses() {
383 assert!(parse_str(&["--hidden"]).hidden);
384 assert!(parse_str(&["--hide"]).hidden);
385 assert!(parse_str(&["-H"]).hidden);
386 assert!(!parse_str(&[]).hidden);
387 let cmd = parse_str(&["--hidden", "--side", "right", "/etc"]);
389 assert!(cmd.hidden);
390 assert_eq!(cmd.side, Some(PanelSide::Right));
391 assert_eq!(cmd.roots, vec![PathBuf::from("/etc")]);
392 assert!(!cmd.is_toggle());
393 }
394
395 #[test]
396 fn encode_decode_round_trips() {
397 let cmd = Command {
398 side: Some(PanelSide::Right),
399 roots: vec![PathBuf::from("/home/eolu/Projects"), PathBuf::from("/tmp")],
400 reveal: vec![PathBuf::from("/home/eolu/some file.txt")],
401 hidden: false,
402 width: None,
403 key: None,
404 };
405 assert_eq!(Command::decode(&cmd.encode()), Some(cmd));
406 }
407
408 #[test]
409 fn key_flag_parses_and_is_never_a_toggle() {
410 assert_eq!(parse_str(&["--key", "Ctrl+c"]).key, Some("Ctrl+c".to_owned()));
411 assert_eq!(parse_str(&["--key=F2"]).key, Some("F2".to_owned()));
412 assert_eq!(parse_str(&["-k", "Alt+Left"]).key, Some("Alt+Left".to_owned()));
413 assert_eq!(parse_str(&["--key", " Ctrl+t "]).key, Some("Ctrl+t".to_owned()));
414 assert!(parse_str(&["--key"]).key.is_none());
416 assert!(!parse_str(&["--key", "Ctrl+c"]).is_toggle());
417 }
418
419 #[test]
420 fn encode_decode_key() {
421 let cmd = Command { key: Some("Ctrl+Shift+m".to_owned()), ..Command::default() };
422 assert_eq!(cmd.encode(), "key=Ctrl+Shift+m");
423 assert_eq!(Command::decode(&cmd.encode()), Some(cmd));
424 }
425
426 #[test]
427 fn encode_decode_hidden() {
428 let cmd = Command { hidden: true, ..Command::default() };
429 assert_eq!(Command::decode(&cmd.encode()), Some(cmd.clone()));
430 assert_eq!(Command::decode("hidden=1"), Some(cmd));
431 }
432
433 #[test]
434 fn encode_decode_toggle_and_empty() {
435 let cmd = Command::default();
436 assert_eq!(Command::decode(&cmd.encode()), Some(Command::default()));
437 assert_eq!(Command::decode(""), Some(Command::default()));
438 assert_eq!(Command::decode(" "), None);
439 }
440
441 #[test]
442 fn decode_rejects_unknown_tokens() {
443 assert_eq!(Command::decode("bogus=x"), None);
444 assert_eq!(Command::decode("side=up"), None);
445 }
446
447 #[test]
448 fn decode_keeps_paths_with_spaces() {
449 let cmd = Command {
450 side: None,
451 roots: vec![PathBuf::from("/home/eolu/My Projects")],
452 hidden: false,
453 width: None,
454 ..Command::default()
455 };
456 assert_eq!(Command::decode(&cmd.encode()), Some(cmd));
457 }
458
459 #[test]
460 fn width_flag_parses_absolute_and_relative() {
461 assert_eq!(parse_str(&["--width", "420"]).width, Some(WidthArg::To(420)));
462 assert_eq!(parse_str(&["--width=420"]).width, Some(WidthArg::To(420)));
463 assert_eq!(parse_str(&["-w", "+40"]).width, Some(WidthArg::By(40)));
464 assert_eq!(parse_str(&["--width", "-40"]).width, Some(WidthArg::By(-40)));
465 assert_eq!(parse_str(&["--width=garbage"]).width, None);
466 assert!(parse_str(&["--width", "420"]).width.is_some());
467 assert!(!parse_str(&["--width", "+40"]).is_toggle());
469 }
470
471 #[test]
472 fn encode_decode_width() {
473 let grow = Command { width: Some(WidthArg::By(40)), ..Command::default() };
474 assert_eq!(grow.encode(), "width=+40");
475 assert_eq!(Command::decode(&grow.encode()), Some(grow));
476
477 let shrink = Command { width: Some(WidthArg::By(-40)), ..Command::default() };
478 assert_eq!(shrink.encode(), "width=-40");
479 assert_eq!(Command::decode(&shrink.encode()), Some(shrink));
480
481 let exact = Command { width: Some(WidthArg::To(420)), ..Command::default() };
482 assert_eq!(exact.encode(), "width=420");
483 assert_eq!(Command::decode(&exact.encode()), Some(exact));
484
485 assert_eq!(Command::decode("width=+oops"), None);
486 }
487}