use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use crate::git_remote::GitOrigin;
use crate::hurl::{HurlEntry, RunStatus, collection_to_hurl};
use crate::tree::{self, Row};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WsTitle {
pub name: String,
pub url: String,
pub method: String,
}
#[derive(Debug, Clone, Default)]
pub struct RunRecord {
key: String,
last_run: RunStatus,
last_response: Option<crate::http::ApiResponse>,
}
fn run_key(entry: &HurlEntry) -> String {
format!("{}\u{1}{}\u{1}{}", entry.title, entry.method, entry.url)
}
fn ws_request_title(entry: &HurlEntry) -> WsTitle {
WsTitle {
name: entry.title.clone(),
url: entry.url.clone(),
method: entry.method.clone(),
}
}
fn ws_leaf_label(title: &str, url: &str) -> String {
let leaf = crate::tree::entry_path(title).pop().unwrap_or_default();
if leaf.is_empty() {
url.to_string()
} else {
leaf
}
}
fn read_collection_labels(path: &Path) -> Vec<WsTitle> {
std::fs::read_to_string(path)
.map(|content| {
crate::postman::parse_collection(&content)
.iter()
.map(ws_request_title)
.collect()
})
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WsRow {
Folder {
path: PathBuf,
name: String,
depth: usize,
expanded: bool,
},
Collection {
path: PathBuf,
name: String,
depth: usize,
open: bool,
},
Report {
path: PathBuf,
name: String,
depth: usize,
},
Environment {
path: PathBuf,
name: String,
depth: usize,
},
RequestFolder {
collection: PathBuf,
path: PathBuf,
name: String,
depth: usize,
expanded: bool,
},
Request {
collection: PathBuf,
idx: usize,
name: String,
method: String,
depth: usize,
loaded: bool,
},
}
impl WsRow {
pub fn path(&self) -> &Path {
match self {
WsRow::Folder { path, .. }
| WsRow::Collection { path, .. }
| WsRow::Report { path, .. }
| WsRow::Environment { path, .. }
| WsRow::RequestFolder { path, .. } => path,
WsRow::Request { collection, .. } => collection,
}
}
pub fn depth(&self) -> usize {
match self {
WsRow::Folder { depth, .. }
| WsRow::Collection { depth, .. }
| WsRow::Report { depth, .. }
| WsRow::Environment { depth, .. }
| WsRow::RequestFolder { depth, .. }
| WsRow::Request { depth, .. } => *depth,
}
}
}
pub fn request_folder_path(collection: &Path, folder: &[String]) -> PathBuf {
let mut path = collection.to_path_buf();
for seg in folder {
let safe: String = seg
.chars()
.map(|c| if std::path::is_separator(c) { '_' } else { c })
.collect();
path.push(match safe.trim_matches('.') {
"" => "_",
_ => safe.as_str(),
});
}
path
}
const WS_SCAN_TTL: Duration = Duration::from_millis(300);
#[derive(Clone)]
struct WsScan {
root: PathBuf,
filter_hurl_json: bool,
taken_at: Instant,
generation: u64,
entries: Vec<crate::workspace::WsEntry>,
}
#[derive(Clone)]
pub struct Collection {
pub id: u64,
pub name: String,
pub entries: Vec<HurlEntry>,
pub selected_entry: usize,
pub linked_env_id: Option<u64>,
pub path: Option<PathBuf>,
pub git_origin: Option<GitOrigin>,
pub request_json_buf: String,
pub request_json_for: Option<usize>,
pub captures: HashMap<String, String>,
pub folder: Vec<String>,
pub list_cursor: usize,
pub deleted_entries: Vec<(usize, HurlEntry)>,
pub workspace_root: Option<PathBuf>,
pub workspace_filter_hurl_json: bool,
pub workspace_auto_prompt_dismissed: bool,
pub workspace_downloaded_from_git: bool,
pub workspace_git_origin: Option<crate::remote_flow::WorkspaceGitOrigin>,
pub workspace_expanded: HashSet<PathBuf>,
pub workspace_selected: Option<PathBuf>,
pub workspace_titles: HashMap<PathBuf, Vec<WsTitle>>,
workspace_scan: RefCell<Option<WsScan>>,
pub workspace_pending: HashMap<PathBuf, Vec<HurlEntry>>,
pub workspace_runs: HashMap<PathBuf, Vec<RunRecord>>,
}
static NEXT_COLLECTION_ID: AtomicU64 = AtomicU64::new(1);
pub fn next_collection_id() -> u64 {
NEXT_COLLECTION_ID.fetch_add(1, Ordering::Relaxed)
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
fn write_hurl(path: &Path, text: &str) -> Result<(), String> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
}
impl Collection {
pub fn new(name: String, entries: Vec<HurlEntry>) -> Self {
let mut c = Self {
id: next_collection_id(),
name,
entries,
selected_entry: 0,
linked_env_id: None,
path: None,
git_origin: None,
request_json_buf: String::new(),
request_json_for: None,
captures: HashMap::new(),
folder: Vec::new(),
list_cursor: 0,
deleted_entries: Vec::new(),
workspace_root: None,
workspace_filter_hurl_json: true,
workspace_auto_prompt_dismissed: false,
workspace_downloaded_from_git: false,
workspace_git_origin: None,
workspace_expanded: HashSet::new(),
workspace_selected: None,
workspace_titles: HashMap::new(),
workspace_scan: RefCell::new(None),
workspace_pending: HashMap::new(),
workspace_runs: HashMap::new(),
};
c.sync_folder_to_selected();
c
}
pub fn to_hurl(&self) -> String {
collection_to_hurl(&self.entries)
}
pub fn first_empty_file_field(&self) -> Option<(String, String)> {
self.entries.iter().find_map(|e| {
e.first_empty_file_field()
.map(|k| (e.title.clone(), k.to_string()))
})
}
pub fn invalidate_request_json(&mut self) {
self.request_json_buf.clear();
self.request_json_for = None;
}
pub fn rows(&self) -> Vec<Row> {
tree::rows_for(&self.entries, &self.folder)
}
pub fn is_workspace(&self) -> bool {
self.workspace_root.is_some()
}
pub fn ws_rows(&self) -> Vec<WsRow> {
self.ws_rows_at(Instant::now())
}
pub(crate) fn ws_rows_at(&self, now: Instant) -> Vec<WsRow> {
self.ws_rows_as_of(now, crate::workspace::tree_generation())
}
pub(crate) fn ws_rows_as_of(&self, now: Instant, generation: u64) -> Vec<WsRow> {
let Some(root) = &self.workspace_root else {
return Vec::new();
};
self.refresh_scan(root, now, generation);
let scan = self.workspace_scan.borrow();
let full_tree = scan
.as_ref()
.map(|s| s.entries.as_slice())
.unwrap_or_default();
let mut out = Vec::new();
let mut ancestor_at: Vec<Option<PathBuf>> = Vec::new();
for entry in full_tree {
let d = entry.depth;
if ancestor_at.len() > d {
ancestor_at.truncate(d);
}
let visible = ancestor_at.iter().all(|opt| {
opt.as_ref()
.is_some_and(|p| self.workspace_expanded.contains(p))
});
if entry.is_dir {
if ancestor_at.len() == d {
ancestor_at.push(Some(entry.path.clone()));
} else {
ancestor_at[d] = Some(entry.path.clone());
}
if visible {
let expanded = self.workspace_expanded.contains(&entry.path);
out.push(WsRow::Folder {
path: entry.path.clone(),
name: entry.display_name.clone(),
depth: d,
expanded,
});
}
} else if visible {
if crate::workspace::is_report_file(&entry.path) {
out.push(WsRow::Report {
path: entry.path.clone(),
name: entry.display_name.clone(),
depth: d,
});
} else if crate::workspace::is_env_file(&entry.path) {
out.push(WsRow::Environment {
path: entry.path.clone(),
name: entry.display_name.clone(),
depth: d,
});
} else {
let expanded = self.workspace_expanded.contains(&entry.path);
out.push(WsRow::Collection {
path: entry.path.clone(),
name: entry.display_name.clone(),
depth: d,
open: expanded,
});
if expanded {
out.extend(self.request_rows_for(&entry.path, d + 1));
}
}
}
}
out
}
fn refresh_scan(&self, root: &Path, now: Instant, generation: u64) {
let mut slot = self.workspace_scan.borrow_mut();
let usable = slot.as_ref().is_some_and(|s| {
s.root == root
&& s.filter_hurl_json == self.workspace_filter_hurl_json
&& s.generation == generation
&& now.saturating_duration_since(s.taken_at) < WS_SCAN_TTL
});
if usable {
return;
}
*slot = Some(WsScan {
root: root.to_path_buf(),
filter_hurl_json: self.workspace_filter_hurl_json,
taken_at: now,
generation,
entries: crate::workspace::scan_workspace(root, self.workspace_filter_hurl_json),
});
}
pub fn workspace_env_files(&self) -> Vec<PathBuf> {
self.workspace_env_files_as_of(Instant::now(), crate::workspace::tree_generation())
}
pub(crate) fn workspace_env_files_as_of(&self, now: Instant, generation: u64) -> Vec<PathBuf> {
let Some(root) = self.workspace_root.clone() else {
return Vec::new();
};
self.refresh_scan(&root, now, generation);
let scan = self.workspace_scan.borrow();
scan.as_ref()
.map(|s| {
s.entries
.iter()
.filter(|e| !e.is_dir && crate::workspace::is_env_file(&e.path))
.map(|e| e.path.clone())
.collect()
})
.unwrap_or_default()
}
fn request_rows_for(&self, path: &Path, depth: usize) -> Vec<WsRow> {
let listing: Vec<(usize, String, String, String)> = if self.path.as_deref() == Some(path) {
self.entries
.iter()
.enumerate()
.map(|(idx, e)| (idx, e.title.clone(), e.url.clone(), e.method.clone()))
.collect()
} else {
match self.workspace_titles.get(path) {
Some(titles) => titles
.iter()
.enumerate()
.map(|(idx, t)| (idx, t.name.clone(), t.url.clone(), t.method.clone()))
.collect(),
None => return Vec::new(),
}
};
let loaded = self.path.as_deref() == Some(path);
let mut out = Vec::new();
self.push_request_rows(path, &listing, &[], depth, loaded, &mut out);
out
}
fn push_request_rows(
&self,
path: &Path,
listing: &[(usize, String, String, String)],
folder: &[String],
depth: usize,
loaded: bool,
out: &mut Vec<WsRow>,
) {
let mut seen: Vec<String> = Vec::new();
let mut leaves: Vec<&(usize, String, String, String)> = Vec::new();
for item in listing {
let segs = tree::entry_path(&item.1);
if segs.len() <= folder.len() || segs[..folder.len()] != *folder {
continue;
}
if segs.len() == folder.len() + 1 {
leaves.push(item);
} else if !seen.contains(&segs[folder.len()]) {
seen.push(segs[folder.len()].clone());
}
}
for name in seen {
let mut child = folder.to_vec();
child.push(name.clone());
let key = request_folder_path(path, &child);
let expanded = self.workspace_expanded.contains(&key);
out.push(WsRow::RequestFolder {
collection: path.to_path_buf(),
path: key,
name,
depth,
expanded,
});
if expanded {
self.push_request_rows(path, listing, &child, depth + 1, loaded, out);
}
}
for (idx, title, url, method) in leaves {
out.push(WsRow::Request {
collection: path.to_path_buf(),
idx: *idx,
name: ws_leaf_label(title, url),
method: method.clone(),
depth,
loaded,
});
}
}
pub fn snapshot_loaded_titles(&mut self) {
if let Some(path) = self.path.clone() {
let titles = self.entries.iter().map(ws_request_title).collect();
self.workspace_titles.insert(path, titles);
}
}
pub fn load_workspace_file(&mut self, path: PathBuf) -> std::io::Result<()> {
let entries = match self.workspace_pending.get(&path) {
Some(parked) => parked.clone(),
None => crate::postman::parse_collection(&std::fs::read_to_string(&path)?),
};
self.workspace_pending.remove(&path);
self.park_pending_edits();
self.park_run_results();
self.snapshot_loaded_titles();
self.entries = entries;
self.restore_run_results(&path);
self.selected_entry = 0;
self.path = Some(path);
self.invalidate_request_json();
self.sync_folder_to_selected();
self.expand_ancestors_for_path();
self.sync_ws_cursor();
Ok(())
}
fn park_pending_edits(&mut self) {
if self.workspace_root.is_none() || !self.has_unsaved_edits() {
return;
}
if let Some(path) = self.path.clone() {
self.workspace_pending.insert(path, self.entries.clone());
}
}
fn park_run_results(&mut self) {
if self.workspace_root.is_none() {
return;
}
let Some(path) = self.path.clone() else {
return;
};
let records: Vec<RunRecord> = self
.entries
.iter()
.map(|e| RunRecord {
key: run_key(e),
last_run: e.last_run,
last_response: e.last_response.clone(),
})
.collect();
if records
.iter()
.all(|r| r.last_run == RunStatus::NotRun && r.last_response.is_none())
{
self.workspace_runs.remove(&path);
} else {
self.workspace_runs.insert(path, records);
}
}
fn restore_run_results(&mut self, path: &Path) {
let Some(records) = self.workspace_runs.get(path) else {
return;
};
for (entry, record) in self.entries.iter_mut().zip(records.iter()) {
if entry.last_run != RunStatus::NotRun || entry.last_response.is_some() {
continue;
}
if run_key(entry) == record.key {
entry.last_run = record.last_run;
entry.last_response = record.last_response.clone();
}
}
}
pub fn workspace_run_status(&self, path: &Path, idx: usize) -> RunStatus {
if self.path.as_deref() == Some(path) {
return self
.entries
.get(idx)
.map(|e| e.last_run)
.unwrap_or(RunStatus::NotRun);
}
if let Some(parked) = self.workspace_pending.get(path) {
return parked
.get(idx)
.map(|e| e.last_run)
.unwrap_or(RunStatus::NotRun);
}
self.workspace_runs
.get(path)
.and_then(|r| r.get(idx))
.map(|r| r.last_run)
.unwrap_or(RunStatus::NotRun)
}
pub fn has_unsaved_edits(&self) -> bool {
self.entries.iter().any(|e| e.user_added || e.modified)
}
pub fn unsaved_edit_count(&self) -> usize {
let loaded = self
.entries
.iter()
.filter(|e| e.user_added || e.modified)
.count();
let parked: usize = self
.workspace_pending
.iter()
.filter(|(path, _)| self.path.as_deref() != Some(path.as_path()))
.map(|(_, entries)| {
entries
.iter()
.filter(|e| e.user_added || e.modified)
.count()
})
.sum();
loaded + parked
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn edits_lost_on_exit(&self) -> usize {
if self.workspace_root.is_some() {
self.unsaved_edit_count()
} else {
0
}
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn workspace_file_edited(&self, path: &std::path::Path) -> bool {
if self.workspace_pending.contains_key(path) {
return true;
}
self.path.as_deref() == Some(path) && self.has_unsaved_edits()
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn workspace_request_edited(&self, path: &std::path::Path, idx: usize) -> bool {
let entries = if self.path.as_deref() == Some(path) {
&self.entries
} else {
match self.workspace_pending.get(path) {
Some(parked) => parked,
None => return false,
}
};
entries.get(idx).is_some_and(|e| e.user_added || e.modified)
}
pub fn revert_request(&mut self, ei: usize) -> Option<String> {
let path = self.path.clone()?;
let content = std::fs::read_to_string(&path).ok()?;
let mut disk = crate::postman::parse_collection(&content);
if ei >= disk.len() || ei >= self.entries.len() {
return None;
}
let entry = disk.swap_remove(ei);
let method = entry.method.clone();
self.entries[ei] = entry; self.invalidate_request_json();
self.sync_folder_to_selected();
Some(method)
}
pub fn revert_workspace_file(&mut self, path: &std::path::Path) -> std::io::Result<()> {
let entries = crate::postman::parse_collection(&std::fs::read_to_string(path)?);
self.workspace_pending.remove(path);
if self.path.as_deref() == Some(path) {
let sel = self.selected_entry;
self.park_run_results();
self.entries = entries;
self.restore_run_results(path);
self.selected_entry = sel.min(self.entries.len().saturating_sub(1));
self.invalidate_request_json();
self.sync_folder_to_selected();
} else {
let titles = entries.iter().map(ws_request_title).collect();
self.workspace_titles.insert(path.to_path_buf(), titles);
}
Ok(())
}
pub fn mark_saved(&mut self) {
for e in &mut self.entries {
e.user_added = false;
e.modified = false;
}
if let Some(path) = &self.path {
self.workspace_pending.remove(path);
}
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn save_workspace_edits(&mut self) -> Result<usize, String> {
if self.workspace_root.is_none() {
return Ok(0);
}
let mut written = 0usize;
if self.has_unsaved_edits()
&& let Some(path) = self.path.clone()
{
write_hurl(&path, &self.to_hurl())?;
written += 1;
}
let parked: Vec<(PathBuf, Vec<HurlEntry>)> = self
.workspace_pending
.iter()
.filter(|(path, _)| self.path.as_deref() != Some(path.as_path()))
.map(|(p, e)| (p.clone(), e.clone()))
.collect();
for (path, entries) in parked {
if !entries.iter().any(|e| e.user_added || e.modified) {
continue;
}
write_hurl(&path, &collection_to_hurl(&entries))?;
written += 1;
}
self.workspace_pending.clear();
self.mark_saved();
Ok(written)
}
pub fn rebuild_expanded_titles(&mut self) {
let loaded = self.path.clone();
let paths: Vec<PathBuf> = self.workspace_expanded.iter().cloned().collect();
for p in paths {
if Some(&p) == loaded.as_ref()
|| !p.is_file()
|| crate::workspace::is_report_file(&p)
|| crate::workspace::is_env_file(&p)
{
continue;
}
let titles = read_collection_labels(&p);
self.workspace_titles.insert(p, titles);
}
}
pub fn expand_ancestors_for_path(&mut self) {
let (Some(root), Some(path)) = (&self.workspace_root, &self.path) else {
return;
};
let root = root.clone();
let path = path.clone();
self.workspace_expanded.insert(path.clone());
if let Some(parent) = path.parent()
&& let Ok(rel) = parent.strip_prefix(&root)
{
let mut cur = root;
for component in rel.components() {
cur.push(component);
self.workspace_expanded.insert(cur.clone());
}
}
}
pub fn sync_ws_cursor(&mut self) {
if !self.is_workspace() {
return;
}
self.expand_selected_request_folders();
let rows = self.ws_rows();
let sel = self.selected_entry;
let loaded = self.path.clone();
let target = rows
.iter()
.position(|r| {
matches!(r, WsRow::Request { collection, idx, .. }
if *idx == sel && Some(collection) == loaded.as_ref())
})
.or_else(|| {
rows.iter().position(|r| {
matches!(r, WsRow::Collection { path, open: true, .. }
if Some(path) == loaded.as_ref())
})
})
.unwrap_or(0);
self.list_cursor = target.min(rows.len().saturating_sub(1));
}
fn expand_selected_request_folders(&mut self) {
let Some(path) = self.path.clone() else {
return;
};
let Some(title) = self
.entries
.get(self.selected_entry)
.map(|e| e.title.clone())
else {
return;
};
let segs = tree::entry_path(&title);
for n in 1..segs.len() {
self.workspace_expanded
.insert(request_folder_path(&path, &segs[..n]));
}
}
pub fn sync_folder_to_selected(&mut self) {
if self.is_workspace() {
let idx = self
.selected_entry
.min(self.entries.len().saturating_sub(1));
self.selected_entry = idx;
if !self.entries.is_empty() {
self.folder = tree::folder_of(&self.entries, idx);
}
self.sync_ws_cursor();
return;
}
if self.entries.is_empty() {
self.folder = Vec::new();
self.list_cursor = 0;
return;
}
let idx = self.selected_entry.min(self.entries.len() - 1);
self.selected_entry = idx;
self.folder = tree::folder_of(&self.entries, idx);
let rows = self.rows();
self.list_cursor = rows.iter().position(|r| *r == Row::Entry(idx)).unwrap_or(0);
}
}
#[cfg(test)]
mod ws_scan_tests {
use super::*;
use std::fs;
fn tmp_root(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("paperboy_ws_scan_{name}_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn workspace_at(root: &Path) -> Collection {
let mut c = Collection::new("ws".into(), Vec::new());
c.workspace_root = Some(root.to_path_buf());
c
}
fn names(rows: &[WsRow]) -> Vec<String> {
rows.iter()
.map(|r| match r {
WsRow::Folder { name, .. }
| WsRow::Report { name, .. }
| WsRow::Environment { name, .. }
| WsRow::Collection { name, .. } => name.clone(),
other => format!("{other:?}"),
})
.collect()
}
#[test]
fn the_workspace_tree_is_read_off_disk_at_most_once_per_ttl() {
let root = tmp_root("ttl");
fs::write(root.join("a.hurl"), "").unwrap();
let c = workspace_at(&root);
let t0 = Instant::now();
assert_eq!(names(&c.ws_rows_as_of(t0, 7)), vec!["a.hurl"]);
fs::write(root.join("b.hurl"), "").unwrap();
assert_eq!(
names(&c.ws_rows_as_of(t0 + WS_SCAN_TTL / 2, 7)),
vec!["a.hurl"],
"still serving the cached scan"
);
assert_eq!(
names(&c.ws_rows_as_of(t0 + WS_SCAN_TTL + Duration::from_millis(1), 7)),
vec!["a.hurl", "b.hurl"],
"the tree catches up with the filesystem"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_environment_file_list_is_served_from_the_tree_scan() {
let root = tmp_root("envscan");
fs::write(root.join("a.hurl"), "").unwrap();
fs::write(root.join("dev.vars"), "K=1").unwrap();
let c = workspace_at(&root);
let t0 = Instant::now();
assert_eq!(
c.workspace_env_files_as_of(t0, 7),
vec![root.join("dev.vars")],
"the workspace's environment files, and only those"
);
fs::write(root.join("prod.vars"), "K=2").unwrap();
assert_eq!(
c.workspace_env_files_as_of(t0 + WS_SCAN_TTL / 2, 7),
vec![root.join("dev.vars")],
"no second walk of the disk"
);
assert_eq!(
c.workspace_env_files_as_of(t0 + WS_SCAN_TTL + Duration::from_millis(1), 7),
vec![root.join("dev.vars"), root.join("prod.vars")]
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_non_workspace_tab_lists_no_environment_files() {
let c = Collection::new("scratch".into(), Vec::new());
assert!(c.workspace_env_files().is_empty());
}
#[test]
fn the_display_filter_does_not_hide_environment_files() {
let root = tmp_root("envfilter");
fs::write(root.join("dev.vars"), "K=1").unwrap();
let mut c = workspace_at(&root);
c.workspace_filter_hurl_json = true;
assert_eq!(c.workspace_env_files(), vec![root.join("dev.vars")]);
c.workspace_filter_hurl_json = false;
assert_eq!(c.workspace_env_files(), vec![root.join("dev.vars")]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn changing_the_filter_or_the_root_bypasses_a_fresh_scan() {
let root = tmp_root("keys");
fs::write(root.join("a.hurl"), "").unwrap();
fs::write(root.join("notes.txt"), "").unwrap();
let mut c = workspace_at(&root);
let t0 = Instant::now();
assert_eq!(names(&c.ws_rows_as_of(t0, 7)), vec!["a.hurl"], "filtered");
c.workspace_filter_hurl_json = false;
assert_eq!(
names(&c.ws_rows_as_of(t0, 7)),
vec!["a.hurl", "notes.txt"],
"showing everything, at the very same instant"
);
let other = tmp_root("keys_other");
fs::write(other.join("z.hurl"), "").unwrap();
c.workspace_root = Some(other.clone());
assert_eq!(
names(&c.ws_rows_as_of(t0, 7)),
vec!["z.hurl"],
"a different root is a different tree"
);
let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&other);
}
#[test]
fn the_app_s_own_file_operations_show_up_at_once() {
let root = tmp_root("generation");
fs::write(root.join("a.hurl"), "").unwrap();
let c = workspace_at(&root);
let t0 = Instant::now();
assert_eq!(names(&c.ws_rows_at(t0)), vec!["a.hurl"]);
crate::workspace::create_item(&root, &root, "b", crate::workspace::NewItemKind::Collection)
.expect("created");
assert_eq!(
names(&c.ws_rows_at(t0)),
vec!["a.hurl", "b.hurl"],
"at the very same instant, well inside the scan window"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn expanding_a_folder_shows_its_contents_immediately() {
let root = tmp_root("expand");
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(root.join("sub/inner.hurl"), "").unwrap();
let mut c = workspace_at(&root);
let t0 = Instant::now();
assert_eq!(names(&c.ws_rows_as_of(t0, 7)), vec!["sub"], "collapsed");
c.workspace_expanded.insert(root.join("sub"));
assert_eq!(
names(&c.ws_rows_as_of(t0, 7)),
vec!["sub", "inner.hurl"],
"no wait for the scan window: the filter isn't cached"
);
let _ = fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod revert_tests {
use super::*;
use std::fs;
fn tmp_root(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("paperboy_revert_{name}_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reverting_a_file_that_isnt_loaded_drops_its_parked_edits() {
let root = tmp_root("parked");
let a = root.join("a.hurl");
let b = root.join("b.hurl");
fs::write(&a, "GET https://example.com/a\n").unwrap();
fs::write(&b, "GET https://example.com/b\n").unwrap();
let mut col = Collection::new("ws".into(), Vec::new());
col.workspace_root = Some(root.clone());
col.load_workspace_file(a.clone()).unwrap();
col.entries[0].url = "https://edited.example".into();
col.entries[0].modified = true;
col.load_workspace_file(b.clone()).unwrap();
assert!(col.workspace_file_edited(&a), "the edits are parked");
col.revert_workspace_file(&a).unwrap();
assert!(!col.workspace_file_edited(&a), "and now they are gone");
col.load_workspace_file(a.clone()).unwrap();
assert_eq!(
col.entries[0].url, "https://example.com/a",
"reopening the file shows what is on disk, not the discarded edit"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn reverting_the_loaded_file_restores_it_in_place() {
let root = tmp_root("loaded");
let a = root.join("a.hurl");
fs::write(
&a,
"GET https://example.com/a\nGET https://example.com/a2\n",
)
.unwrap();
let mut col = Collection::new("ws".into(), Vec::new());
col.workspace_root = Some(root.clone());
col.load_workspace_file(a.clone()).unwrap();
col.selected_entry = 1;
col.entries[1].url = "https://edited.example".into();
col.entries[1].modified = true;
col.revert_workspace_file(&a).unwrap();
assert_eq!(col.path.as_deref(), Some(a.as_path()));
assert_eq!(col.selected_entry, 1, "the selection stays where it was");
assert_eq!(col.entries[1].url, "https://example.com/a2");
assert!(!col.has_unsaved_edits());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn reverting_an_unreadable_file_changes_nothing() {
let root = tmp_root("missing");
let a = root.join("a.hurl");
fs::write(&a, "GET https://example.com/a\n").unwrap();
let mut col = Collection::new("ws".into(), Vec::new());
col.workspace_root = Some(root.clone());
col.load_workspace_file(a.clone()).unwrap();
col.entries[0].url = "https://edited.example".into();
col.entries[0].modified = true;
fs::remove_file(&a).unwrap();
assert!(col.revert_workspace_file(&a).is_err());
assert_eq!(col.entries[0].url, "https://edited.example");
assert!(col.has_unsaved_edits());
let _ = fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod request_folder_tests {
use super::*;
use std::fs;
fn tmp_root(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("paperboy_reqfold_{name}_{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn nested_collection(root: &Path) -> (Collection, PathBuf) {
let path = root.join("api.hurl");
fs::write(
&path,
"# Auth/Login\nGET https://example.com/login\n\n\
# Auth/Tokens/Refresh\nPOST https://example.com/refresh\n\n\
# Health\nGET https://example.com/health\n",
)
.unwrap();
let mut col = Collection::new("ws".into(), Vec::new());
col.workspace_root = Some(root.to_path_buf());
col.load_workspace_file(path.clone()).unwrap();
(col, path)
}
fn shape(col: &Collection) -> Vec<(usize, String)> {
col.ws_rows()
.into_iter()
.map(|r| match r {
WsRow::Folder { name, depth, .. }
| WsRow::Collection { name, depth, .. }
| WsRow::Report { name, depth, .. }
| WsRow::Environment { name, depth, .. }
| WsRow::RequestFolder { name, depth, .. }
| WsRow::Request { name, depth, .. } => (depth, name),
})
.collect()
}
#[test]
fn titles_with_slashes_nest_instead_of_flattening_into_one_list() {
let root = tmp_root("nest");
let (col, _) = nested_collection(&root);
assert_eq!(
shape(&col),
vec![
(0, "api.hurl".to_string()),
(1, "Auth".to_string()),
(2, "Tokens".to_string()),
(2, "Login".to_string()),
(1, "Health".to_string()),
],
"each title segment is a row of its own"
);
assert!(
!shape(&col).iter().any(|(_, n)| n.contains('/')),
"no row still carries a raw `folder/request` name"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn folders_the_selection_is_not_in_start_closed() {
let root = tmp_root("closed");
let (col, _) = nested_collection(&root);
assert!(
!shape(&col).iter().any(|(_, n)| n == "Refresh"),
"Auth/Tokens is closed, so the request inside it isn't listed"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn opening_a_virtual_folder_reveals_one_level_at_a_time() {
let root = tmp_root("open");
let (mut col, path) = nested_collection(&root);
col.workspace_expanded
.insert(request_folder_path(&path, &["Auth".to_string()]));
assert_eq!(
shape(&col),
vec![
(0, "api.hurl".to_string()),
(1, "Auth".to_string()),
(2, "Tokens".to_string()),
(2, "Login".to_string()),
(1, "Health".to_string()),
],
"Auth's own folder and request, indented under it"
);
col.workspace_expanded.insert(request_folder_path(
&path,
&["Auth".to_string(), "Tokens".to_string()],
));
assert_eq!(
shape(&col),
vec![
(0, "api.hurl".to_string()),
(1, "Auth".to_string()),
(2, "Tokens".to_string()),
(3, "Refresh".to_string()),
(2, "Login".to_string()),
(1, "Health".to_string()),
],
"the nested folder opens independently"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn nesting_does_not_disturb_the_request_indices() {
let root = tmp_root("idx");
let (mut col, path) = nested_collection(&root);
col.workspace_expanded
.insert(request_folder_path(&path, &["Auth".to_string()]));
col.workspace_expanded.insert(request_folder_path(
&path,
&["Auth".to_string(), "Tokens".to_string()],
));
let found: Vec<(usize, String)> = col
.ws_rows()
.into_iter()
.filter_map(|r| match r {
WsRow::Request { idx, name, .. } => Some((idx, name)),
_ => None,
})
.collect();
assert_eq!(
found,
vec![
(1, "Refresh".to_string()),
(0, "Login".to_string()),
(2, "Health".to_string()),
],
"the file's own order is what the indices mean"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn an_untitled_requests_url_is_never_read_as_folders() {
let root = tmp_root("untitled");
let path = root.join("bare.hurl");
fs::write(&path, "GET https://example.com/a/b/c\n").unwrap();
let mut col = Collection::new("ws".into(), Vec::new());
col.workspace_root = Some(root.clone());
col.load_workspace_file(path).unwrap();
assert_eq!(
shape(&col),
vec![
(0, "bare.hurl".to_string()),
(1, "https://example.com/a/b/c".to_string()),
],
"one row, showing the whole URL"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn selecting_a_nested_request_opens_the_folders_hiding_it() {
let root = tmp_root("reveal");
let (mut col, _) = nested_collection(&root);
col.selected_entry = 1; col.sync_ws_cursor();
let rows = col.ws_rows();
let cursor = rows.get(col.list_cursor);
assert!(
matches!(cursor, Some(WsRow::Request { idx: 1, .. })),
"the cursor is on the selected request, not on a fallback row: {cursor:?}"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_folder_key_cannot_escape_its_collection() {
let collection = Path::new("/ws/api.hurl");
let key = request_folder_path(
collection,
&["..".to_string(), "a/b".to_string(), ".".to_string()],
);
assert!(
key.starts_with(collection),
"every key stays under its collection: {key:?}"
);
assert!(
!key.components().any(|c| c.as_os_str() == ".."),
"and never contains a parent hop: {key:?}"
);
}
}