1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use ratatui::widgets::ListState;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum BrowserSort {
10 Name,
11 Date,
12 DateAsc,
13}
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct FileEntry {
18 pub name: String,
19 pub is_dir: bool,
20 pub size: Option<u64>,
21 pub modified: Option<i64>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BrowserPane {
28 Local,
29 Remote,
30}
31
32pub struct CopyRequest {
34 pub sources: Vec<String>,
35 pub source_pane: BrowserPane,
36 pub has_dirs: bool,
37}
38
39pub struct FileBrowserState {
41 pub alias: String,
42 pub askpass: Option<String>,
43 pub active_pane: BrowserPane,
44 pub local_path: PathBuf,
46 pub local_entries: Vec<FileEntry>,
47 pub local_list_state: ListState,
48 pub local_selected: HashSet<String>,
49 pub local_error: Option<String>,
50 pub remote_path: String,
52 pub remote_entries: Vec<FileEntry>,
53 pub remote_list_state: ListState,
54 pub remote_selected: HashSet<String>,
55 pub remote_error: Option<String>,
56 pub remote_loading: bool,
57 pub show_hidden: bool,
59 pub sort: BrowserSort,
60 pub confirm_copy: Option<CopyRequest>,
62 pub transferring: Option<String>,
64 pub transfer_error: Option<String>,
66 pub connection_recorded: bool,
68}
69
70pub fn list_local(
73 path: &Path,
74 show_hidden: bool,
75 sort: BrowserSort,
76) -> anyhow::Result<Vec<FileEntry>> {
77 let mut entries = Vec::new();
78 for entry in std::fs::read_dir(path)? {
79 let entry = entry?;
80 let name = entry.file_name().to_string_lossy().to_string();
81 if !show_hidden && name.starts_with('.') {
82 continue;
83 }
84 let metadata = entry.metadata()?;
85 let is_dir = metadata.is_dir();
86 let size = if is_dir { None } else { Some(metadata.len()) };
87 let modified = metadata.modified().ok().and_then(|t| {
88 t.duration_since(std::time::UNIX_EPOCH)
89 .ok()
90 .map(|d| d.as_secs() as i64)
91 });
92 entries.push(FileEntry {
93 name,
94 is_dir,
95 size,
96 modified,
97 });
98 }
99 sort_entries(&mut entries, sort);
100 Ok(entries)
101}
102
103pub fn sort_entries(entries: &mut [FileEntry], sort: BrowserSort) {
105 match sort {
106 BrowserSort::Name => {
107 entries.sort_by(|a, b| {
108 b.is_dir.cmp(&a.is_dir).then_with(|| {
109 a.name
110 .to_ascii_lowercase()
111 .cmp(&b.name.to_ascii_lowercase())
112 })
113 });
114 }
115 BrowserSort::Date => {
116 entries.sort_by(|a, b| {
117 b.is_dir.cmp(&a.is_dir).then_with(|| {
118 b.modified.unwrap_or(0).cmp(&a.modified.unwrap_or(0))
120 })
121 });
122 }
123 BrowserSort::DateAsc => {
124 entries.sort_by(|a, b| {
125 b.is_dir.cmp(&a.is_dir).then_with(|| {
126 a.modified
128 .unwrap_or(i64::MAX)
129 .cmp(&b.modified.unwrap_or(i64::MAX))
130 })
131 });
132 }
133 }
134}
135
136pub fn parse_ls_output(output: &str, show_hidden: bool, sort: BrowserSort) -> Vec<FileEntry> {
141 let mut entries = Vec::new();
142 for line in output.lines() {
143 let line = line.trim();
144 if line.is_empty() || line.starts_with("total ") {
145 continue;
146 }
147 let mut parts: Vec<&str> = Vec::with_capacity(9);
150 let mut rest = line;
151 for _ in 0..8 {
152 rest = rest.trim_start();
153 if rest.is_empty() {
154 break;
155 }
156 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
157 parts.push(&rest[..end]);
158 rest = &rest[end..];
159 }
160 rest = rest.trim_start();
161 if !rest.is_empty() {
162 parts.push(rest);
163 }
164 if parts.len() < 9 {
165 continue;
166 }
167 let permissions = parts[0];
168 let is_dir = permissions.starts_with('d');
169 let name = parts[8];
170 if name.is_empty() {
172 continue;
173 }
174 if !show_hidden && name.starts_with('.') {
175 continue;
176 }
177 let size = if is_dir {
179 None
180 } else {
181 Some(parse_human_size(parts[4]))
182 };
183 let modified = parse_ls_date(parts[5], parts[6], parts[7]);
185 entries.push(FileEntry {
186 name: name.to_string(),
187 is_dir,
188 size,
189 modified,
190 });
191 }
192 sort_entries(&mut entries, sort);
193 entries
194}
195
196fn parse_human_size(s: &str) -> u64 {
198 let s = s.trim();
199 if s.is_empty() {
200 return 0;
201 }
202 let last = s.as_bytes()[s.len() - 1];
203 let multiplier = match last {
204 b'K' => 1024,
205 b'M' => 1024 * 1024,
206 b'G' => 1024 * 1024 * 1024,
207 b'T' => 1024u64 * 1024 * 1024 * 1024,
208 _ => 1,
209 };
210 let num_str = if multiplier > 1 { &s[..s.len() - 1] } else { s };
211 let num: f64 = num_str.parse().unwrap_or(0.0);
212 (num * multiplier as f64) as u64
213}
214
215fn parse_ls_date(month_str: &str, day_str: &str, time_or_year: &str) -> Option<i64> {
220 let month = match month_str {
221 "Jan" => 0,
222 "Feb" => 1,
223 "Mar" => 2,
224 "Apr" => 3,
225 "May" => 4,
226 "Jun" => 5,
227 "Jul" => 6,
228 "Aug" => 7,
229 "Sep" => 8,
230 "Oct" => 9,
231 "Nov" => 10,
232 "Dec" => 11,
233 _ => return None,
234 };
235 let day: i64 = day_str.parse().ok()?;
236 if !(1..=31).contains(&day) {
237 return None;
238 }
239
240 let now = std::time::SystemTime::now()
241 .duration_since(std::time::UNIX_EPOCH)
242 .unwrap_or_default()
243 .as_secs() as i64;
244 let now_year = epoch_to_year(now);
245
246 if time_or_year.contains(':') {
247 let mut parts = time_or_year.splitn(2, ':');
249 let hour: i64 = parts.next()?.parse().ok()?;
250 let min: i64 = parts.next()?.parse().ok()?;
251 let mut year = now_year;
253 let approx = approximate_epoch(year, month, day, hour, min);
254 if approx > now + 86400 {
255 year -= 1;
256 }
257 Some(approximate_epoch(year, month, day, hour, min))
258 } else {
259 let year: i64 = time_or_year.parse().ok()?;
261 if !(1970..=2100).contains(&year) {
262 return None;
263 }
264 Some(approximate_epoch(year, month, day, 0, 0))
265 }
266}
267
268fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
270 let y = year - 1970;
272 let mut days = y * 365 + (y + 1) / 4; let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
275 days += month_days[month as usize];
276 if month > 1 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
278 days += 1;
279 }
280 days += day - 1;
281 days * 86400 + hour * 3600 + min * 60
282}
283
284fn epoch_to_year(ts: i64) -> i64 {
286 let mut y = 1970 + ts / 31_557_600;
287 if approximate_epoch(y, 0, 1, 0, 0) > ts {
288 y -= 1;
289 } else if approximate_epoch(y + 1, 0, 1, 0, 0) <= ts {
290 y += 1;
291 }
292 y
293}
294
295fn is_leap_year(year: i64) -> bool {
296 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
297}
298
299pub fn format_relative_time(ts: i64) -> String {
302 let now = std::time::SystemTime::now()
303 .duration_since(std::time::UNIX_EPOCH)
304 .unwrap_or_default()
305 .as_secs() as i64;
306 let diff = now - ts;
307 if diff < 0 {
308 return format_short_date(ts);
310 }
311 if diff < 60 {
312 return "just now".to_string();
313 }
314 if diff < 3600 {
315 return format!("{}m ago", diff / 60);
316 }
317 if diff < 86400 {
318 return format!("{}h ago", diff / 3600);
319 }
320 if diff < 86400 * 30 {
321 return format!("{}d ago", diff / 86400);
322 }
323 format_short_date(ts)
324}
325
326fn format_short_date(ts: i64) -> String {
328 let now = std::time::SystemTime::now()
329 .duration_since(std::time::UNIX_EPOCH)
330 .unwrap_or_default()
331 .as_secs() as i64;
332 let now_year = epoch_to_year(now);
333 let ts_year = epoch_to_year(ts);
334
335 let months = [
336 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
337 ];
338
339 let year_start = approximate_epoch(ts_year, 0, 1, 0, 0);
341 let day_of_year = ((ts - year_start) / 86400).max(0) as usize;
342 let feb = if is_leap_year(ts_year) { 29 } else { 28 };
343 let month_lengths = [31, feb, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
344 let mut m = 0;
345 let mut remaining = day_of_year;
346 for (i, &len) in month_lengths.iter().enumerate() {
347 if remaining < len {
348 m = i;
349 break;
350 }
351 remaining -= len;
352 m = i + 1;
353 }
354 let m = m.min(11);
355 let d = remaining + 1;
356
357 if ts_year == now_year {
358 format!("{} {:>2}", months[m], d)
359 } else {
360 format!("{} {}", months[m], ts_year)
361 }
362}
363
364fn shell_escape(path: &str) -> String {
366 crate::snippet::shell_escape(path)
367}
368
369pub fn get_remote_home(
371 alias: &str,
372 config_path: &Path,
373 askpass: Option<&str>,
374 bw_session: Option<&str>,
375 has_active_tunnel: bool,
376) -> anyhow::Result<String> {
377 let result = crate::snippet::run_snippet(
378 alias,
379 config_path,
380 "pwd",
381 askpass,
382 bw_session,
383 true,
384 has_active_tunnel,
385 )?;
386 if result.status.success() {
387 Ok(result.stdout.trim().to_string())
388 } else {
389 let msg = filter_ssh_warnings(result.stderr.trim());
390 if msg.is_empty() {
391 anyhow::bail!("Failed to connect.")
392 } else {
393 anyhow::bail!("{}", msg)
394 }
395 }
396}
397
398#[allow(clippy::too_many_arguments)]
400pub fn fetch_remote_listing(
401 alias: &str,
402 config_path: &Path,
403 remote_path: &str,
404 show_hidden: bool,
405 sort: BrowserSort,
406 askpass: Option<&str>,
407 bw_session: Option<&str>,
408 has_tunnel: bool,
409) -> Result<Vec<FileEntry>, String> {
410 let command = format!("LC_ALL=C ls -lhAL {}", shell_escape(remote_path));
411 let result = crate::snippet::run_snippet(
412 alias,
413 config_path,
414 &command,
415 askpass,
416 bw_session,
417 true,
418 has_tunnel,
419 );
420 match result {
421 Ok(r) if r.status.success() => Ok(parse_ls_output(&r.stdout, show_hidden, sort)),
422 Ok(r) => {
423 let msg = filter_ssh_warnings(r.stderr.trim());
424 if msg.is_empty() {
425 Err(format!(
426 "ls exited with code {}.",
427 r.status.code().unwrap_or(1)
428 ))
429 } else {
430 Err(msg)
431 }
432 }
433 Err(e) => Err(e.to_string()),
434 }
435}
436
437#[allow(clippy::too_many_arguments)]
440pub fn spawn_remote_listing<F>(
441 alias: String,
442 config_path: PathBuf,
443 remote_path: String,
444 show_hidden: bool,
445 sort: BrowserSort,
446 askpass: Option<String>,
447 bw_session: Option<String>,
448 has_tunnel: bool,
449 send: F,
450) where
451 F: FnOnce(String, String, Result<Vec<FileEntry>, String>) + Send + 'static,
452{
453 std::thread::spawn(move || {
454 let listing = fetch_remote_listing(
455 &alias,
456 &config_path,
457 &remote_path,
458 show_hidden,
459 sort,
460 askpass.as_deref(),
461 bw_session.as_deref(),
462 has_tunnel,
463 );
464 send(alias, remote_path, listing);
465 });
466}
467
468pub struct ScpResult {
470 pub status: ExitStatus,
471 pub stderr_output: String,
472}
473
474pub fn run_scp(
480 alias: &str,
481 config_path: &Path,
482 askpass: Option<&str>,
483 bw_session: Option<&str>,
484 has_active_tunnel: bool,
485 scp_args: &[String],
486) -> anyhow::Result<ScpResult> {
487 let mut cmd = Command::new("scp");
488 cmd.arg("-F").arg(config_path);
489
490 if has_active_tunnel {
491 cmd.arg("-o").arg("ClearAllForwardings=yes");
492 }
493
494 for arg in scp_args {
495 cmd.arg(arg);
496 }
497
498 cmd.stdin(Stdio::null())
499 .stdout(Stdio::null())
500 .stderr(Stdio::piped());
501
502 if askpass.is_some() {
503 crate::askpass_env::configure_ssh_command(&mut cmd, alias, config_path);
504 }
505
506 if let Some(token) = bw_session {
507 cmd.env("BW_SESSION", token);
508 }
509
510 let output = cmd
511 .output()
512 .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
513
514 let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
515
516 Ok(ScpResult {
517 status: output.status,
518 stderr_output,
519 })
520}
521
522pub fn filter_ssh_warnings(stderr: &str) -> String {
525 stderr
526 .lines()
527 .filter(|line| {
528 let trimmed = line.trim();
529 !trimmed.is_empty()
530 && !trimmed.starts_with("** ")
531 && !trimmed.starts_with("Warning:")
532 && !trimmed.contains("see https://")
533 && !trimmed.contains("See https://")
534 && !trimmed.starts_with("The server may need")
535 && !trimmed.starts_with("This session may be")
536 })
537 .collect::<Vec<_>>()
538 .join("\n")
539}
540
541pub fn build_scp_args(
549 alias: &str,
550 source_pane: BrowserPane,
551 local_path: &Path,
552 remote_path: &str,
553 filenames: &[String],
554 has_dirs: bool,
555) -> Vec<String> {
556 let mut args = Vec::new();
557 if has_dirs {
558 args.push("-r".to_string());
559 }
560 args.push("--".to_string());
561
562 match source_pane {
563 BrowserPane::Local => {
565 for name in filenames {
566 args.push(local_path.join(name).to_string_lossy().to_string());
567 }
568 let dest = format!("{}:{}", alias, remote_path);
569 args.push(dest);
570 }
571 BrowserPane::Remote => {
573 let base = remote_path.trim_end_matches('/');
574 for name in filenames {
575 let rpath = format!("{}/{}", base, name);
576 args.push(format!("{}:{}", alias, rpath));
577 }
578 args.push(local_path.to_string_lossy().to_string());
579 }
580 }
581 args
582}
583
584pub fn format_size(bytes: u64) -> String {
586 if bytes >= 1024 * 1024 * 1024 {
587 format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
588 } else if bytes >= 1024 * 1024 {
589 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
590 } else if bytes >= 1024 {
591 format!("{:.1} KB", bytes as f64 / 1024.0)
592 } else {
593 format!("{} B", bytes)
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
606 fn test_shell_escape_simple() {
607 assert_eq!(shell_escape("/home/user"), "'/home/user'");
608 }
609
610 #[test]
611 fn test_shell_escape_with_single_quote() {
612 assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
613 }
614
615 #[test]
616 fn test_shell_escape_with_spaces() {
617 assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
618 }
619
620 #[test]
625 fn test_parse_ls_basic() {
626 let output = "\
627total 24
628drwxr-xr-x 2 user user 4096 Jan 1 12:00 subdir
629-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
630-rw-r--r-- 1 user user 1.1K Jan 1 12:00 big.log
631";
632 let entries = parse_ls_output(output, true, BrowserSort::Name);
633 assert_eq!(entries.len(), 3);
634 assert_eq!(entries[0].name, "subdir");
635 assert!(entries[0].is_dir);
636 assert_eq!(entries[0].size, None);
637 assert_eq!(entries[1].name, "big.log");
639 assert!(!entries[1].is_dir);
640 assert_eq!(entries[1].size, Some(1126)); assert_eq!(entries[2].name, "file.txt");
642 assert!(!entries[2].is_dir);
643 assert_eq!(entries[2].size, Some(512));
644 }
645
646 #[test]
647 fn test_parse_ls_hidden_filter() {
648 let output = "\
649total 8
650-rw-r--r-- 1 user user 100 Jan 1 12:00 .hidden
651-rw-r--r-- 1 user user 200 Jan 1 12:00 visible
652";
653 let entries = parse_ls_output(output, false, BrowserSort::Name);
654 assert_eq!(entries.len(), 1);
655 assert_eq!(entries[0].name, "visible");
656
657 let entries = parse_ls_output(output, true, BrowserSort::Name);
658 assert_eq!(entries.len(), 2);
659 }
660
661 #[test]
662 fn test_parse_ls_symlink_to_file_dereferenced() {
663 let output = "\
665total 4
666-rw-r--r-- 1 user user 11 Jan 1 12:00 link
667";
668 let entries = parse_ls_output(output, true, BrowserSort::Name);
669 assert_eq!(entries.len(), 1);
670 assert_eq!(entries[0].name, "link");
671 assert!(!entries[0].is_dir);
672 }
673
674 #[test]
675 fn test_parse_ls_symlink_to_dir_dereferenced() {
676 let output = "\
678total 4
679drwxr-xr-x 3 user user 4096 Jan 1 12:00 link
680";
681 let entries = parse_ls_output(output, true, BrowserSort::Name);
682 assert_eq!(entries.len(), 1);
683 assert_eq!(entries[0].name, "link");
684 assert!(entries[0].is_dir);
685 }
686
687 #[test]
688 fn test_parse_ls_filename_with_spaces() {
689 let output = "\
690total 4
691-rw-r--r-- 1 user user 100 Jan 1 12:00 my file name.txt
692";
693 let entries = parse_ls_output(output, true, BrowserSort::Name);
694 assert_eq!(entries.len(), 1);
695 assert_eq!(entries[0].name, "my file name.txt");
696 }
697
698 #[test]
699 fn test_parse_ls_empty() {
700 let output = "total 0\n";
701 let entries = parse_ls_output(output, true, BrowserSort::Name);
702 assert!(entries.is_empty());
703 }
704
705 #[test]
710 fn test_parse_human_size() {
711 assert_eq!(parse_human_size("512"), 512);
712 assert_eq!(parse_human_size("1.0K"), 1024);
713 assert_eq!(parse_human_size("1.5M"), 1572864);
714 assert_eq!(parse_human_size("2.0G"), 2147483648);
715 }
716
717 #[test]
722 fn test_format_size() {
723 assert_eq!(format_size(0), "0 B");
724 assert_eq!(format_size(512), "512 B");
725 assert_eq!(format_size(1024), "1.0 KB");
726 assert_eq!(format_size(1536), "1.5 KB");
727 assert_eq!(format_size(1048576), "1.0 MB");
728 assert_eq!(format_size(1073741824), "1.0 GB");
729 }
730
731 #[test]
736 fn test_build_scp_args_upload() {
737 let args = build_scp_args(
738 "myhost",
739 BrowserPane::Local,
740 Path::new("/home/user/docs"),
741 "/remote/path/",
742 &["file.txt".to_string()],
743 false,
744 );
745 assert_eq!(
746 args,
747 vec!["--", "/home/user/docs/file.txt", "myhost:/remote/path/",]
748 );
749 }
750
751 #[test]
752 fn test_build_scp_args_download() {
753 let args = build_scp_args(
754 "myhost",
755 BrowserPane::Remote,
756 Path::new("/home/user/docs"),
757 "/remote/path",
758 &["file.txt".to_string()],
759 false,
760 );
761 assert_eq!(
762 args,
763 vec!["--", "myhost:/remote/path/file.txt", "/home/user/docs",]
764 );
765 }
766
767 #[test]
768 fn test_build_scp_args_spaces_in_path() {
769 let args = build_scp_args(
770 "myhost",
771 BrowserPane::Remote,
772 Path::new("/local"),
773 "/remote/my path",
774 &["my file.txt".to_string()],
775 false,
776 );
777 assert_eq!(
779 args,
780 vec!["--", "myhost:/remote/my path/my file.txt", "/local",]
781 );
782 }
783
784 #[test]
785 fn test_build_scp_args_with_dirs() {
786 let args = build_scp_args(
787 "myhost",
788 BrowserPane::Local,
789 Path::new("/local"),
790 "/remote/",
791 &["mydir".to_string()],
792 true,
793 );
794 assert_eq!(args[0], "-r");
795 }
796
797 #[test]
802 fn test_list_local_sorts_dirs_first() {
803 let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
804 let _ = std::fs::remove_dir_all(&base);
805 std::fs::create_dir_all(&base).unwrap();
806 std::fs::create_dir(base.join("zdir")).unwrap();
807 std::fs::write(base.join("afile.txt"), "hello").unwrap();
808 std::fs::write(base.join("bfile.txt"), "world").unwrap();
809
810 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
811 assert_eq!(entries.len(), 3);
812 assert!(entries[0].is_dir);
813 assert_eq!(entries[0].name, "zdir");
814 assert_eq!(entries[1].name, "afile.txt");
815 assert_eq!(entries[2].name, "bfile.txt");
816
817 let _ = std::fs::remove_dir_all(&base);
818 }
819
820 #[test]
821 fn test_list_local_hidden() {
822 let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
823 let _ = std::fs::remove_dir_all(&base);
824 std::fs::create_dir_all(&base).unwrap();
825 std::fs::write(base.join(".hidden"), "").unwrap();
826 std::fs::write(base.join("visible"), "").unwrap();
827
828 let entries = list_local(&base, false, BrowserSort::Name).unwrap();
829 assert_eq!(entries.len(), 1);
830 assert_eq!(entries[0].name, "visible");
831
832 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
833 assert_eq!(entries.len(), 2);
834
835 let _ = std::fs::remove_dir_all(&base);
836 }
837
838 #[test]
843 fn test_filter_ssh_warnings_filters_warnings() {
844 let stderr = "\
845** WARNING: connection is not using a post-quantum key exchange algorithm.
846** This session may be vulnerable to \"store now, decrypt later\" attacks.
847** The server may need to be upgraded. See https://openssh.com/pq.html
848scp: '/root/file.rpm': No such file or directory";
849 assert_eq!(
850 filter_ssh_warnings(stderr),
851 "scp: '/root/file.rpm': No such file or directory"
852 );
853 }
854
855 #[test]
856 fn test_filter_ssh_warnings_keeps_plain_error() {
857 let stderr = "scp: /etc/shadow: Permission denied\n";
858 assert_eq!(
859 filter_ssh_warnings(stderr),
860 "scp: /etc/shadow: Permission denied"
861 );
862 }
863
864 #[test]
865 fn test_filter_ssh_warnings_empty() {
866 assert_eq!(filter_ssh_warnings(""), "");
867 assert_eq!(filter_ssh_warnings(" \n \n"), "");
868 }
869
870 #[test]
871 fn test_filter_ssh_warnings_warning_prefix() {
872 let stderr = "Warning: Permanently added '10.0.0.1' to the list of known hosts.\nPermission denied (publickey).";
873 assert_eq!(
874 filter_ssh_warnings(stderr),
875 "Permission denied (publickey)."
876 );
877 }
878
879 #[test]
880 fn test_filter_ssh_warnings_lowercase_see_https() {
881 let stderr = "For details, see https://openssh.com/legacy.html\nConnection refused";
882 assert_eq!(filter_ssh_warnings(stderr), "Connection refused");
883 }
884
885 #[test]
886 fn test_filter_ssh_warnings_only_warnings() {
887 let stderr = "** WARNING: connection is not using a post-quantum key exchange algorithm.\n** This session may be vulnerable to \"store now, decrypt later\" attacks.\n** The server may need to be upgraded. See https://openssh.com/pq.html";
888 assert_eq!(filter_ssh_warnings(stderr), "");
889 }
890
891 #[test]
896 fn test_approximate_epoch_known_dates() {
897 let ts = approximate_epoch(2024, 0, 1, 0, 0);
899 assert_eq!(ts, 1704067200);
900 let ts = approximate_epoch(2000, 0, 1, 0, 0);
902 assert_eq!(ts, 946684800);
903 assert_eq!(approximate_epoch(1970, 0, 1, 0, 0), 0);
905 }
906
907 #[test]
908 fn test_approximate_epoch_leap_year() {
909 let feb29 = approximate_epoch(2024, 1, 29, 0, 0);
911 let mar01 = approximate_epoch(2024, 2, 1, 0, 0);
912 assert_eq!(mar01 - feb29, 86400);
913 }
914
915 #[test]
920 fn test_epoch_to_year() {
921 assert_eq!(epoch_to_year(0), 1970);
922 assert_eq!(epoch_to_year(1672531200), 2023);
924 assert_eq!(epoch_to_year(1704067200), 2024);
926 assert_eq!(epoch_to_year(1735689599), 2024);
928 assert_eq!(epoch_to_year(1735689600), 2025);
930 }
931
932 #[test]
937 fn test_parse_ls_date_recent_format() {
938 let ts = parse_ls_date("Jan", "15", "12:34");
940 assert!(ts.is_some());
941 let ts = ts.unwrap();
942 let now = std::time::SystemTime::now()
944 .duration_since(std::time::UNIX_EPOCH)
945 .unwrap()
946 .as_secs() as i64;
947 assert!(ts <= now + 86400);
948 assert!(ts > now - 366 * 86400);
949 }
950
951 #[test]
952 fn test_parse_ls_date_old_format() {
953 let ts = parse_ls_date("Mar", "5", "2023");
954 assert!(ts.is_some());
955 let ts = ts.unwrap();
956 assert_eq!(epoch_to_year(ts), 2023);
958 }
959
960 #[test]
961 fn test_parse_ls_date_invalid_month() {
962 assert!(parse_ls_date("Foo", "1", "12:00").is_none());
963 }
964
965 #[test]
966 fn test_parse_ls_date_invalid_day() {
967 assert!(parse_ls_date("Jan", "0", "12:00").is_none());
968 assert!(parse_ls_date("Jan", "32", "12:00").is_none());
969 }
970
971 #[test]
972 fn test_parse_ls_date_invalid_year() {
973 assert!(parse_ls_date("Jan", "1", "1969").is_none());
974 }
975
976 #[test]
981 fn test_format_relative_time_ranges() {
982 let now = std::time::SystemTime::now()
983 .duration_since(std::time::UNIX_EPOCH)
984 .unwrap()
985 .as_secs() as i64;
986 assert_eq!(format_relative_time(now), "just now");
987 assert_eq!(format_relative_time(now - 30), "just now");
988 assert_eq!(format_relative_time(now - 120), "2m ago");
989 assert_eq!(format_relative_time(now - 7200), "2h ago");
990 assert_eq!(format_relative_time(now - 86400 * 3), "3d ago");
991 }
992
993 #[test]
994 fn test_format_relative_time_old_date() {
995 let old = approximate_epoch(2020, 5, 15, 0, 0);
997 let result = format_relative_time(old);
998 assert!(
999 result.contains("2020"),
1000 "Expected year in '{}' for old date",
1001 result
1002 );
1003 }
1004
1005 #[test]
1006 fn test_format_relative_time_future() {
1007 let now = std::time::SystemTime::now()
1008 .duration_since(std::time::UNIX_EPOCH)
1009 .unwrap()
1010 .as_secs() as i64;
1011 let result = format_relative_time(now + 86400 * 30);
1013 assert!(!result.is_empty());
1014 }
1015
1016 #[test]
1021 fn test_format_short_date_different_year() {
1022 let ts = approximate_epoch(2020, 2, 15, 0, 0); let result = format_short_date(ts);
1024 assert!(result.contains("2020"), "Expected year in '{}'", result);
1025 assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1026 }
1027
1028 #[test]
1029 fn test_format_short_date_leap_year() {
1030 let ts = approximate_epoch(2024, 2, 1, 0, 0);
1032 let result = format_short_date(ts);
1033 assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1034 assert!(result.contains("2024"), "Expected 2024 in '{}'", result);
1035 let feb29 = approximate_epoch(2024, 1, 29, 12, 0);
1037 let mar01 = approximate_epoch(2024, 2, 1, 12, 0);
1038 let feb29_date = format_short_date(feb29);
1039 let mar01_date = format_short_date(mar01);
1040 assert!(
1041 feb29_date.starts_with("Feb"),
1042 "Expected Feb in '{}'",
1043 feb29_date
1044 );
1045 assert!(
1046 mar01_date.starts_with("Mar"),
1047 "Expected Mar in '{}'",
1048 mar01_date
1049 );
1050 }
1051
1052 #[test]
1057 fn test_sort_entries_date_dirs_first_newest_first() {
1058 let mut entries = vec![
1059 FileEntry {
1060 name: "old.txt".into(),
1061 is_dir: false,
1062 size: Some(100),
1063 modified: Some(1000),
1064 },
1065 FileEntry {
1066 name: "new.txt".into(),
1067 is_dir: false,
1068 size: Some(200),
1069 modified: Some(3000),
1070 },
1071 FileEntry {
1072 name: "mid.txt".into(),
1073 is_dir: false,
1074 size: Some(150),
1075 modified: Some(2000),
1076 },
1077 FileEntry {
1078 name: "adir".into(),
1079 is_dir: true,
1080 size: None,
1081 modified: Some(500),
1082 },
1083 ];
1084 sort_entries(&mut entries, BrowserSort::Date);
1085 assert!(entries[0].is_dir);
1086 assert_eq!(entries[0].name, "adir");
1087 assert_eq!(entries[1].name, "new.txt");
1088 assert_eq!(entries[2].name, "mid.txt");
1089 assert_eq!(entries[3].name, "old.txt");
1090 }
1091
1092 #[test]
1093 fn test_sort_entries_name_mode() {
1094 let mut entries = vec![
1095 FileEntry {
1096 name: "zebra.txt".into(),
1097 is_dir: false,
1098 size: Some(100),
1099 modified: Some(3000),
1100 },
1101 FileEntry {
1102 name: "alpha.txt".into(),
1103 is_dir: false,
1104 size: Some(200),
1105 modified: Some(1000),
1106 },
1107 FileEntry {
1108 name: "mydir".into(),
1109 is_dir: true,
1110 size: None,
1111 modified: Some(2000),
1112 },
1113 ];
1114 sort_entries(&mut entries, BrowserSort::Name);
1115 assert!(entries[0].is_dir);
1116 assert_eq!(entries[1].name, "alpha.txt");
1117 assert_eq!(entries[2].name, "zebra.txt");
1118 }
1119
1120 #[test]
1125 fn test_parse_ls_output_populates_modified() {
1126 let output = "\
1127total 4
1128-rw-r--r-- 1 user user 512 Jan 1 12:00 file.txt
1129";
1130 let entries = parse_ls_output(output, true, BrowserSort::Name);
1131 assert_eq!(entries.len(), 1);
1132 assert!(
1133 entries[0].modified.is_some(),
1134 "modified should be populated"
1135 );
1136 }
1137
1138 #[test]
1139 fn test_parse_ls_output_date_sort() {
1140 let output = "\
1142total 12
1143-rw-r--r-- 1 user user 100 Jan 1 2020 old.txt
1144-rw-r--r-- 1 user user 200 Jun 15 2023 new.txt
1145-rw-r--r-- 1 user user 150 Mar 5 2022 mid.txt
1146";
1147 let entries = parse_ls_output(output, true, BrowserSort::Date);
1148 assert_eq!(entries.len(), 3);
1149 assert_eq!(entries[0].name, "new.txt");
1151 assert_eq!(entries[1].name, "mid.txt");
1152 assert_eq!(entries[2].name, "old.txt");
1153 }
1154
1155 #[test]
1160 fn test_list_local_populates_modified() {
1161 let base = std::env::temp_dir().join(format!("purple_fb_mtime_{}", std::process::id()));
1162 let _ = std::fs::remove_dir_all(&base);
1163 std::fs::create_dir_all(&base).unwrap();
1164 std::fs::write(base.join("test.txt"), "hello").unwrap();
1165
1166 let entries = list_local(&base, true, BrowserSort::Name).unwrap();
1167 assert_eq!(entries.len(), 1);
1168 assert!(
1169 entries[0].modified.is_some(),
1170 "modified should be populated for local files"
1171 );
1172
1173 let _ = std::fs::remove_dir_all(&base);
1174 }
1175
1176 #[test]
1181 fn test_epoch_to_year_2100_boundary() {
1182 let ts_2100 = approximate_epoch(2100, 0, 1, 0, 0);
1183 assert_eq!(epoch_to_year(ts_2100), 2100);
1184 assert_eq!(epoch_to_year(ts_2100 - 1), 2099);
1185 let mid_2100 = approximate_epoch(2100, 5, 15, 12, 0);
1186 assert_eq!(epoch_to_year(mid_2100), 2100);
1187 }
1188
1189 #[test]
1194 fn test_parse_ls_date_midnight() {
1195 let ts = parse_ls_date("Jan", "1", "00:00");
1196 assert!(ts.is_some(), "00:00 should parse successfully");
1197 let ts = ts.unwrap();
1198 let now = std::time::SystemTime::now()
1199 .duration_since(std::time::UNIX_EPOCH)
1200 .unwrap()
1201 .as_secs() as i64;
1202 assert!(ts <= now + 86400);
1203 assert!(ts > now - 366 * 86400);
1204 }
1205
1206 #[test]
1211 fn test_sort_entries_date_with_none_modified() {
1212 let mut entries = vec![
1213 FileEntry {
1214 name: "known.txt".into(),
1215 is_dir: false,
1216 size: Some(100),
1217 modified: Some(5000),
1218 },
1219 FileEntry {
1220 name: "unknown.txt".into(),
1221 is_dir: false,
1222 size: Some(200),
1223 modified: None,
1224 },
1225 FileEntry {
1226 name: "recent.txt".into(),
1227 is_dir: false,
1228 size: Some(300),
1229 modified: Some(9000),
1230 },
1231 ];
1232 sort_entries(&mut entries, BrowserSort::Date);
1233 assert_eq!(entries[0].name, "recent.txt");
1234 assert_eq!(entries[1].name, "known.txt");
1235 assert_eq!(entries[2].name, "unknown.txt");
1236 }
1237
1238 #[test]
1239 fn test_sort_entries_date_asc_oldest_first() {
1240 let mut entries = vec![
1241 FileEntry {
1242 name: "old.txt".into(),
1243 is_dir: false,
1244 size: Some(100),
1245 modified: Some(1000),
1246 },
1247 FileEntry {
1248 name: "new.txt".into(),
1249 is_dir: false,
1250 size: Some(200),
1251 modified: Some(3000),
1252 },
1253 FileEntry {
1254 name: "mid.txt".into(),
1255 is_dir: false,
1256 size: Some(150),
1257 modified: Some(2000),
1258 },
1259 FileEntry {
1260 name: "adir".into(),
1261 is_dir: true,
1262 size: None,
1263 modified: Some(500),
1264 },
1265 ];
1266 sort_entries(&mut entries, BrowserSort::DateAsc);
1267 assert!(entries[0].is_dir);
1268 assert_eq!(entries[0].name, "adir");
1269 assert_eq!(entries[1].name, "old.txt");
1270 assert_eq!(entries[2].name, "mid.txt");
1271 assert_eq!(entries[3].name, "new.txt");
1272 }
1273
1274 #[test]
1275 fn test_sort_entries_date_asc_none_modified_sorts_to_end() {
1276 let mut entries = vec![
1277 FileEntry {
1278 name: "known.txt".into(),
1279 is_dir: false,
1280 size: Some(100),
1281 modified: Some(5000),
1282 },
1283 FileEntry {
1284 name: "unknown.txt".into(),
1285 is_dir: false,
1286 size: Some(200),
1287 modified: None,
1288 },
1289 FileEntry {
1290 name: "old.txt".into(),
1291 is_dir: false,
1292 size: Some(300),
1293 modified: Some(1000),
1294 },
1295 ];
1296 sort_entries(&mut entries, BrowserSort::DateAsc);
1297 assert_eq!(entries[0].name, "old.txt");
1298 assert_eq!(entries[1].name, "known.txt");
1299 assert_eq!(entries[2].name, "unknown.txt"); }
1301
1302 #[test]
1303 fn test_parse_ls_output_date_asc_sort() {
1304 let output = "\
1305total 12
1306-rw-r--r-- 1 user user 100 Jan 1 2020 old.txt
1307-rw-r--r-- 1 user user 200 Jun 15 2023 new.txt
1308-rw-r--r-- 1 user user 150 Mar 5 2022 mid.txt
1309";
1310 let entries = parse_ls_output(output, true, BrowserSort::DateAsc);
1311 assert_eq!(entries.len(), 3);
1312 assert_eq!(entries[0].name, "old.txt");
1314 assert_eq!(entries[1].name, "mid.txt");
1315 assert_eq!(entries[2].name, "new.txt");
1316 }
1317
1318 #[test]
1319 fn test_sort_entries_date_multiple_dirs() {
1320 let mut entries = vec![
1321 FileEntry {
1322 name: "old_dir".into(),
1323 is_dir: true,
1324 size: None,
1325 modified: Some(1000),
1326 },
1327 FileEntry {
1328 name: "new_dir".into(),
1329 is_dir: true,
1330 size: None,
1331 modified: Some(3000),
1332 },
1333 FileEntry {
1334 name: "mid_dir".into(),
1335 is_dir: true,
1336 size: None,
1337 modified: Some(2000),
1338 },
1339 FileEntry {
1340 name: "file.txt".into(),
1341 is_dir: false,
1342 size: Some(100),
1343 modified: Some(5000),
1344 },
1345 ];
1346 sort_entries(&mut entries, BrowserSort::Date);
1347 assert!(entries[0].is_dir);
1348 assert_eq!(entries[0].name, "new_dir");
1349 assert_eq!(entries[1].name, "mid_dir");
1350 assert_eq!(entries[2].name, "old_dir");
1351 assert_eq!(entries[3].name, "file.txt");
1352 }
1353
1354 #[test]
1359 fn test_format_relative_time_exactly_60s() {
1360 let now = std::time::SystemTime::now()
1361 .duration_since(std::time::UNIX_EPOCH)
1362 .unwrap()
1363 .as_secs() as i64;
1364 assert_eq!(format_relative_time(now - 60), "1m ago");
1365 assert_eq!(format_relative_time(now - 59), "just now");
1366 }
1367
1368 #[test]
1373 fn test_parse_ls_output_date_sort_with_dirs() {
1374 let output = "\
1375total 16
1376drwxr-xr-x 2 user user 4096 Jan 1 2020 old_dir
1377-rw-r--r-- 1 user user 200 Jun 15 2023 new_file.txt
1378drwxr-xr-x 2 user user 4096 Dec 1 2023 new_dir
1379-rw-r--r-- 1 user user 100 Mar 5 2022 old_file.txt
1380";
1381 let entries = parse_ls_output(output, true, BrowserSort::Date);
1382 assert_eq!(entries.len(), 4);
1383 assert!(entries[0].is_dir);
1384 assert_eq!(entries[0].name, "new_dir");
1385 assert!(entries[1].is_dir);
1386 assert_eq!(entries[1].name, "old_dir");
1387 assert_eq!(entries[2].name, "new_file.txt");
1388 assert_eq!(entries[3].name, "old_file.txt");
1389 }
1390}