paperboy 0.5.5

A Rust TUI API tester
//! Native OS file/folder pickers for the GUI.
//!
//! The terminal UI has its own in-app browser overlay (the right UX for a
//! terminal), so these helpers are GUI-only. They wrap `rfd`'s **synchronous**
//! `FileDialog` (its XDG-portal backend blocks on `pollster`, needing no system
//! GTK libraries) -- but never on the thread drawing the window.
//!
//! # Why the dialog runs on its own thread
//!
//! The synchronous call blocks until the user chooses a file. Called straight
//! from the egui update closure — as every one of these used to be — that
//! blocks the *whole* frame loop: the window stops repainting for as long as
//! the dialog is open, and the desktop eventually offers to force-quit the
//! "not responding" application. It also stalls every other per-frame poll,
//! so a report finishing while the user stood in a save dialog couldn't be
//! collected, and the export that followed reported nothing to export.
//!
//! So a picker is *requested*, not called: [`spawn`] runs the blocking dialog
//! on a worker thread and returns a [`PendingPick`] handle, which the update
//! loop polls with [`PendingPick::take`] once per frame and applies when it
//! resolves. This mirrors how a report run is already driven.
//!
//! This relies on the dialog being safe to open off the main thread, which
//! holds for the XDG-portal backend (it is D-Bus traffic, not a native toolkit
//! window). A macOS build would need `AsyncFileDialog` instead, as AppKit
//! panels are main-thread-only.
//!
//! A picker is only ever triggered by a button/menu click — but a test can
//! click one, so [`spawn`] and [`error_alert`] open nothing under `cfg(test)`:
//! the suite must not put a window in front of whoever is running it.

use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, TryRecvError};

/// Which dialog to open, together with everything it needs. Owned (rather than
/// borrowed) because it crosses to a worker thread.
///
/// Its contents are only ever read by [`spawn_dialog`], which does not exist
/// under `cfg(test)` — the suite must not put a native window in front of
/// whoever is running it — so under test the fields are carried and never
/// looked at. They are still what the shipped binary opens the dialog with.
#[cfg_attr(test, allow(dead_code))]
pub enum PickKind {
    File {
        filters: Vec<(String, Vec<String>)>,
    },
    Folder,
    Save {
        default_name: String,
        filters: Vec<(String, Vec<String>)>,
    },
}

/// A dialog currently open on a worker thread.
///
/// `A` is the caller's own "what to do with this path" tag: the click that
/// opened the dialog knows which field or tab it was for, and by the time the
/// path arrives — many frames later — that context is long gone from the
/// stack, so it rides along here.
pub struct PendingPick<A> {
    rx: Receiver<Option<PathBuf>>,
    /// `Option` only so `take` can move it out on completion.
    action: Option<A>,
}

impl<A> PendingPick<A> {
    /// The waiting action, for tests that need to tell one pending dialog from
    /// another without opening either.
    #[cfg(test)]
    pub fn action(&self) -> Option<&A> {
        self.action.as_ref()
    }

    /// Poll the dialog. `None` means "still open" — the common case, since a
    /// user takes many frames to choose.
    ///
    /// Returns `Some((action, None))` on cancel rather than swallowing it: some
    /// callers have state to unwind when the user backs out.
    pub fn take(&mut self) -> Option<(A, Option<PathBuf>)> {
        match self.rx.try_recv() {
            // Cleaned here because this is the one door every dialog result
            // comes through: a chooser that hands back `collection.hurl/`
            // otherwise leaves the caller holding a path nothing can be
            // written to. See `shared_utils::file_path`.
            Ok(path) => Some((
                self.action.take()?,
                path.map(crate::shared_utils::file_path),
            )),
            // The worker thread vanished without answering. Treat it exactly as
            // a cancel: a lost dialog must not wedge the picker slot shut.
            Err(TryRecvError::Disconnected) => Some((self.action.take()?, None)),
            Err(TryRecvError::Empty) => None,
        }
    }
}

/// Open a dialog on a worker thread, to be polled with [`PendingPick::take`].
///
/// Under `cfg(test)` no dialog is opened at all. The suite runs while its
/// author is doing something else, and a native chooser puts a window in front
/// of whatever that is and takes the keyboard with it; a test has nobody to
/// answer it either, so it was only ever an interruption. The handle returned
/// still behaves like a real one — it resolves, as a cancel — so the code that
/// polls and unwinds a pick stays exercised.
pub fn spawn<A>(kind: PickKind, title: &str, dir: Option<&Path>, action: A) -> PendingPick<A> {
    let (tx, rx) = std::sync::mpsc::channel();
    #[cfg(test)]
    {
        let _ = (kind, title, dir);
        drop(tx);
    }
    #[cfg(not(test))]
    spawn_dialog(kind, title.to_string(), dir.map(Path::to_path_buf), tx);
    PendingPick {
        rx,
        action: Some(action),
    }
}

/// A handle that has *already* answered, for tests: `spawn` opens no dialog
/// under `cfg(test)` and so can only ever resolve as a cancel, which leaves the
/// "the user actually chose something" half of every caller unexercised.
#[cfg(test)]
pub fn resolved<A>(action: A, path: Option<PathBuf>) -> PendingPick<A> {
    let (tx, rx) = std::sync::mpsc::channel();
    let _ = tx.send(path);
    PendingPick {
        rx,
        action: Some(action),
    }
}

