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 name(&self) -> &str {
match self {
WsRow::Folder { name, .. }
| WsRow::Collection { name, .. }
| WsRow::Report { name, .. }
| WsRow::Environment { name, .. }
| WsRow::RequestFolder { name, .. }
| WsRow::Request { name, .. } => name,
}
}
}
fn filter_ws_rows(rows: Vec<WsRow>, query: &str) -> Vec<WsRow> {
let needle = query.trim().to_lowercase();
if needle.is_empty() {
return rows;
}
let mut keep = vec![false; rows.len()];
let mut ancestors: Vec<usize> = Vec::new();
let mut inside: Option<usize> = None;
for (i, row) in rows.iter().enumerate() {
let d = row.depth();
while ancestors.last().is_some_and(|&a| rows[a].depth() >= d) {
ancestors.pop();
}
if inside.is_some_and(|kept| d <= kept) {
inside = None;
}
if inside.is_some() || row.name().to_lowercase().contains(&needle) {
keep[i] = true;
for &a in &ancestors {
keep[a] = true;
}
if inside.is_none() && matches!(row, WsRow::Folder { .. } | WsRow::RequestFolder { .. })
{
inside = Some(d);
}
}
ancestors.push(i);
}
rows.into_iter()
.zip(keep)
.filter_map(|(r, k)| k.then_some(r))
.collect()
}
pub fn unique_entry_title(entries: &[HurlEntry], title: &str) -> String {
let (prefix, leaf) = match title.rfind('/') {
Some(i) => title.split_at(i + 1),
None => ("", title),
};
let stem = leaf
.rsplit_once(" (")
.and_then(|(head, tail)| {
tail.strip_suffix(')')
.filter(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit()))
.map(|_| head)
})
.unwrap_or(leaf);
let taken: HashSet<&str> = entries.iter().map(|e| e.title.as_str()).collect();
(2..)
.map(|n| format!("{prefix}{stem} ({n})"))
.find(|candidate| !taken.contains(candidate.as_str()))
.unwrap_or_else(|| title.to_string())
}
fn shift_index(i: usize, from: usize, to: usize) -> usize {
if i == from {
to
} else if from < to && i > from && i <= to {
i - 1
} else if to < from && i >= to && i < from {
i + 1
} else {
i
}
}
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 list_query: String,
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 structure_modified: bool,
pub structure_baseline: Vec<u64>,
pub workspace_baselines: HashMap<PathBuf, Vec<u64>>,
pub workspace_structure_modified: HashSet<PathBuf>,
pub workspace_runs: HashMap<PathBuf, Vec<RunRecord>>,
}
static NEXT_COLLECTION_ID: AtomicU64 = AtomicU64::new(1);
static NEXT_ENTRY_UID: 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,
list_query: String::new(),
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(),
structure_modified: false,
structure_baseline: Vec::new(),
workspace_baselines: HashMap::new(),
workspace_structure_modified: HashSet::new(),
workspace_runs: HashMap::new(),
};
c.reset_structure_baseline();
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> {
if self.list_filter_active() {
tree::rows_matching(&self.entries, &self.list_query)
} else {
tree::rows_for(&self.entries, &self.folder)
}
}
pub fn list_row_count(&self) -> usize {
if self.is_workspace() {
self.ws_rows().len()
} else {
self.rows().len()
}
}
pub fn list_filter_active(&self) -> bool {
!self.list_query.trim().is_empty()
}
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_transfer_target(&self) -> Option<usize> {
if !self.is_workspace() || self.workspace_root.is_none() {
return None;
}
match self.ws_rows().into_iter().nth(self.list_cursor) {
Some(WsRow::Request {
idx, loaded: true, ..
}) => Some(idx),
_ => None,
}
}
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 filtering = self.list_filter_active();
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 = filtering
|| 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 = filtering || 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));
}
}
}
}
if filtering {
return filter_ws_rows(out, &self.list_query);
}
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.workspace_structure_modified.remove(&path);
self.park_run_results();
self.snapshot_loaded_titles();
self.entries = entries;
match self.workspace_baselines.remove(&path) {
Some(baseline) => {
self.structure_baseline = baseline;
self.refresh_structure_modified();
}
None => self.reset_structure_baseline(),
}
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() {
if self.structure_modified {
self.workspace_structure_modified.insert(path.clone());
}
self.workspace_baselines
.insert(path.clone(), self.structure_baseline.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)
}
fn structure_fingerprint(&self) -> Vec<u64> {
self.entries.iter().map(|e| e.uid).collect()
}
pub fn reset_structure_baseline(&mut self) {
for e in &mut self.entries {
e.uid = NEXT_ENTRY_UID.fetch_add(1, Ordering::Relaxed);
}
self.structure_baseline = self.structure_fingerprint();
self.structure_modified = false;
}
fn refresh_structure_modified(&mut self) {
self.structure_modified = self.structure_fingerprint() != self.structure_baseline;
}
pub fn has_unsaved_edits(&self) -> bool {
self.structure_modified || self.entries.iter().any(|e| e.user_added || e.modified)
}
pub fn unsaved_edit_count(&self) -> usize {
let edited = |entries: &[HurlEntry]| {
entries
.iter()
.filter(|e| e.user_added || e.modified)
.count()
};
let loaded = edited(&self.entries) + usize::from(self.structure_modified);
let parked: usize = self
.workspace_pending
.iter()
.filter(|(path, _)| self.path.as_deref() != Some(path.as_path()))
.map(|(path, entries)| {
edited(entries) + usize::from(self.workspace_structure_modified.contains(path))
})
.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
}
}
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_unsaved_under(&self, prefix: &std::path::Path) -> bool {
if self.path.as_deref().is_some_and(|p| p.starts_with(prefix)) && self.has_unsaved_edits() {
return true;
}
self.workspace_pending.keys().any(|p| p.starts_with(prefix))
|| self
.workspace_structure_modified
.iter()
.any(|p| p.starts_with(prefix))
}
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(())
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn repoint_workspace_paths(&mut self, from: &std::path::Path, to: &std::path::Path) {
use crate::workspace::repoint;
let moved = |p: &std::path::Path| repoint(p, from, to);
if let Some(p) = self.path.as_deref().and_then(moved) {
self.path = Some(p);
}
if let Some(p) = self.workspace_selected.as_deref().and_then(moved) {
self.workspace_selected = Some(p);
}
self.workspace_expanded = self
.workspace_expanded
.drain()
.map(|p| moved(&p).unwrap_or(p))
.collect();
self.workspace_titles = self
.workspace_titles
.drain()
.map(|(p, v)| (moved(&p).unwrap_or(p), v))
.collect();
self.workspace_pending = self
.workspace_pending
.drain()
.map(|(p, v)| (moved(&p).unwrap_or(p), v))
.collect();
self.workspace_baselines = self
.workspace_baselines
.drain()
.map(|(p, v)| (moved(&p).unwrap_or(p), v))
.collect();
self.workspace_structure_modified = self
.workspace_structure_modified
.drain()
.map(|p| moved(&p).unwrap_or(p))
.collect();
self.workspace_runs = self
.workspace_runs
.drain()
.map(|(p, v)| (moved(&p).unwrap_or(p), v))
.collect();
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn prune_workspace_paths(&mut self, deleted: &std::path::Path) {
let under = |p: &std::path::Path| p.starts_with(deleted);
if self.path.as_deref().is_some_and(under) {
self.path = None;
self.entries.clear();
self.selected_entry = 0;
self.structure_modified = false;
self.structure_baseline.clear();
self.invalidate_request_json();
}
if self.workspace_selected.as_deref().is_some_and(under) {
self.workspace_selected = None;
}
self.workspace_expanded.retain(|p| !under(p));
self.workspace_titles.retain(|p, _| !under(p));
self.workspace_pending.retain(|p, _| !under(p));
self.workspace_baselines.retain(|p, _| !under(p));
self.workspace_structure_modified.retain(|p| !under(p));
self.workspace_runs.retain(|p, _| !under(p));
}
pub fn mark_saved(&mut self) {
for e in &mut self.entries {
e.user_added = false;
e.modified = false;
}
self.reset_structure_baseline();
if let Some(path) = &self.path {
self.workspace_pending.remove(path);
self.workspace_structure_modified.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 !self.workspace_structure_modified.contains(&path)
&& !entries.iter().any(|e| e.user_added || e.modified)
{
continue;
}
write_hurl(&path, &collection_to_hurl(&entries))?;
written += 1;
}
self.workspace_pending.clear();
self.workspace_structure_modified.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);
}
pub fn remove_entry_recording_undo(&mut self, idx: usize) -> Option<HurlEntry> {
if idx >= self.entries.len() {
return None;
}
let removed = self.entries.remove(idx);
self.refresh_structure_modified();
self.deleted_entries.push((idx, removed.clone()));
if self.deleted_entries.len() > 20 {
self.deleted_entries.remove(0);
}
Some(removed)
}
pub fn move_entry(&mut self, from: usize, to: usize) -> bool {
let len = self.entries.len();
if from >= len || to >= len || from == to {
return false;
}
let entry = self.entries.remove(from);
self.entries.insert(to, entry);
self.selected_entry = shift_index(self.selected_entry, from, to);
self.refresh_structure_modified();
self.invalidate_request_json();
self.sync_folder_to_selected();
true
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn move_entry_before(&mut self, from: usize, before: usize) -> bool {
let len = self.entries.len();
if from >= len || before > len {
return false;
}
if before == from || before == from + 1 {
return false;
}
let to = if from < before { before - 1 } else { before };
self.move_entry(from, to)
}
pub fn restore_last_deleted(&mut self) -> Option<usize> {
let (idx, entry) = self.deleted_entries.pop()?;
let idx = idx.min(self.entries.len());
self.entries.insert(idx, entry);
self.refresh_structure_modified();
Some(idx)
}
}
#[cfg(test)]
mod undo_delete_tests {
use super::*;
fn entry(title: &str) -> HurlEntry {
let mut e = HurlEntry::default();
e.title = title.into();
e
}
#[test]
fn remove_entry_recording_undo_records_index_and_entry() {
let mut c = Collection::new("c".into(), vec![entry("a"), entry("b"), entry("c")]);
let removed = c
.remove_entry_recording_undo(1)
.expect("index 1 is in range");
assert_eq!(removed.title, "b");
assert_eq!(
c.entries
.iter()
.map(|e| e.title.as_str())
.collect::<Vec<_>>(),
vec!["a", "c"]
);
assert_eq!(c.deleted_entries.len(), 1);
assert_eq!(c.deleted_entries[0].0, 1);
assert_eq!(c.deleted_entries[0].1.title, "b");
}
#[test]
fn deleted_entries_cap_holds_at_20() {
let mut c = Collection::new(
"c".into(),
(0..25).map(|i| entry(&format!("r{i}"))).collect(),
);
for _ in 0..25 {
c.remove_entry_recording_undo(0);
}
assert_eq!(c.deleted_entries.len(), 20);
assert_eq!(c.deleted_entries.first().unwrap().1.title, "r5");
assert_eq!(c.deleted_entries.last().unwrap().1.title, "r24");
}
#[test]
fn restore_last_deleted_reinserts_at_recorded_index() {
let mut c = Collection::new("c".into(), vec![entry("a"), entry("b"), entry("c")]);
c.remove_entry_recording_undo(1);
assert_eq!(
c.entries
.iter()
.map(|e| e.title.as_str())
.collect::<Vec<_>>(),
vec!["a", "c"]
);
let idx = c.restore_last_deleted().unwrap();
assert_eq!(idx, 1);
assert_eq!(
c.entries
.iter()
.map(|e| e.title.as_str())
.collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
assert!(c.deleted_entries.is_empty());
}
#[test]
fn removing_an_out_of_range_entry_is_a_no_op_rather_than_a_panic() {
let mut c = Collection::new("c".into(), vec![entry("a")]);
assert!(c.remove_entry_recording_undo(7).is_none());
assert_eq!(c.entries.len(), 1, "nothing was removed");
assert!(c.deleted_entries.is_empty(), "and nothing was recorded");
}
#[test]
fn restore_last_deleted_is_none_when_history_empty() {
let mut c = Collection::new("c".into(), vec![entry("a")]);
assert!(c.restore_last_deleted().is_none());
}
}
#[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:?}"
);
}
}
#[cfg(test)]
mod structure_edit_tests {
use super::*;
fn ws_collection(dir: &std::path::Path, titles: &[&str]) -> (Collection, PathBuf) {
let a = dir.join("a.hurl");
let b = dir.join("b.hurl");
let entries: Vec<HurlEntry> = titles
.iter()
.map(|t| HurlEntry {
title: (*t).to_string(),
method: "GET".into(),
url: "http://x".into(),
..Default::default()
})
.collect();
std::fs::write(&a, collection_to_hurl(&entries)).unwrap();
std::fs::write(&b, "GET http://other\n").unwrap();
let mut col = Collection::new("ws".into(), entries);
col.workspace_root = Some(dir.to_path_buf());
col.path = Some(a.clone());
(col, b)
}
fn temp_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("paperboy_structedit_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn deleting_a_request_survives_a_workspace_file_switch() {
let dir = temp_dir("delete");
let (mut col, other) = ws_collection(&dir, &["Login", "Logout"]);
col.remove_entry_recording_undo(0);
assert!(
col.has_unsaved_edits(),
"a deletion is an unsaved edit, even though no surviving entry is flagged"
);
let a = col.path.clone().unwrap();
col.load_workspace_file(other).unwrap();
col.load_workspace_file(a).unwrap();
let titles: Vec<&str> = col.entries.iter().map(|e| e.title.as_str()).collect();
assert_eq!(
titles,
vec!["Logout"],
"the deleted request must not come back from disk"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn titles(c: &Collection) -> Vec<&str> {
c.entries.iter().map(|e| e.title.as_str()).collect()
}
fn plain(titles: &[&str]) -> Collection {
Collection::new(
"c".into(),
titles
.iter()
.map(|t| HurlEntry {
title: (*t).to_string(),
method: "GET".into(),
..Default::default()
})
.collect(),
)
}
#[test]
fn moving_an_entry_shifts_the_ones_it_steps_over() {
let mut c = plain(&["a", "b", "c", "d"]);
assert!(c.move_entry(0, 2), "a moves down past b and c");
assert_eq!(titles(&c), vec!["b", "c", "a", "d"]);
let mut c = plain(&["a", "b", "c", "d"]);
assert!(c.move_entry(3, 1), "d moves up past c and b");
assert_eq!(titles(&c), vec!["a", "d", "b", "c"]);
}
#[test]
fn a_move_that_cannot_happen_is_a_no_op() {
let mut c = plain(&["a", "b"]);
assert!(!c.move_entry(1, 1), "nowhere to go");
assert!(!c.move_entry(0, 9), "past the end");
assert!(!c.move_entry(9, 0), "from nowhere");
assert_eq!(titles(&c), vec!["a", "b"]);
assert!(
!c.structure_modified,
"and a move that did not happen is not an unsaved change"
);
}
#[test]
fn a_drop_lands_in_the_gap_it_was_aimed_at() {
let mut c = plain(&["a", "b", "c", "d"]);
assert!(c.move_entry_before(0, 3));
assert_eq!(titles(&c), vec!["b", "c", "a", "d"]);
let mut c = plain(&["a", "b", "c", "d"]);
assert!(c.move_entry_before(3, 1));
assert_eq!(titles(&c), vec!["a", "d", "b", "c"]);
let mut c = plain(&["a", "b", "c"]);
assert!(c.move_entry_before(0, 3));
assert_eq!(titles(&c), vec!["b", "c", "a"]);
}
#[test]
fn dropping_a_request_back_where_it_started_changes_nothing() {
let mut c = plain(&["a", "b", "c"]);
assert!(!c.move_entry_before(1, 1), "the gap above it");
assert!(!c.move_entry_before(1, 2), "the gap below it");
assert!(!c.move_entry_before(9, 0), "from nowhere");
assert!(!c.move_entry_before(0, 9), "into nowhere");
assert_eq!(titles(&c), vec!["a", "b", "c"]);
assert!(!c.structure_modified);
}
#[test]
fn the_selection_follows_whatever_it_was_pointing_at() {
let mut c = plain(&["a", "b", "c"]);
c.selected_entry = 0;
c.move_entry(0, 2);
assert_eq!(c.selected_entry, 2, "still on 'a'");
let mut c = plain(&["a", "b", "c"]);
c.selected_entry = 1;
c.move_entry(0, 2);
assert_eq!(c.selected_entry, 0, "still on 'b', which slid up");
let mut c = plain(&["a", "b", "c", "d"]);
c.selected_entry = 3;
c.move_entry(0, 2);
assert_eq!(c.selected_entry, 3, "still on 'd'");
}
#[test]
fn reordering_counts_as_an_unsaved_change() {
let dir = temp_dir("reorder");
let (mut col, _) = ws_collection(&dir, &["Login", "Logout"]);
let a = col.path.clone().unwrap();
assert!(col.move_entry(0, 1));
assert!(col.has_unsaved_edits());
assert_eq!(col.unsaved_edit_count(), 1);
assert_eq!(col.save_workspace_edits().unwrap(), 1);
let on_disk = std::fs::read_to_string(&a).unwrap();
assert!(
on_disk.find("Logout").unwrap() < on_disk.find("Login").unwrap(),
"the new order reached the file: {on_disk}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_structural_edit_counts_as_an_unsaved_change() {
let dir = temp_dir("count");
let (mut col, other) = ws_collection(&dir, &["Login", "Logout"]);
assert_eq!(col.unsaved_edit_count(), 0);
col.remove_entry_recording_undo(0);
assert_eq!(
col.unsaved_edit_count(),
1,
"the deletion is a change, even with no request left to flag it"
);
col.load_workspace_file(other).unwrap();
assert_eq!(col.unsaved_edit_count(), 1);
col.save_workspace_edits().unwrap();
assert_eq!(col.unsaved_edit_count(), 0, "and saving settles it");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_parked_files_structural_edit_is_written_too() {
let dir = temp_dir("parked");
let (mut col, other) = ws_collection(&dir, &["Login", "Logout"]);
let a = col.path.clone().unwrap();
col.remove_entry_recording_undo(0);
col.load_workspace_file(other).unwrap();
assert!(
col.workspace_pending.contains_key(&a),
"the deletion was parked rather than discarded"
);
assert_eq!(
col.save_workspace_edits().unwrap(),
1,
"the parked file was written"
);
let on_disk = std::fs::read_to_string(&a).unwrap();
assert!(
!on_disk.contains("Login"),
"the parked deletion reached the file: {on_disk}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_clears_the_structural_marks_it_just_wrote() {
let dir = temp_dir("clears");
let (mut col, _) = ws_collection(&dir, &["Login", "Logout"]);
col.remove_entry_recording_undo(0);
col.save_workspace_edits().unwrap();
assert!(col.workspace_structure_modified.is_empty());
assert!(!col.structure_modified);
assert_eq!(
col.save_workspace_edits().unwrap(),
0,
"a second save has nothing left to write"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_structural_edit_is_written_by_save_workspace_edits() {
let dir = temp_dir("save");
let (mut col, _) = ws_collection(&dir, &["Login", "Logout"]);
let a = col.path.clone().unwrap();
col.remove_entry_recording_undo(0);
assert_eq!(
col.save_workspace_edits().unwrap(),
1,
"the file was written"
);
let on_disk = std::fs::read_to_string(&a).unwrap();
assert!(
!on_disk.contains("Login"),
"the deletion reached the file: {on_disk}"
);
assert!(
!col.has_unsaved_edits(),
"and saving clears the structural edit, like any other"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod structure_baseline_tests {
use super::*;
fn entry(title: &str) -> HurlEntry {
let mut e = HurlEntry::default();
e.title = title.into();
e
}
fn col() -> Collection {
Collection::new("c".into(), vec![entry("a"), entry("b"), entry("c")])
}
#[test]
fn a_freshly_built_collection_is_not_structurally_modified() {
assert!(!col().structure_modified);
}
#[test]
fn reordering_marks_the_collection_and_reordering_back_clears_it() {
let mut c = col();
assert!(c.move_entry(0, 1));
assert!(
c.structure_modified,
"a reorder has to register — nothing else records it"
);
assert!(c.move_entry(1, 0));
assert!(
!c.structure_modified,
"putting the request back leaves nothing to save, so the marker must clear"
);
}
#[test]
fn a_longer_walk_back_to_the_original_order_also_clears_it() {
let mut c = col();
let titles = |c: &Collection| {
c.entries
.iter()
.map(|e| e.title.clone())
.collect::<Vec<_>>()
};
c.move_entry(0, 2);
c.move_entry(0, 1);
assert_eq!(titles(&c), vec!["c", "b", "a"]);
assert!(c.structure_modified);
c.move_entry(0, 2);
c.move_entry(0, 1);
assert_eq!(titles(&c), vec!["a", "b", "c"]);
assert!(!c.structure_modified);
}
#[test]
fn deleting_marks_the_collection_and_undoing_the_delete_clears_it() {
let mut c = col();
c.remove_entry_recording_undo(1);
assert!(c.structure_modified);
c.restore_last_deleted();
assert!(
!c.structure_modified,
"the restored request landed back where it was, so the list matches disk again"
);
}
#[test]
fn a_restore_that_lands_somewhere_else_stays_marked() {
let mut c = col();
c.remove_entry_recording_undo(0);
c.remove_entry_recording_undo(0);
c.restore_last_deleted();
assert!(c.structure_modified);
}
#[test]
fn reordering_untitled_requests_is_still_detected() {
let mut c = Collection::new("c".into(), vec![entry(""), entry(""), entry("")]);
c.entries[0].url = "https://example.test/a".into();
c.entries[1].url = "https://example.test/b".into();
c.entries[2].url = "https://example.test/c".into();
c.mark_saved();
assert!(c.move_entry(0, 1));
assert!(c.structure_modified, "the requests really did swap places");
assert!(c.move_entry(1, 0));
assert!(!c.structure_modified);
}
#[test]
fn editing_a_request_in_place_is_not_a_structural_change() {
let mut c = col();
c.entries[1].url = "https://example.test/rewritten".into();
c.entries[1].title = "renamed".into();
c.entries[1].modified = true;
assert!(
!c.structure_modified,
"the list is unchanged; only what one of its entries holds is"
);
assert!(c.has_unsaved_edits(), "still unsaved, via the entry's flag");
assert_eq!(c.unsaved_edit_count(), 1, "counted once, not twice");
}
#[test]
fn saving_adopts_the_current_order_as_the_new_baseline() {
let mut c = col();
c.move_entry(0, 2);
c.mark_saved();
assert!(!c.structure_modified);
c.move_entry(2, 0);
assert!(c.structure_modified);
}
}