Skip to main content

purple_ssh/
file_browser.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::{Command, ExitStatus, Stdio};
4
5use ratatui::widgets::ListState;
6
7/// Sort mode for file browser panes.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum BrowserSort {
10    Name,
11    Date,
12    DateAsc,
13}
14
15/// A file or directory entry in the browser.
16#[derive(Debug, Clone, PartialEq)]
17pub struct FileEntry {
18    pub name: String,
19    pub is_dir: bool,
20    pub size: Option<u64>,
21    /// Modification time as Unix timestamp (seconds since epoch).
22    pub modified: Option<i64>,
23}
24
25/// Which pane is active.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum BrowserPane {
28    Local,
29    Remote,
30}
31
32/// Pending copy operation awaiting confirmation.
33pub struct CopyRequest {
34    pub sources: Vec<String>,
35    pub source_pane: BrowserPane,
36    pub has_dirs: bool,
37}
38
39/// State for the dual-pane file browser overlay.
40pub struct FileBrowserState {
41    pub alias: String,
42    pub askpass: Option<String>,
43    pub active_pane: BrowserPane,
44    // Local
45    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    // Remote
51    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    // Options
58    pub show_hidden: bool,
59    pub sort: BrowserSort,
60    // Copy confirmation
61    pub confirm_copy: Option<CopyRequest>,
62    // Transfer in progress
63    pub transferring: Option<String>,
64    // Transfer error (shown as dismissible dialog)
65    pub transfer_error: Option<String>,
66    // Whether the initial remote connection has been recorded in history
67    pub connection_recorded: bool,
68}
69
70/// List local directory entries.
71/// Sorts: directories first, then by name or date. Filters dotfiles based on show_hidden.
72pub 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
103/// Sort file entries: directories first, then by the chosen mode.
104pub 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                    // Newest first: reverse order
119                    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                    // Oldest first; unknown dates sort to the end
127                    a.modified
128                        .unwrap_or(i64::MAX)
129                        .cmp(&b.modified.unwrap_or(i64::MAX))
130                })
131            });
132        }
133    }
134}
135
136/// Parse `ls -lhAL` output into FileEntry list.
137/// With -L, symlinks are dereferenced so their target type is shown directly.
138/// Recognizes directories via 'd' permission prefix. Skips the "total" line.
139/// Broken symlinks are omitted by ls -L (they cannot be transferred anyway).
140pub 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        // ls -l format: permissions links owner group size month day time name
148        // Split on whitespace runs, taking 9 fields (last gets the rest including spaces)
149        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        // Skip empty names
171        if name.is_empty() {
172            continue;
173        }
174        if !show_hidden && name.starts_with('.') {
175            continue;
176        }
177        // Parse human-readable size (e.g. "1.1K", "4.0M", "512")
178        let size = if is_dir {
179            None
180        } else {
181            Some(parse_human_size(parts[4]))
182        };
183        // Parse date from month/day/time-or-year (parts[5..=7])
184        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
196/// Parse a human-readable size string like "1.1K", "4.0M", "512" into bytes.
197fn 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
215/// Parse the date fields from `ls -l` with `LC_ALL=C`.
216/// Recent files: "Jan 1 12:34" (month day HH:MM).
217/// Old files: "Jan 1 2024" (month day year).
218/// Returns approximate Unix timestamp or None if unparseable.
219fn 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        // Recent format: "HH:MM"
248        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        // Determine year: if month/day is in the future, it's last year
252        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        // Old format: "2024" (year)
260        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
268/// Rough Unix timestamp from date components (no leap second precision needed).
269fn approximate_epoch(year: i64, month: i64, day: i64, hour: i64, min: i64) -> i64 {
270    // Days from 1970-01-01 to start of year
271    let y = year - 1970;
272    let mut days = y * 365 + (y + 1) / 4; // approximate leap years
273    // Days to start of month (non-leap approximation, close enough for sorting)
274    let month_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
275    days += month_days[month as usize];
276    // Add leap day if applicable
277    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
284/// Convert epoch seconds to a year (correctly handles year boundaries).
285fn 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
299/// Format a Unix timestamp as a relative or short date string.
300/// Returns strings like "2m ago", "3h ago", "5d ago", "Jan 15", "Mar 2024".
301pub 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        // Future timestamp (clock skew), just show date
309        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
326/// Format a timestamp as "Mon DD" (same year) or "Mon YYYY" (different year).
327fn 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    // Approximate month and day from day-of-year
340    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
364/// Shell-escape a path with single quotes.
365fn shell_escape(path: &str) -> String {
366    crate::snippet::shell_escape(path)
367}
368
369/// Get the remote home directory via `pwd`.
370pub 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/// Fetch remote directory listing synchronously (used by spawn_remote_listing).
399#[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/// Spawn background thread for remote directory listing.
438/// Sends result back via the provided sender function.
439#[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
468/// Result of an scp transfer.
469pub struct ScpResult {
470    pub status: ExitStatus,
471    pub stderr_output: String,
472}
473
474/// Run scp in the background with captured stderr for error reporting.
475/// Stderr is piped and captured so errors can be extracted. Progress percentage
476/// is not available because scp only outputs progress to a TTY, not to a pipe.
477/// Stdin is null (askpass handles authentication). Stdout is null (scp has no
478/// meaningful stdout output).
479pub 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        let exe = std::env::current_exe()
504            .ok()
505            .map(|p| p.to_string_lossy().to_string())
506            .or_else(|| std::env::args().next())
507            .unwrap_or_else(|| "purple".to_string());
508        cmd.env("SSH_ASKPASS", &exe)
509            .env("SSH_ASKPASS_REQUIRE", "prefer")
510            .env("PURPLE_ASKPASS_MODE", "1")
511            .env("PURPLE_HOST_ALIAS", alias)
512            .env("PURPLE_CONFIG_PATH", config_path.as_os_str());
513    }
514
515    if let Some(token) = bw_session {
516        cmd.env("BW_SESSION", token);
517    }
518
519    let output = cmd
520        .output()
521        .map_err(|e| anyhow::anyhow!("Failed to run scp: {}", e))?;
522
523    let stderr_output = String::from_utf8_lossy(&output.stderr).to_string();
524
525    Ok(ScpResult {
526        status: output.status,
527        stderr_output,
528    })
529}
530
531/// Filter SSH warning noise from stderr, keeping only actionable error lines.
532/// Strips lines like "** WARNING: connection is not using a post-quantum key exchange".
533pub fn filter_ssh_warnings(stderr: &str) -> String {
534    stderr
535        .lines()
536        .filter(|line| {
537            let trimmed = line.trim();
538            !trimmed.is_empty()
539                && !trimmed.starts_with("** ")
540                && !trimmed.starts_with("Warning:")
541                && !trimmed.contains("see https://")
542                && !trimmed.contains("See https://")
543                && !trimmed.starts_with("The server may need")
544                && !trimmed.starts_with("This session may be")
545        })
546        .collect::<Vec<_>>()
547        .join("\n")
548}
549
550/// Build scp arguments for a file transfer.
551/// Returns the args to pass after `scp -F <config>`.
552///
553/// Remote paths are NOT shell-escaped because scp is invoked via Command::arg()
554/// which bypasses the shell entirely. The colon in `alias:path` is the only
555/// special character scp interprets. Paths with spaces, globbing chars etc. are
556/// passed through literally by the OS exec layer.
557pub fn build_scp_args(
558    alias: &str,
559    source_pane: BrowserPane,
560    local_path: &Path,
561    remote_path: &str,
562    filenames: &[String],
563    has_dirs: bool,
564) -> Vec<String> {
565    let mut args = Vec::new();
566    if has_dirs {
567        args.push("-r".to_string());
568    }
569    args.push("--".to_string());
570
571    match source_pane {
572        // Upload: local files -> remote
573        BrowserPane::Local => {
574            for name in filenames {
575                args.push(local_path.join(name).to_string_lossy().to_string());
576            }
577            let dest = format!("{}:{}", alias, remote_path);
578            args.push(dest);
579        }
580        // Download: remote files -> local
581        BrowserPane::Remote => {
582            let base = remote_path.trim_end_matches('/');
583            for name in filenames {
584                let rpath = format!("{}/{}", base, name);
585                args.push(format!("{}:{}", alias, rpath));
586            }
587            args.push(local_path.to_string_lossy().to_string());
588        }
589    }
590    args
591}
592
593/// Format a file size in human-readable form.
594pub fn format_size(bytes: u64) -> String {
595    if bytes >= 1024 * 1024 * 1024 {
596        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
597    } else if bytes >= 1024 * 1024 {
598        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
599    } else if bytes >= 1024 {
600        format!("{:.1} KB", bytes as f64 / 1024.0)
601    } else {
602        format!("{} B", bytes)
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    // =========================================================================
611    // shell_escape
612    // =========================================================================
613
614    #[test]
615    fn test_shell_escape_simple() {
616        assert_eq!(shell_escape("/home/user"), "'/home/user'");
617    }
618
619    #[test]
620    fn test_shell_escape_with_single_quote() {
621        assert_eq!(shell_escape("/home/it's"), "'/home/it'\\''s'");
622    }
623
624    #[test]
625    fn test_shell_escape_with_spaces() {
626        assert_eq!(shell_escape("/home/my dir"), "'/home/my dir'");
627    }
628
629    // =========================================================================
630    // parse_ls_output
631    // =========================================================================
632
633    #[test]
634    fn test_parse_ls_basic() {
635        let output = "\
636total 24
637drwxr-xr-x  2 user user 4096 Jan  1 12:00 subdir
638-rw-r--r--  1 user user  512 Jan  1 12:00 file.txt
639-rw-r--r--  1 user user 1.1K Jan  1 12:00 big.log
640";
641        let entries = parse_ls_output(output, true, BrowserSort::Name);
642        assert_eq!(entries.len(), 3);
643        assert_eq!(entries[0].name, "subdir");
644        assert!(entries[0].is_dir);
645        assert_eq!(entries[0].size, None);
646        // Files sorted alphabetically after dirs
647        assert_eq!(entries[1].name, "big.log");
648        assert!(!entries[1].is_dir);
649        assert_eq!(entries[1].size, Some(1126)); // 1.1 * 1024
650        assert_eq!(entries[2].name, "file.txt");
651        assert!(!entries[2].is_dir);
652        assert_eq!(entries[2].size, Some(512));
653    }
654
655    #[test]
656    fn test_parse_ls_hidden_filter() {
657        let output = "\
658total 8
659-rw-r--r--  1 user user  100 Jan  1 12:00 .hidden
660-rw-r--r--  1 user user  200 Jan  1 12:00 visible
661";
662        let entries = parse_ls_output(output, false, BrowserSort::Name);
663        assert_eq!(entries.len(), 1);
664        assert_eq!(entries[0].name, "visible");
665
666        let entries = parse_ls_output(output, true, BrowserSort::Name);
667        assert_eq!(entries.len(), 2);
668    }
669
670    #[test]
671    fn test_parse_ls_symlink_to_file_dereferenced() {
672        // With -L, symlink to file appears as regular file
673        let output = "\
674total 4
675-rw-r--r--  1 user user   11 Jan  1 12:00 link
676";
677        let entries = parse_ls_output(output, true, BrowserSort::Name);
678        assert_eq!(entries.len(), 1);
679        assert_eq!(entries[0].name, "link");
680        assert!(!entries[0].is_dir);
681    }
682
683    #[test]
684    fn test_parse_ls_symlink_to_dir_dereferenced() {
685        // With -L, symlink to directory appears as directory
686        let output = "\
687total 4
688drwxr-xr-x  3 user user 4096 Jan  1 12:00 link
689";
690        let entries = parse_ls_output(output, true, BrowserSort::Name);
691        assert_eq!(entries.len(), 1);
692        assert_eq!(entries[0].name, "link");
693        assert!(entries[0].is_dir);
694    }
695
696    #[test]
697    fn test_parse_ls_filename_with_spaces() {
698        let output = "\
699total 4
700-rw-r--r--  1 user user  100 Jan  1 12:00 my file name.txt
701";
702        let entries = parse_ls_output(output, true, BrowserSort::Name);
703        assert_eq!(entries.len(), 1);
704        assert_eq!(entries[0].name, "my file name.txt");
705    }
706
707    #[test]
708    fn test_parse_ls_empty() {
709        let output = "total 0\n";
710        let entries = parse_ls_output(output, true, BrowserSort::Name);
711        assert!(entries.is_empty());
712    }
713
714    // =========================================================================
715    // parse_human_size
716    // =========================================================================
717
718    #[test]
719    fn test_parse_human_size() {
720        assert_eq!(parse_human_size("512"), 512);
721        assert_eq!(parse_human_size("1.0K"), 1024);
722        assert_eq!(parse_human_size("1.5M"), 1572864);
723        assert_eq!(parse_human_size("2.0G"), 2147483648);
724    }
725
726    // =========================================================================
727    // format_size
728    // =========================================================================
729
730    #[test]
731    fn test_format_size() {
732        assert_eq!(format_size(0), "0 B");
733        assert_eq!(format_size(512), "512 B");
734        assert_eq!(format_size(1024), "1.0 KB");
735        assert_eq!(format_size(1536), "1.5 KB");
736        assert_eq!(format_size(1048576), "1.0 MB");
737        assert_eq!(format_size(1073741824), "1.0 GB");
738    }
739
740    // =========================================================================
741    // build_scp_args
742    // =========================================================================
743
744    #[test]
745    fn test_build_scp_args_upload() {
746        let args = build_scp_args(
747            "myhost",
748            BrowserPane::Local,
749            Path::new("/home/user/docs"),
750            "/remote/path/",
751            &["file.txt".to_string()],
752            false,
753        );
754        assert_eq!(
755            args,
756            vec!["--", "/home/user/docs/file.txt", "myhost:/remote/path/",]
757        );
758    }
759
760    #[test]
761    fn test_build_scp_args_download() {
762        let args = build_scp_args(
763            "myhost",
764            BrowserPane::Remote,
765            Path::new("/home/user/docs"),
766            "/remote/path",
767            &["file.txt".to_string()],
768            false,
769        );
770        assert_eq!(
771            args,
772            vec!["--", "myhost:/remote/path/file.txt", "/home/user/docs",]
773        );
774    }
775
776    #[test]
777    fn test_build_scp_args_spaces_in_path() {
778        let args = build_scp_args(
779            "myhost",
780            BrowserPane::Remote,
781            Path::new("/local"),
782            "/remote/my path",
783            &["my file.txt".to_string()],
784            false,
785        );
786        // No shell escaping: Command::arg() passes paths literally
787        assert_eq!(
788            args,
789            vec!["--", "myhost:/remote/my path/my file.txt", "/local",]
790        );
791    }
792
793    #[test]
794    fn test_build_scp_args_with_dirs() {
795        let args = build_scp_args(
796            "myhost",
797            BrowserPane::Local,
798            Path::new("/local"),
799            "/remote/",
800            &["mydir".to_string()],
801            true,
802        );
803        assert_eq!(args[0], "-r");
804    }
805
806    // =========================================================================
807    // list_local
808    // =========================================================================
809
810    #[test]
811    fn test_list_local_sorts_dirs_first() {
812        let base = std::env::temp_dir().join(format!("purple_fb_test_{}", std::process::id()));
813        let _ = std::fs::remove_dir_all(&base);
814        std::fs::create_dir_all(&base).unwrap();
815        std::fs::create_dir(base.join("zdir")).unwrap();
816        std::fs::write(base.join("afile.txt"), "hello").unwrap();
817        std::fs::write(base.join("bfile.txt"), "world").unwrap();
818
819        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
820        assert_eq!(entries.len(), 3);
821        assert!(entries[0].is_dir);
822        assert_eq!(entries[0].name, "zdir");
823        assert_eq!(entries[1].name, "afile.txt");
824        assert_eq!(entries[2].name, "bfile.txt");
825
826        let _ = std::fs::remove_dir_all(&base);
827    }
828
829    #[test]
830    fn test_list_local_hidden() {
831        let base = std::env::temp_dir().join(format!("purple_fb_hidden_{}", std::process::id()));
832        let _ = std::fs::remove_dir_all(&base);
833        std::fs::create_dir_all(&base).unwrap();
834        std::fs::write(base.join(".hidden"), "").unwrap();
835        std::fs::write(base.join("visible"), "").unwrap();
836
837        let entries = list_local(&base, false, BrowserSort::Name).unwrap();
838        assert_eq!(entries.len(), 1);
839        assert_eq!(entries[0].name, "visible");
840
841        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
842        assert_eq!(entries.len(), 2);
843
844        let _ = std::fs::remove_dir_all(&base);
845    }
846
847    // =========================================================================
848    // filter_ssh_warnings
849    // =========================================================================
850
851    #[test]
852    fn test_filter_ssh_warnings_filters_warnings() {
853        let stderr = "\
854** WARNING: connection is not using a post-quantum key exchange algorithm.
855** This session may be vulnerable to \"store now, decrypt later\" attacks.
856** The server may need to be upgraded. See https://openssh.com/pq.html
857scp: '/root/file.rpm': No such file or directory";
858        assert_eq!(
859            filter_ssh_warnings(stderr),
860            "scp: '/root/file.rpm': No such file or directory"
861        );
862    }
863
864    #[test]
865    fn test_filter_ssh_warnings_keeps_plain_error() {
866        let stderr = "scp: /etc/shadow: Permission denied\n";
867        assert_eq!(
868            filter_ssh_warnings(stderr),
869            "scp: /etc/shadow: Permission denied"
870        );
871    }
872
873    #[test]
874    fn test_filter_ssh_warnings_empty() {
875        assert_eq!(filter_ssh_warnings(""), "");
876        assert_eq!(filter_ssh_warnings("  \n  \n"), "");
877    }
878
879    #[test]
880    fn test_filter_ssh_warnings_warning_prefix() {
881        let stderr = "Warning: Permanently added '10.0.0.1' to the list of known hosts.\nPermission denied (publickey).";
882        assert_eq!(
883            filter_ssh_warnings(stderr),
884            "Permission denied (publickey)."
885        );
886    }
887
888    #[test]
889    fn test_filter_ssh_warnings_lowercase_see_https() {
890        let stderr = "For details, see https://openssh.com/legacy.html\nConnection refused";
891        assert_eq!(filter_ssh_warnings(stderr), "Connection refused");
892    }
893
894    #[test]
895    fn test_filter_ssh_warnings_only_warnings() {
896        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";
897        assert_eq!(filter_ssh_warnings(stderr), "");
898    }
899
900    // =========================================================================
901    // approximate_epoch (known dates)
902    // =========================================================================
903
904    #[test]
905    fn test_approximate_epoch_known_dates() {
906        // 2024-01-01 00:00 UTC = 1704067200
907        let ts = approximate_epoch(2024, 0, 1, 0, 0);
908        assert_eq!(ts, 1704067200);
909        // 2000-01-01 00:00 UTC = 946684800
910        let ts = approximate_epoch(2000, 0, 1, 0, 0);
911        assert_eq!(ts, 946684800);
912        // 1970-01-01 00:00 UTC = 0
913        assert_eq!(approximate_epoch(1970, 0, 1, 0, 0), 0);
914    }
915
916    #[test]
917    fn test_approximate_epoch_leap_year() {
918        // 2024-02-29 should differ from 2024-03-01 by 86400
919        let feb29 = approximate_epoch(2024, 1, 29, 0, 0);
920        let mar01 = approximate_epoch(2024, 2, 1, 0, 0);
921        assert_eq!(mar01 - feb29, 86400);
922    }
923
924    // =========================================================================
925    // epoch_to_year
926    // =========================================================================
927
928    #[test]
929    fn test_epoch_to_year() {
930        assert_eq!(epoch_to_year(0), 1970);
931        // 2023-01-01 00:00 UTC = 1672531200
932        assert_eq!(epoch_to_year(1672531200), 2023);
933        // 2024-01-01 00:00 UTC = 1704067200
934        assert_eq!(epoch_to_year(1704067200), 2024);
935        // 2024-12-31 23:59:59
936        assert_eq!(epoch_to_year(1735689599), 2024);
937        // 2025-01-01 00:00:00
938        assert_eq!(epoch_to_year(1735689600), 2025);
939    }
940
941    // =========================================================================
942    // parse_ls_date
943    // =========================================================================
944
945    #[test]
946    fn test_parse_ls_date_recent_format() {
947        // "Jan 15 12:34" - should return a timestamp
948        let ts = parse_ls_date("Jan", "15", "12:34");
949        assert!(ts.is_some());
950        let ts = ts.unwrap();
951        // Should be within the last year
952        let now = std::time::SystemTime::now()
953            .duration_since(std::time::UNIX_EPOCH)
954            .unwrap()
955            .as_secs() as i64;
956        assert!(ts <= now + 86400);
957        assert!(ts > now - 366 * 86400);
958    }
959
960    #[test]
961    fn test_parse_ls_date_old_format() {
962        let ts = parse_ls_date("Mar", "5", "2023");
963        assert!(ts.is_some());
964        let ts = ts.unwrap();
965        // Should be in 2023
966        assert_eq!(epoch_to_year(ts), 2023);
967    }
968
969    #[test]
970    fn test_parse_ls_date_invalid_month() {
971        assert!(parse_ls_date("Foo", "1", "12:00").is_none());
972    }
973
974    #[test]
975    fn test_parse_ls_date_invalid_day() {
976        assert!(parse_ls_date("Jan", "0", "12:00").is_none());
977        assert!(parse_ls_date("Jan", "32", "12:00").is_none());
978    }
979
980    #[test]
981    fn test_parse_ls_date_invalid_year() {
982        assert!(parse_ls_date("Jan", "1", "1969").is_none());
983    }
984
985    // =========================================================================
986    // format_relative_time
987    // =========================================================================
988
989    #[test]
990    fn test_format_relative_time_ranges() {
991        let now = std::time::SystemTime::now()
992            .duration_since(std::time::UNIX_EPOCH)
993            .unwrap()
994            .as_secs() as i64;
995        assert_eq!(format_relative_time(now), "just now");
996        assert_eq!(format_relative_time(now - 30), "just now");
997        assert_eq!(format_relative_time(now - 120), "2m ago");
998        assert_eq!(format_relative_time(now - 7200), "2h ago");
999        assert_eq!(format_relative_time(now - 86400 * 3), "3d ago");
1000    }
1001
1002    #[test]
1003    fn test_format_relative_time_old_date() {
1004        // A date far in the past should show short date format
1005        let old = approximate_epoch(2020, 5, 15, 0, 0);
1006        let result = format_relative_time(old);
1007        assert!(
1008            result.contains("2020"),
1009            "Expected year in '{}' for old date",
1010            result
1011        );
1012    }
1013
1014    #[test]
1015    fn test_format_relative_time_future() {
1016        let now = std::time::SystemTime::now()
1017            .duration_since(std::time::UNIX_EPOCH)
1018            .unwrap()
1019            .as_secs() as i64;
1020        // Future timestamp should not panic and should show date
1021        let result = format_relative_time(now + 86400 * 30);
1022        assert!(!result.is_empty());
1023    }
1024
1025    // =========================================================================
1026    // format_short_date
1027    // =========================================================================
1028
1029    #[test]
1030    fn test_format_short_date_different_year() {
1031        let ts = approximate_epoch(2020, 2, 15, 0, 0); // Mar 15 2020
1032        let result = format_short_date(ts);
1033        assert!(result.contains("2020"), "Expected year in '{}'", result);
1034        assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1035    }
1036
1037    #[test]
1038    fn test_format_short_date_leap_year() {
1039        // Mar 1 2024 (leap year, different year) should show "Mar 2024"
1040        let ts = approximate_epoch(2024, 2, 1, 0, 0);
1041        let result = format_short_date(ts);
1042        assert!(result.starts_with("Mar"), "Expected Mar in '{}'", result);
1043        assert!(result.contains("2024"), "Expected 2024 in '{}'", result);
1044        // Verify Feb 29 and Mar 1 are distinct days (86400 apart)
1045        let feb29 = approximate_epoch(2024, 1, 29, 12, 0);
1046        let mar01 = approximate_epoch(2024, 2, 1, 12, 0);
1047        let feb29_date = format_short_date(feb29);
1048        let mar01_date = format_short_date(mar01);
1049        assert!(
1050            feb29_date.starts_with("Feb"),
1051            "Expected Feb in '{}'",
1052            feb29_date
1053        );
1054        assert!(
1055            mar01_date.starts_with("Mar"),
1056            "Expected Mar in '{}'",
1057            mar01_date
1058        );
1059    }
1060
1061    // =========================================================================
1062    // sort_entries (date mode)
1063    // =========================================================================
1064
1065    #[test]
1066    fn test_sort_entries_date_dirs_first_newest_first() {
1067        let mut entries = vec![
1068            FileEntry {
1069                name: "old.txt".into(),
1070                is_dir: false,
1071                size: Some(100),
1072                modified: Some(1000),
1073            },
1074            FileEntry {
1075                name: "new.txt".into(),
1076                is_dir: false,
1077                size: Some(200),
1078                modified: Some(3000),
1079            },
1080            FileEntry {
1081                name: "mid.txt".into(),
1082                is_dir: false,
1083                size: Some(150),
1084                modified: Some(2000),
1085            },
1086            FileEntry {
1087                name: "adir".into(),
1088                is_dir: true,
1089                size: None,
1090                modified: Some(500),
1091            },
1092        ];
1093        sort_entries(&mut entries, BrowserSort::Date);
1094        assert!(entries[0].is_dir);
1095        assert_eq!(entries[0].name, "adir");
1096        assert_eq!(entries[1].name, "new.txt");
1097        assert_eq!(entries[2].name, "mid.txt");
1098        assert_eq!(entries[3].name, "old.txt");
1099    }
1100
1101    #[test]
1102    fn test_sort_entries_name_mode() {
1103        let mut entries = vec![
1104            FileEntry {
1105                name: "zebra.txt".into(),
1106                is_dir: false,
1107                size: Some(100),
1108                modified: Some(3000),
1109            },
1110            FileEntry {
1111                name: "alpha.txt".into(),
1112                is_dir: false,
1113                size: Some(200),
1114                modified: Some(1000),
1115            },
1116            FileEntry {
1117                name: "mydir".into(),
1118                is_dir: true,
1119                size: None,
1120                modified: Some(2000),
1121            },
1122        ];
1123        sort_entries(&mut entries, BrowserSort::Name);
1124        assert!(entries[0].is_dir);
1125        assert_eq!(entries[1].name, "alpha.txt");
1126        assert_eq!(entries[2].name, "zebra.txt");
1127    }
1128
1129    // =========================================================================
1130    // parse_ls_output with modified field
1131    // =========================================================================
1132
1133    #[test]
1134    fn test_parse_ls_output_populates_modified() {
1135        let output = "\
1136total 4
1137-rw-r--r--  1 user user  512 Jan  1 12:00 file.txt
1138";
1139        let entries = parse_ls_output(output, true, BrowserSort::Name);
1140        assert_eq!(entries.len(), 1);
1141        assert!(
1142            entries[0].modified.is_some(),
1143            "modified should be populated"
1144        );
1145    }
1146
1147    #[test]
1148    fn test_parse_ls_output_date_sort() {
1149        // Use year format to avoid ambiguity with current date
1150        let output = "\
1151total 12
1152-rw-r--r--  1 user user  100 Jan  1  2020 old.txt
1153-rw-r--r--  1 user user  200 Jun 15  2023 new.txt
1154-rw-r--r--  1 user user  150 Mar  5  2022 mid.txt
1155";
1156        let entries = parse_ls_output(output, true, BrowserSort::Date);
1157        assert_eq!(entries.len(), 3);
1158        // Should be sorted newest first (2023 > 2022 > 2020)
1159        assert_eq!(entries[0].name, "new.txt");
1160        assert_eq!(entries[1].name, "mid.txt");
1161        assert_eq!(entries[2].name, "old.txt");
1162    }
1163
1164    // =========================================================================
1165    // list_local with modified field
1166    // =========================================================================
1167
1168    #[test]
1169    fn test_list_local_populates_modified() {
1170        let base = std::env::temp_dir().join(format!("purple_fb_mtime_{}", std::process::id()));
1171        let _ = std::fs::remove_dir_all(&base);
1172        std::fs::create_dir_all(&base).unwrap();
1173        std::fs::write(base.join("test.txt"), "hello").unwrap();
1174
1175        let entries = list_local(&base, true, BrowserSort::Name).unwrap();
1176        assert_eq!(entries.len(), 1);
1177        assert!(
1178            entries[0].modified.is_some(),
1179            "modified should be populated for local files"
1180        );
1181
1182        let _ = std::fs::remove_dir_all(&base);
1183    }
1184
1185    // =========================================================================
1186    // epoch_to_year boundary
1187    // =========================================================================
1188
1189    #[test]
1190    fn test_epoch_to_year_2100_boundary() {
1191        let ts_2100 = approximate_epoch(2100, 0, 1, 0, 0);
1192        assert_eq!(epoch_to_year(ts_2100), 2100);
1193        assert_eq!(epoch_to_year(ts_2100 - 1), 2099);
1194        let mid_2100 = approximate_epoch(2100, 5, 15, 12, 0);
1195        assert_eq!(epoch_to_year(mid_2100), 2100);
1196    }
1197
1198    // =========================================================================
1199    // parse_ls_date edge cases
1200    // =========================================================================
1201
1202    #[test]
1203    fn test_parse_ls_date_midnight() {
1204        let ts = parse_ls_date("Jan", "1", "00:00");
1205        assert!(ts.is_some(), "00:00 should parse successfully");
1206        let ts = ts.unwrap();
1207        let now = std::time::SystemTime::now()
1208            .duration_since(std::time::UNIX_EPOCH)
1209            .unwrap()
1210            .as_secs() as i64;
1211        assert!(ts <= now + 86400);
1212        assert!(ts > now - 366 * 86400);
1213    }
1214
1215    // =========================================================================
1216    // sort_entries edge cases
1217    // =========================================================================
1218
1219    #[test]
1220    fn test_sort_entries_date_with_none_modified() {
1221        let mut entries = vec![
1222            FileEntry {
1223                name: "known.txt".into(),
1224                is_dir: false,
1225                size: Some(100),
1226                modified: Some(5000),
1227            },
1228            FileEntry {
1229                name: "unknown.txt".into(),
1230                is_dir: false,
1231                size: Some(200),
1232                modified: None,
1233            },
1234            FileEntry {
1235                name: "recent.txt".into(),
1236                is_dir: false,
1237                size: Some(300),
1238                modified: Some(9000),
1239            },
1240        ];
1241        sort_entries(&mut entries, BrowserSort::Date);
1242        assert_eq!(entries[0].name, "recent.txt");
1243        assert_eq!(entries[1].name, "known.txt");
1244        assert_eq!(entries[2].name, "unknown.txt");
1245    }
1246
1247    #[test]
1248    fn test_sort_entries_date_asc_oldest_first() {
1249        let mut entries = vec![
1250            FileEntry {
1251                name: "old.txt".into(),
1252                is_dir: false,
1253                size: Some(100),
1254                modified: Some(1000),
1255            },
1256            FileEntry {
1257                name: "new.txt".into(),
1258                is_dir: false,
1259                size: Some(200),
1260                modified: Some(3000),
1261            },
1262            FileEntry {
1263                name: "mid.txt".into(),
1264                is_dir: false,
1265                size: Some(150),
1266                modified: Some(2000),
1267            },
1268            FileEntry {
1269                name: "adir".into(),
1270                is_dir: true,
1271                size: None,
1272                modified: Some(500),
1273            },
1274        ];
1275        sort_entries(&mut entries, BrowserSort::DateAsc);
1276        assert!(entries[0].is_dir);
1277        assert_eq!(entries[0].name, "adir");
1278        assert_eq!(entries[1].name, "old.txt");
1279        assert_eq!(entries[2].name, "mid.txt");
1280        assert_eq!(entries[3].name, "new.txt");
1281    }
1282
1283    #[test]
1284    fn test_sort_entries_date_asc_none_modified_sorts_to_end() {
1285        let mut entries = vec![
1286            FileEntry {
1287                name: "known.txt".into(),
1288                is_dir: false,
1289                size: Some(100),
1290                modified: Some(5000),
1291            },
1292            FileEntry {
1293                name: "unknown.txt".into(),
1294                is_dir: false,
1295                size: Some(200),
1296                modified: None,
1297            },
1298            FileEntry {
1299                name: "old.txt".into(),
1300                is_dir: false,
1301                size: Some(300),
1302                modified: Some(1000),
1303            },
1304        ];
1305        sort_entries(&mut entries, BrowserSort::DateAsc);
1306        assert_eq!(entries[0].name, "old.txt");
1307        assert_eq!(entries[1].name, "known.txt");
1308        assert_eq!(entries[2].name, "unknown.txt"); // None sorts to end
1309    }
1310
1311    #[test]
1312    fn test_parse_ls_output_date_asc_sort() {
1313        let output = "\
1314total 12
1315-rw-r--r--  1 user user  100 Jan  1  2020 old.txt
1316-rw-r--r--  1 user user  200 Jun 15  2023 new.txt
1317-rw-r--r--  1 user user  150 Mar  5  2022 mid.txt
1318";
1319        let entries = parse_ls_output(output, true, BrowserSort::DateAsc);
1320        assert_eq!(entries.len(), 3);
1321        // Should be sorted oldest first (2020 < 2022 < 2023)
1322        assert_eq!(entries[0].name, "old.txt");
1323        assert_eq!(entries[1].name, "mid.txt");
1324        assert_eq!(entries[2].name, "new.txt");
1325    }
1326
1327    #[test]
1328    fn test_sort_entries_date_multiple_dirs() {
1329        let mut entries = vec![
1330            FileEntry {
1331                name: "old_dir".into(),
1332                is_dir: true,
1333                size: None,
1334                modified: Some(1000),
1335            },
1336            FileEntry {
1337                name: "new_dir".into(),
1338                is_dir: true,
1339                size: None,
1340                modified: Some(3000),
1341            },
1342            FileEntry {
1343                name: "mid_dir".into(),
1344                is_dir: true,
1345                size: None,
1346                modified: Some(2000),
1347            },
1348            FileEntry {
1349                name: "file.txt".into(),
1350                is_dir: false,
1351                size: Some(100),
1352                modified: Some(5000),
1353            },
1354        ];
1355        sort_entries(&mut entries, BrowserSort::Date);
1356        assert!(entries[0].is_dir);
1357        assert_eq!(entries[0].name, "new_dir");
1358        assert_eq!(entries[1].name, "mid_dir");
1359        assert_eq!(entries[2].name, "old_dir");
1360        assert_eq!(entries[3].name, "file.txt");
1361    }
1362
1363    // =========================================================================
1364    // format_relative_time boundaries
1365    // =========================================================================
1366
1367    #[test]
1368    fn test_format_relative_time_exactly_60s() {
1369        let now = std::time::SystemTime::now()
1370            .duration_since(std::time::UNIX_EPOCH)
1371            .unwrap()
1372            .as_secs() as i64;
1373        assert_eq!(format_relative_time(now - 60), "1m ago");
1374        assert_eq!(format_relative_time(now - 59), "just now");
1375    }
1376
1377    // =========================================================================
1378    // parse_ls_output date sort with dirs
1379    // =========================================================================
1380
1381    #[test]
1382    fn test_parse_ls_output_date_sort_with_dirs() {
1383        let output = "\
1384total 16
1385drwxr-xr-x  2 user user 4096 Jan  1  2020 old_dir
1386-rw-r--r--  1 user user  200 Jun 15  2023 new_file.txt
1387drwxr-xr-x  2 user user 4096 Dec  1  2023 new_dir
1388-rw-r--r--  1 user user  100 Mar  5  2022 old_file.txt
1389";
1390        let entries = parse_ls_output(output, true, BrowserSort::Date);
1391        assert_eq!(entries.len(), 4);
1392        assert!(entries[0].is_dir);
1393        assert_eq!(entries[0].name, "new_dir");
1394        assert!(entries[1].is_dir);
1395        assert_eq!(entries[1].name, "old_dir");
1396        assert_eq!(entries[2].name, "new_file.txt");
1397        assert_eq!(entries[3].name, "old_file.txt");
1398    }
1399}