/// The blocking half of [`spawn`], on its own thread.
#[cfg(not(test))]
fn spawn_dialog(
    kind: PickKind,
    title: String,
    dir: Option<PathBuf>,
    tx: std::sync::mpsc::Sender<Option<PathBuf>>,
) {
    std::thread::spawn(move || {
        let picked = match kind {
            PickKind::File { filters } => {
                with_owned_filters(base(&title, dir.as_deref()), &filters).pick_file()
            }
            PickKind::Folder => base(&title, dir.as_deref()).pick_folder(),
            PickKind::Save {
                default_name,
                filters,
            } => {
                let d = with_owned_filters(base(&title, dir.as_deref()), &filters);
                let d = if default_name.is_empty() {
                    d
                } else {
                    d.set_file_name(default_name)
                };
                d.save_file()
            }
        };
        // The receiver is gone if the window closed while the dialog was up;
        // there is nothing left to tell, so the error is genuinely ignorable.
        let _ = tx.send(picked);
    });
}

/// Borrowed [`Filter`] rows in the owned form [`PickKind`] needs.
pub fn owned_filters(filters: &[Filter]) -> Vec<(String, Vec<String>)> {
    filters
        .iter()
        .map(|(n, e)| {
            (
                (*n).to_string(),
                e.iter().map(|x| (*x).to_string()).collect(),
            )
        })
        .collect()
}

// Only reached from `spawn_dialog`, which `cfg(test)` leaves out.
#[cfg_attr(test, allow(dead_code))]
fn with_owned_filters(
    mut d: rfd::FileDialog,
    filters: &[(String, Vec<String>)],
) -> rfd::FileDialog {
    for (name, exts) in filters {
        if exts.len() == 1 && exts[0] == "*" {
            continue; // "all files" — no restrictive filter
        }
        d = d.add_filter(name, exts);
    }
    d
}

/// A named group of extensions offered as a filter row in the picker
/// (`("Hurl / Postman", &["hurl", "json"])`). An entry of `&["*"]` is treated as
/// "all files" and adds no restrictive filter.
pub type Filter<'a> = (&'a str, &'a [&'a str]);

// Only reached from `spawn_dialog`, which `cfg(test)` leaves out.
#[cfg_attr(test, allow(dead_code))]
fn base(title: &str, dir: Option<&Path>) -> rfd::FileDialog {
    let mut d = rfd::FileDialog::new().set_title(title);
    // Seed the starting directory from a sensible context (the last-used file's
    // folder, the collection's directory, …) when the caller has one.
    if let Some(dir) = dir.filter(|d| d.is_dir()) {
        d = d.set_directory(dir);
    }
    d
}

/// Show a native error alert (used to report a failed open/save now that the
/// old in-app text dialog — which showed the error inline — is gone).
pub fn error_alert(title: &str, message: &str) {
    // Same reasoning as `spawn`: a test must not put a window on the screen of
    // whoever is running it, and there is nobody there to dismiss it.
    #[cfg(test)]
    {
        let _ = (title, message);
        return;
    }
    #[cfg(not(test))]
    rfd::MessageDialog::new()
        .set_level(rfd::MessageLevel::Error)
        .set_title(title)
        .set_description(message)
        .show();
}

/// The directory to seed a picker from, given an optional current path string.
/// Returns the file's parent (for a file path) or the path itself (for a
/// directory), so re-opening a picker lands where the user last was.
pub fn seed_dir(current: &str) -> Option<PathBuf> {
    if current.is_empty() {
        return None;
    }
    let p = PathBuf::from(current);
    if p.is_dir() {
        Some(p)
    } else {
        p.parent().map(Path::to_path_buf)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Build a handle around a channel the test drives itself, standing in for
    /// the worker thread. Opening a real dialog in a test is not an option.
    fn pending<A>(action: A) -> (std::sync::mpsc::Sender<Option<PathBuf>>, PendingPick<A>) {
        let (tx, rx) = std::sync::mpsc::channel();
        (
            tx,
            PendingPick {
                rx,
                action: Some(action),
            },
        )
    }

    /// The usual case by far: the user is still looking at the dialog, and the
    /// frame must carry on without them.
    #[test]
    fn an_open_dialog_reports_nothing_and_keeps_its_action() {
        let (_tx, mut p) = pending("open");
        assert!(p.take().is_none());
        assert!(p.take().is_none(), "and stays pollable");
    }

    #[test]
    fn a_chosen_path_arrives_with_the_action_that_asked_for_it() {
        let (tx, mut p) = pending("open");
        tx.send(Some(PathBuf::from("/tmp/a.hurl"))).unwrap();
        let (action, path) = p.take().expect("resolved");
        assert_eq!(action, "open");
        assert_eq!(path, Some(PathBuf::from("/tmp/a.hurl")));
    }

    /// A cancel is delivered, not swallowed: callers may have state to unwind.
    #[test]
    fn a_cancel_is_reported_as_a_resolved_dialog_with_no_path() {
        let (tx, mut p) = pending("save");
        tx.send(None).unwrap();
        let (action, path) = p.take().expect("resolved");
        assert_eq!(action, "save");
        assert_eq!(path, None);
    }

    /// If the worker thread dies without answering, the picker slot must still
    /// come unstuck -- otherwise the menu item is dead for the rest of the run.
    #[test]
    fn a_lost_dialog_resolves_like_a_cancel_rather_than_hanging() {
        let (tx, mut p) = pending("open");
        drop(tx);
        let (_, path) = p.take().expect("resolved rather than pending forever");
        assert_eq!(path, None);
    }
}