use std::cmp::Ordering;
use crate::hurl::HurlEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub enum SortMode {
#[default]
File,
Alpha,
ReverseAlpha,
}
impl SortMode {
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn next(self) -> Self {
match self {
Self::File => Self::Alpha,
Self::Alpha => Self::ReverseAlpha,
Self::ReverseAlpha => Self::File,
}
}
}
pub fn leaf_name(entry: &HurlEntry) -> String {
entry_path(&entry.title).pop().unwrap_or_default()
}
pub fn cmp_names(mode: SortMode, a: &str, b: &str) -> Ordering {
let (a, b) = (a.to_lowercase(), b.to_lowercase());
match mode {
SortMode::File => Ordering::Equal,
SortMode::Alpha => a.cmp(&b),
SortMode::ReverseAlpha => b.cmp(&a),
}
}
pub fn sort_rows(rows: &mut [Row], entries: &[HurlEntry], mode: SortMode) {
let name = |row: &Row| match row {
Row::Up => String::new(),
Row::Folder(n) => n.clone(),
Row::Entry(i) => entries.get(*i).map(leaf_name).unwrap_or_default(),
};
rows.sort_by(|a, b| {
let pinned = |r: &Row| u8::from(!matches!(r, Row::Up));
pinned(a)
.cmp(&pinned(b))
.then_with(|| cmp_names(mode, &name(a), &name(b)))
});
}
pub fn entry_path(title: &str) -> Vec<String> {
let segs: Vec<String> = title
.split('/')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if segs.is_empty() {
vec![String::new()]
} else {
segs
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Row {
Up,
Folder(String),
Entry(usize),
}
pub fn rows_for(entries: &[HurlEntry], folder: &[String]) -> Vec<Row> {
let mut rows = Vec::with_capacity(entries.len() + 1);
if !folder.is_empty() {
rows.push(Row::Up);
}
for (i, e) in entries.iter().enumerate() {
let path = entry_path(&e.title);
if path.len() <= folder.len() || path[..folder.len()] != *folder {
continue;
}
if path.len() == folder.len() + 1 {
rows.push(Row::Entry(i));
} else {
let row = Row::Folder(path[folder.len()].clone());
if !rows.contains(&row) {
rows.push(row);
}
}
}
rows
}
pub fn rows_matching(entries: &[HurlEntry], query: &str) -> Vec<Row> {
let needle = query.trim().to_lowercase();
entries
.iter()
.enumerate()
.filter(|(_, e)| e.title.to_lowercase().contains(&needle))
.map(|(i, _)| Row::Entry(i))
.collect()
}
pub fn folder_of(entries: &[HurlEntry], idx: usize) -> Vec<String> {
let Some(e) = entries.get(idx) else {
return Vec::new();
};
let mut path = entry_path(&e.title);
path.pop();
path
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(title: &str) -> HurlEntry {
HurlEntry {
title: title.to_string(),
method: "GET".to_string(),
url: "http://x".to_string(),
..Default::default()
}
}
#[test]
fn entry_path_splits_on_slash_and_trims_segments() {
assert_eq!(entry_path("Auth / Login"), vec!["Auth", "Login"]);
assert_eq!(
entry_path("Auth/Tokens/Refresh"),
vec!["Auth", "Tokens", "Refresh"]
);
assert_eq!(entry_path("plain"), vec!["plain"]);
}
#[test]
fn entry_path_of_an_untitled_or_slash_only_title_is_one_empty_segment() {
assert_eq!(entry_path(""), vec![""]);
assert_eq!(entry_path("///"), vec![""]);
}
#[test]
fn root_rows_interleave_folders_and_requests_in_file_order() {
let entries = vec![
entry("plain"),
entry("Auth/Login"),
entry("Auth/Logout"),
entry("Files/Upload/Big"),
];
let rows = rows_for(&entries, &[]);
assert_eq!(
rows,
vec![
Row::Entry(0),
Row::Folder("Auth".into()),
Row::Folder("Files".into()),
]
);
}
#[test]
fn a_folder_keeps_the_position_of_its_first_request() {
let entries = vec![entry("Zed/One"), entry("loose"), entry("Abe/Two")];
assert_eq!(
rows_for(&entries, &[]),
vec![
Row::Folder("Zed".into()),
Row::Entry(1),
Row::Folder("Abe".into()),
]
);
}
#[test]
fn descending_a_folder_shows_its_direct_children_with_an_up_row() {
let entries = vec![
entry("plain"),
entry("Auth/Login"),
entry("Auth/Tokens/Refresh"),
entry("Auth/Logout"),
];
let rows = rows_for(&entries, &["Auth".to_string()]);
assert_eq!(
rows,
vec![
Row::Up,
Row::Entry(1),
Row::Folder("Tokens".into()),
Row::Entry(3)
]
);
}
#[test]
fn nested_folders_stay_flat_at_each_level() {
let entries = vec![entry("Files/Upload/Big"), entry("Files/Upload/Small")];
let rows = rows_for(&entries, &["Files".to_string(), "Upload".to_string()]);
assert_eq!(rows, vec![Row::Up, Row::Entry(0), Row::Entry(1)]);
}
#[test]
fn a_filtered_list_is_flat_and_matches_on_the_whole_title() {
let entries = vec![
entry("plain"),
entry("Auth/Login"),
entry("Auth/Logout"),
entry("Files/Upload/Big"),
];
assert_eq!(
rows_matching(&entries, "log"),
vec![Row::Entry(1), Row::Entry(2)]
);
assert_eq!(
rows_matching(&entries, "auth/"),
vec![Row::Entry(1), Row::Entry(2)]
);
assert_eq!(rows_matching(&entries, "BIG"), vec![Row::Entry(3)]);
assert!(rows_matching(&entries, "nothing").is_empty());
}
#[test]
fn a_filter_reaches_requests_the_current_folder_would_hide() {
let entries = vec![entry("Auth/Login"), entry("Files/Upload/Big")];
let browsing = rows_for(&entries, &["Auth".to_string()]);
assert_eq!(browsing, vec![Row::Up, Row::Entry(0)]);
assert_eq!(rows_matching(&entries, "upload"), vec![Row::Entry(1)]);
}
#[test]
fn an_all_whitespace_query_matches_everything_rather_than_nothing() {
let entries = vec![entry("Auth/Login"), entry("plain")];
assert_eq!(
rows_matching(&entries, " "),
vec![Row::Entry(0), Row::Entry(1)]
);
}
#[test]
fn sorting_orders_folders_and_requests_together_and_pins_the_up_row() {
let entries = vec![entry("Zed/One"), entry("loose"), entry("Abe/Two")];
let mut rows = rows_for(&entries, &[]);
sort_rows(&mut rows, &entries, SortMode::Alpha);
assert_eq!(
rows,
vec![
Row::Folder("Abe".into()),
Row::Entry(1),
Row::Folder("Zed".into()),
],
"folders and requests sort into one A-Z run, not separate blocks"
);
sort_rows(&mut rows, &entries, SortMode::ReverseAlpha);
assert_eq!(
rows,
vec![
Row::Folder("Zed".into()),
Row::Entry(1),
Row::Folder("Abe".into()),
]
);
let mut nested = rows_for(&entries, &["Zed".to_string()]);
sort_rows(&mut nested, &entries, SortMode::ReverseAlpha);
assert_eq!(nested.first(), Some(&Row::Up));
}
#[test]
fn file_order_is_restored_by_switching_back_rather_than_left_half_sorted() {
let entries = vec![entry("Zed/One"), entry("loose"), entry("Abe/Two")];
let mut rows = rows_for(&entries, &[]);
let original = rows.clone();
sort_rows(&mut rows, &entries, SortMode::Alpha);
sort_rows(&mut rows, &entries, SortMode::File);
assert_ne!(rows, original);
let mut fresh = rows_for(&entries, &[]);
sort_rows(&mut fresh, &entries, SortMode::File);
assert_eq!(fresh, original);
}
#[test]
fn the_sort_button_cycles_back_to_file_order() {
assert_eq!(SortMode::default(), SortMode::File);
assert_eq!(SortMode::File.next(), SortMode::Alpha);
assert_eq!(SortMode::Alpha.next(), SortMode::ReverseAlpha);
assert_eq!(SortMode::ReverseAlpha.next(), SortMode::File);
}
#[test]
fn folder_of_returns_the_parent_path_of_an_entry() {
let entries = vec![entry("plain"), entry("Auth/Tokens/Refresh")];
assert_eq!(folder_of(&entries, 0), Vec::<String>::new());
assert_eq!(
folder_of(&entries, 1),
vec!["Auth".to_string(), "Tokens".to_string()]
);
assert_eq!(folder_of(&entries, 99), Vec::<String>::new());
}
}