use std::cmp::Ordering;
use std::collections::HashSet;
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::Folder { path, .. } => path.last().cloned().unwrap_or_default(),
Row::Entry(i) => entries.get(*i).map(leaf_name).unwrap_or_default(),
};
rows.sort_by(|a, b| 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 {
Folder { path: Vec<String>, expanded: bool },
Entry(usize),
}
impl Row {
pub fn depth(&self, entries: &[HurlEntry]) -> usize {
match self {
Self::Folder { path, .. } => path.len() - 1,
Self::Entry(i) => entries
.get(*i)
.map(|e| entry_path(&e.title).len() - 1)
.unwrap_or(0),
}
}
}
pub fn rows_for(entries: &[HurlEntry], expanded: &HashSet<Vec<String>>) -> Vec<Row> {
let items: Vec<(usize, Vec<String>)> = entries
.iter()
.enumerate()
.map(|(i, e)| {
let path = entry_path(&e.title);
(i, path[..path.len() - 1].to_vec())
})
.collect();
let mut rows = Vec::with_capacity(entries.len() + 1);
push_level(&items, &[], expanded, &mut rows);
rows
}
fn push_level(
items: &[(usize, Vec<String>)],
prefix: &[String],
expanded: &HashSet<Vec<String>>,
rows: &mut Vec<Row>,
) {
let depth = prefix.len();
let mut done: Vec<&String> = Vec::new();
for (i, folders) in items {
if folders.len() == depth {
rows.push(Row::Entry(*i));
continue;
}
let name = &folders[depth];
if done.contains(&name) {
continue;
}
done.push(name);
let mut path = prefix.to_vec();
path.push(name.clone());
let open = expanded.contains(&path);
rows.push(Row::Folder {
path: path.clone(),
expanded: open,
});
if open {
let inside: Vec<(usize, Vec<String>)> = items
.iter()
.filter(|(_, f)| f.len() > depth && &f[depth] == name)
.cloned()
.collect();
push_level(&inside, &path, expanded, rows);
}
}
}
pub fn ancestors_of(folder: &[String]) -> HashSet<Vec<String>> {
(0..folder.len()).map(|d| folder[..=d].to_vec()).collect()
}
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 open(paths: &[&str]) -> HashSet<Vec<String>> {
paths
.iter()
.map(|p| p.split('/').map(str::to_string).collect())
.collect()
}
fn folder(path: &str, expanded: bool) -> Row {
Row::Folder {
path: path.split('/').map(str::to_string).collect(),
expanded,
}
}
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, &open(&[]));
assert_eq!(
rows,
vec![Row::Entry(0), folder("Auth", false), folder("Files", false),]
);
}
#[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, &open(&[])),
vec![folder("Zed", false), Row::Entry(1), folder("Abe", false)]
);
}
#[test]
fn several_folders_can_be_open_at_once() {
let entries = vec![
entry("plain"),
entry("Auth/Login"),
entry("Auth/Tokens/Refresh"),
entry("Files/Upload/Big"),
];
let rows = rows_for(&entries, &open(&["Auth", "Files"]));
assert_eq!(
rows,
vec![
Row::Entry(0),
folder("Auth", true),
Row::Entry(1),
folder("Auth/Tokens", false),
folder("Files", true),
folder("Files/Upload", false),
],
"opening Auth should not have closed Files"
);
}
#[test]
fn a_folders_requests_are_gathered_under_it_even_when_the_file_interleaves_them() {
let entries = vec![
entry("Auth/Login"),
entry("Users/List"),
entry("Auth/Logout"),
];
assert_eq!(
rows_for(&entries, &open(&["Auth"])),
vec![
folder("Auth", true),
Row::Entry(0),
Row::Entry(2),
folder("Users", false),
]
);
}
#[test]
fn a_closed_folder_hides_its_subfolders_as_well_as_its_requests() {
let entries = vec![entry("Files/Upload/Big"), entry("Files/Upload/Small")];
assert_eq!(rows_for(&entries, &open(&[])), vec![folder("Files", false)]);
assert_eq!(
rows_for(&entries, &open(&["Files"])),
vec![folder("Files", true), folder("Files/Upload", false)]
);
assert_eq!(
rows_for(&entries, &open(&["Files", "Files/Upload"])),
vec![
folder("Files", true),
folder("Files/Upload", true),
Row::Entry(0),
Row::Entry(1),
]
);
}
#[test]
fn opening_a_folder_inside_a_closed_one_changes_nothing() {
let entries = vec![entry("Files/Upload/Big")];
assert_eq!(
rows_for(&entries, &open(&["Files/Upload"])),
vec![folder("Files", false)]
);
}
#[test]
fn depth_counts_the_folders_above_a_row() {
let entries = vec![entry("Files/Upload/Big"), entry("plain")];
assert_eq!(folder("Files", false).depth(&entries), 0);
assert_eq!(folder("Files/Upload", false).depth(&entries), 1);
assert_eq!(Row::Entry(0).depth(&entries), 2);
assert_eq!(Row::Entry(1).depth(&entries), 0);
}
#[test]
fn the_ancestors_of_a_folder_are_every_step_down_to_it() {
let path = vec!["Files".to_string(), "Upload".to_string()];
assert_eq!(ancestors_of(&path), open(&["Files", "Files/Upload"]));
assert!(ancestors_of(&[]).is_empty());
}
#[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_a_closed_folder_would_hide() {
let entries = vec![entry("Auth/Login"), entry("Files/Upload/Big")];
let browsing = rows_for(&entries, &open(&["Auth"]));
assert_eq!(
browsing,
vec![folder("Auth", true), Row::Entry(0), folder("Files", false)]
);
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_a_flat_list_of_matches() {
let entries = vec![entry("Zed/One"), entry("loose"), entry("Abe/Two")];
let mut rows = rows_matching(&entries, "");
sort_rows(&mut rows, &entries, SortMode::Alpha);
assert_eq!(rows, vec![Row::Entry(1), Row::Entry(0), Row::Entry(2)]);
sort_rows(&mut rows, &entries, SortMode::ReverseAlpha);
assert_eq!(rows, vec![Row::Entry(2), Row::Entry(0), Row::Entry(1)]);
}
#[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_matching(&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_matching(&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());
}
}