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