use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex};
use std::time::SystemTime;
const MAX_DEPTH: usize = 32;
static TREE_GENERATION: AtomicU64 = AtomicU64::new(0);
pub fn tree_generation() -> u64 {
TREE_GENERATION.load(Ordering::Relaxed)
}
pub fn note_tree_changed() {
TREE_GENERATION.fetch_add(1, Ordering::Relaxed);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WsEntry {
pub path: PathBuf,
pub display_name: String,
pub depth: usize,
pub is_dir: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum NewItemKind {
Collection,
Report,
Environment,
Folder,
}
impl NewItemKind {
pub fn extension(self) -> &'static str {
match self {
NewItemKind::Collection => "hurl",
NewItemKind::Report => "trail",
NewItemKind::Environment => "vars",
NewItemKind::Folder => "",
}
}
pub fn from_name(name: &str) -> Option<Self> {
match Path::new(name).extension().and_then(|e| e.to_str()) {
None => Some(NewItemKind::Collection),
Some(e) => match e.to_ascii_lowercase().as_str() {
"hurl" | "json" => Some(NewItemKind::Collection),
"trail" => Some(NewItemKind::Report),
"vars" => Some(NewItemKind::Environment),
_ => None,
},
}
}
fn starter(self, stem: &str) -> String {
match self {
NewItemKind::Collection => format!("# {stem}\n"),
NewItemKind::Report => crate::report::Report::scratch(stem).text,
NewItemKind::Environment => format!("# {stem}\n"),
NewItemKind::Folder => String::new(),
}
}
}
pub fn create_item(
root: &Path,
dir: &Path,
name: &str,
kind: NewItemKind,
) -> Result<PathBuf, NewItemError> {
let name = name.trim();
if name.is_empty() {
return Err(NewItemError::EmptyName);
}
let mut rel = PathBuf::from(name);
if kind != NewItemKind::Folder && rel.extension().is_none() {
rel.set_extension(kind.extension());
}
let lexically_safe = rel
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir));
if !lexically_safe {
return Err(NewItemError::Escapes(rel.display().to_string()));
}
let full = dir.join(&rel);
if escapes_root(root, &full) {
return Err(NewItemError::Escapes(full.display().to_string()));
}
if full.exists() {
return Err(NewItemError::Exists(display_name(root, &full)));
}
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| NewItemError::Io(format!("{}: {e}", parent.display())))?;
}
if kind == NewItemKind::Folder {
std::fs::create_dir_all(&full)
.map_err(|e| NewItemError::Io(format!("{}: {e}", full.display())))?;
note_tree_changed();
return Ok(full);
}
let stem = full
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| name.to_string());
std::fs::write(&full, kind.starter(&stem))
.map_err(|e| NewItemError::Io(format!("{}: {e}", full.display())))?;
note_tree_changed();
Ok(full)
}
pub fn move_item(root: &Path, src: &Path, dest_dir: &Path) -> Result<PathBuf, MoveError> {
if !src.starts_with(root) || escapes_root(root, src) {
return Err(MoveError::Escapes(src.display().to_string()));
}
if !dest_dir.starts_with(root) || escapes_root(root, dest_dir) {
return Err(MoveError::Escapes(dest_dir.display().to_string()));
}
let Some(name) = src.file_name() else {
return Err(MoveError::Escapes(src.display().to_string()));
};
if src.parent() == Some(dest_dir) {
return Ok(src.to_path_buf());
}
if dest_dir.starts_with(src) {
return Err(MoveError::IntoItself);
}
let dest = dest_dir.join(name);
if dest.exists() {
return Err(MoveError::Exists(display_name(root, &dest)));
}
std::fs::create_dir_all(dest_dir)
.map_err(|e| MoveError::Io(format!("{}: {e}", dest_dir.display())))?;
std::fs::rename(src, &dest).map_err(|e| MoveError::Io(format!("{}: {e}", dest.display())))?;
note_tree_changed();
Ok(dest)
}
#[derive(Clone, Debug, PartialEq)]
pub enum MoveError {
Escapes(String),
Exists(String),
IntoItself,
Io(String),
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn rename_item(root: &Path, src: &Path, new_name: &str) -> Result<PathBuf, RenameError> {
if !src.starts_with(root) || escapes_root(root, src) {
return Err(RenameError::Escapes(src.display().to_string()));
}
let new_name = new_name.trim();
if new_name.is_empty() {
return Err(RenameError::EmptyName);
}
let candidate = Path::new(new_name);
let mut comps = candidate.components();
let single_component =
matches!(comps.next(), Some(Component::Normal(_))) && comps.next().is_none();
if !single_component {
return Err(RenameError::Escapes(new_name.to_string()));
}
let is_dir = src.is_dir();
let final_name = if is_dir {
new_name.to_string()
} else {
preserve_extension(src, new_name)
};
let Some(parent) = src.parent() else {
return Err(RenameError::Escapes(src.display().to_string()));
};
let dest = parent.join(&final_name);
if dest == src {
return Ok(src.to_path_buf());
}
if escapes_root(root, &dest) {
return Err(RenameError::Escapes(dest.display().to_string()));
}
if dest.exists() {
return Err(RenameError::Exists(display_name(root, &dest)));
}
std::fs::rename(src, &dest).map_err(|e| RenameError::Io(format!("{}: {e}", dest.display())))?;
note_tree_changed();
Ok(dest)
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
fn preserve_extension(src: &Path, name: &str) -> String {
let Some(src_ext) = src.extension().and_then(|e| e.to_str()) else {
return name.to_string();
};
let already = Path::new(name)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(src_ext));
if already {
name.to_string()
} else {
format!("{name}.{src_ext}")
}
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
#[derive(Clone, Debug, PartialEq)]
pub enum RenameError {
EmptyName,
Escapes(String),
Exists(String),
Io(String),
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn delete_item(root: &Path, path: &Path) -> Result<(), DeleteError> {
if !path.starts_with(root) || escapes_root(root, path) {
return Err(DeleteError::Escapes(path.display().to_string()));
}
let is_root = match (root.canonicalize(), path.canonicalize()) {
(Ok(r), Ok(p)) => r == p,
_ => path == root,
};
if is_root {
return Err(DeleteError::IsRoot);
}
let result = if path.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
result.map_err(|e| DeleteError::Io(format!("{}: {e}", path.display())))?;
note_tree_changed();
Ok(())
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn descendant_file_count(path: &Path) -> usize {
if !path.is_dir() {
return 1;
}
fn count(dir: &Path, depth: usize) -> usize {
if depth >= MAX_DEPTH {
return 0;
}
let Ok(read) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0;
for entry in read.flatten() {
let p = entry.path();
let hidden = p
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.'));
if hidden {
continue;
}
if p.is_dir() {
total += count(&p, depth + 1);
} else {
total += 1;
}
}
total
}
count(path, 0)
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
#[derive(Clone, Debug, PartialEq)]
pub enum DeleteError {
Escapes(String),
IsRoot,
Io(String),
}
pub fn repoint(path: &Path, from: &Path, to: &Path) -> Option<PathBuf> {
path.strip_prefix(from).ok().map(|rest| to.join(rest))
}
#[derive(Clone, Debug, PartialEq)]
pub enum NewItemError {
EmptyName,
Escapes(String),
Exists(String),
Io(String),
}
pub fn display_name(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}
pub fn escapes_root(root: &Path, target: &Path) -> bool {
let Ok(canon_root) = root.canonicalize() else {
return false;
};
let mut ancestor = target;
loop {
if ancestor.exists() {
return match ancestor.canonicalize() {
Ok(real) => !real.starts_with(&canon_root),
Err(_) => true,
};
}
match ancestor.parent() {
Some(parent) => ancestor = parent,
None => return true,
}
}
}
pub fn scan_workspace(root: &Path, filter_hurl_json: bool) -> Vec<WsEntry> {
let mut out = Vec::new();
scan_dir(root, 0, filter_hurl_json, &mut out);
out
}
fn scan_dir(dir: &Path, depth: usize, filter_hurl_json: bool, out: &mut Vec<WsEntry>) {
if depth >= MAX_DEPTH {
return;
}
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
let mut dirs: Vec<PathBuf> = Vec::new();
let mut files: Vec<PathBuf> = Vec::new();
for entry in read.flatten() {
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
dirs.push(path);
} else if is_matching_file(&path, filter_hurl_json) {
files.push(path);
}
}
dirs.sort();
files.sort();
for d in dirs {
let mut sub = Vec::new();
scan_dir(&d, depth + 1, filter_hurl_json, &mut sub);
let display_name = d
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
out.push(WsEntry {
path: d,
display_name,
depth,
is_dir: true,
});
out.extend(sub);
}
for f in files {
let display_name = f
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
out.push(WsEntry {
path: f,
display_name,
depth,
is_dir: false,
});
}
}
fn is_matching_file(path: &Path, filter_hurl_json: bool) -> bool {
if !filter_hurl_json {
return true;
}
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => {
ext.eq_ignore_ascii_case("hurl")
|| ext.eq_ignore_ascii_case("json")
|| ext.eq_ignore_ascii_case("vars")
|| ext.eq_ignore_ascii_case("trail")
}
None => false,
}
}
pub fn is_report_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("trail"))
}
pub fn is_env_file(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
if ext.eq_ignore_ascii_case("vars") {
return true;
}
if !ext.eq_ignore_ascii_case("json") {
return false;
}
is_postman_env_json(path)
}
type JsonEnvCache = HashMap<PathBuf, ((u64, Option<SystemTime>), bool)>;
static JSON_ENV_CACHE: LazyLock<Mutex<JsonEnvCache>> = LazyLock::new(|| Mutex::new(HashMap::new()));
fn is_postman_env_json(path: &Path) -> bool {
let stamp = std::fs::metadata(path)
.map(|m| (m.len(), m.modified().ok()))
.unwrap_or((0, None));
if let Ok(cache) = JSON_ENV_CACHE.lock()
&& let Some((cached_stamp, answer)) = cache.get(path)
&& *cached_stamp == stamp
{
return *answer;
}
let answer = std::fs::read_to_string(path)
.ok()
.is_some_and(|c| crate::postman::postman_env_values(&c).is_some());
if let Ok(mut cache) = JSON_ENV_CACHE.lock() {
cache.insert(path.to_path_buf(), (stamp, answer));
}
answer
}
pub fn is_workspace_file(path: &Path) -> bool {
is_matching_file(path, true)
}
pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
copy_dir_all_inner(src, dst, 0)
}
fn copy_dir_all_inner(src: &Path, dst: &Path, depth: usize) -> std::io::Result<()> {
if depth >= MAX_DEPTH {
return Ok(());
}
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if name.starts_with('.') {
continue;
}
let dest_path = dst.join(name);
if path.is_dir() {
copy_dir_all_inner(&path, &dest_path, depth + 1)?;
} else {
std::fs::copy(&path, &dest_path)?;
}
}
Ok(())
}
pub fn collect_files_for_commit(root: &Path) -> std::io::Result<Vec<(String, String)>> {
let mut out = Vec::new();
collect_files_inner(root, root, 0, &mut out)?;
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
fn collect_files_inner(
root: &Path,
dir: &Path,
depth: usize,
out: &mut Vec<(String, String)>,
) -> std::io::Result<()> {
if depth >= MAX_DEPTH {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
collect_files_inner(root, &path, depth + 1, out)?;
} else if let Ok(rel) = path.strip_prefix(root) {
if let Ok(contents) = std::fs::read_to_string(&path) {
let repo_path = rel
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect::<Vec<_>>()
.join("/");
out.push((repo_path, contents));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn a_new_workspace_file_is_created_with_its_extension_and_valid_starting_content() {
let root = tmp_dir("new_item");
fs::create_dir_all(root.join("apis")).expect("subfolder");
let c = create_item(
&root,
&root.join("apis"),
"billing",
NewItemKind::Collection,
)
.expect("the collection is created");
assert_eq!(c, root.join("apis/billing.hurl"), "extension filled in");
let r = create_item(&root, &root, "nightly.trail", NewItemKind::Report)
.expect("the report is created");
let text = fs::read_to_string(&r).expect("readable");
assert!(
crate::report::parser::parse_flow(&text).is_ok(),
"a new report parses, rather than starting life broken: {text:?}"
);
let e = create_item(&root, &root, "dev", NewItemKind::Environment)
.expect("the environment is created");
assert_eq!(e, root.join("dev.vars"));
assert!(
fs::read_to_string(&e).expect("readable").starts_with('#'),
"a new environment is a comment, not an empty file"
);
let nested = create_item(&root, &root, "team/smoke", NewItemKind::Collection)
.expect("the nested collection is created");
assert!(nested.exists(), "the missing folder was created for it");
let names: Vec<String> = scan_workspace(&root, true)
.into_iter()
.map(|e| e.display_name)
.collect();
for want in ["billing.hurl", "nightly.trail", "dev.vars", "smoke.hurl"] {
assert!(names.contains(&want.to_string()), "{want} is in the tree");
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn creating_a_workspace_file_refuses_to_escape_the_root_or_overwrite() {
let root = tmp_dir("new_item_guard");
fs::create_dir_all(&root).expect("root");
fs::write(root.join("taken.hurl"), "GET https://x\n").expect("existing file");
assert!(
matches!(
create_item(&root, &root, "../outside.hurl", NewItemKind::Collection),
Err(NewItemError::Escapes(_))
),
"a `..` segment is refused"
);
assert!(
matches!(
create_item(&root, &root, "/tmp/outside.hurl", NewItemKind::Collection),
Err(NewItemError::Escapes(_))
),
"an absolute path is refused"
);
assert!(
matches!(
create_item(&root, &root, "taken.hurl", NewItemKind::Collection),
Err(NewItemError::Exists(_))
),
"an existing file is never overwritten"
);
assert_eq!(
fs::read_to_string(root.join("taken.hurl")).expect("still there"),
"GET https://x\n",
"and it is left exactly as it was"
);
assert!(
matches!(
create_item(&root, &root, " ", NewItemKind::Collection),
Err(NewItemError::EmptyName)
),
"an empty name is nothing to report, just nothing to do"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn moving_a_workspace_item_relocates_it_and_repoints_what_referred_to_it() {
let root = tmp_dir("move_item");
fs::create_dir_all(root.join("apis")).expect("subfolder");
fs::write(root.join("billing.hurl"), "GET https://x\n").expect("collection");
let src = root.join("billing.hurl");
let moved = move_item(&root, &src, &root.join("apis")).expect("it moves");
assert_eq!(moved, root.join("apis/billing.hurl"));
assert!(!src.exists(), "it is no longer where it was");
assert!(moved.exists(), "and it is where it was put");
assert_eq!(
repoint(&src, &src, &moved),
Some(moved.clone()),
"the item itself is repointed"
);
assert_eq!(
repoint(
&root.join("team/a.hurl"),
&root.join("team"),
&root.join("apis/team")
),
Some(root.join("apis/team/a.hurl")),
"and so is anything that was inside a moved folder"
);
assert_eq!(
repoint(&root.join("other.hurl"), &src, &moved),
None,
"anything unrelated is left alone"
);
assert_eq!(
move_item(&root, &moved, &root.join("apis")).expect("no-op"),
moved,
"a move to where it already is is not an error"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn moving_a_workspace_item_refuses_to_escape_overwrite_or_nest_inside_itself() {
let root = tmp_dir("move_guard");
fs::create_dir_all(root.join("apis/deep")).expect("subfolders");
fs::write(root.join("a.hurl"), "GET https://x\n").expect("collection");
fs::write(root.join("apis/a.hurl"), "GET https://y\n").expect("clash");
assert!(
matches!(
move_item(&root, &root.join("a.hurl"), &root.join("apis")),
Err(MoveError::Exists(_))
),
"it will not replace the file already called that"
);
assert_eq!(
fs::read_to_string(root.join("apis/a.hurl")).expect("still there"),
"GET https://y\n",
"and the file it would have replaced is untouched"
);
assert!(
matches!(
move_item(&root, &root.join("apis"), &root.join("apis/deep")),
Err(MoveError::IntoItself)
),
"a folder cannot be moved inside itself"
);
assert!(
matches!(
move_item(&root, &root.join("a.hurl"), Path::new("/tmp")),
Err(MoveError::Escapes(_))
),
"nothing may be moved out of the workspace"
);
assert!(
root.join("a.hurl").exists(),
"and every refusal leaves the original exactly where it was"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn renaming_a_file_keeps_the_extension_that_says_what_it_is() {
let root = tmp_dir("rename_ext");
fs::create_dir_all(&root).expect("root");
fs::write(root.join("billing.hurl"), "GET https://x\n").expect("collection");
fs::write(root.join("nightly.trail"), "{\"nodes\":[]}\n").expect("report");
let renamed = rename_item(&root, &root.join("billing.hurl"), "invoices")
.expect("the collection is renamed");
assert_eq!(renamed, root.join("invoices.hurl"), "extension regained");
assert!(!root.join("billing.hurl").exists(), "old name is gone");
assert!(renamed.exists(), "new name is on disk");
let kept = rename_item(&root, &root.join("nightly.trail"), "archive.bak")
.expect("the report is renamed");
assert_eq!(
kept,
root.join("archive.bak.trail"),
"the .trail is preserved so the report stays visible"
);
assert!(
is_report_file(&kept),
"and it is still recognised as a report"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn renaming_a_folder_uses_the_name_verbatim_and_a_same_name_rename_is_a_no_op() {
let root = tmp_dir("rename_folder");
fs::create_dir_all(root.join("apis")).expect("subfolder");
fs::write(root.join("apis/a.hurl"), "GET https://x\n").expect("file inside");
let renamed =
rename_item(&root, &root.join("apis"), "services").expect("the folder is renamed");
assert_eq!(renamed, root.join("services"), "no extension is added");
assert!(
root.join("services/a.hurl").exists(),
"its contents came with it"
);
let same = rename_item(&root, &root.join("services"), "services")
.expect("a same-name rename is a no-op success");
assert_eq!(same, root.join("services"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn renaming_refuses_a_collision_or_a_name_that_is_really_a_path() {
let root = tmp_dir("rename_guard");
fs::write(root.join("a.hurl"), "GET https://x\n").expect("collection");
fs::write(root.join("b.hurl"), "GET https://y\n").expect("other collection");
assert!(
matches!(
rename_item(&root, &root.join("a.hurl"), "b.hurl"),
Err(RenameError::Exists(_))
),
"a rename onto an existing name is refused"
);
assert_eq!(
fs::read_to_string(root.join("b.hurl")).expect("still there"),
"GET https://y\n",
"and the file it would have replaced is untouched"
);
for path_like in ["../escape", "sub/nested", "/etc/passwd"] {
assert!(
matches!(
rename_item(&root, &root.join("a.hurl"), path_like),
Err(RenameError::Escapes(_))
),
"a name containing a path ({path_like}) is refused"
);
}
assert!(
root.join("a.hurl").exists(),
"every refusal leaves the original exactly where it was"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn deleting_removes_a_file_or_a_whole_folder() {
let root = tmp_dir("delete_ok");
fs::create_dir_all(root.join("apis/deep")).expect("subfolders");
fs::write(root.join("keep.hurl"), "GET https://x\n").expect("survivor");
fs::write(root.join("apis/a.hurl"), "GET https://a\n").expect("nested file");
fs::write(root.join("apis/deep/b.hurl"), "GET https://b\n").expect("deeper file");
assert_eq!(
descendant_file_count(&root.join("apis")),
2,
"the folder holds two files, at any depth"
);
assert_eq!(
descendant_file_count(&root.join("keep.hurl")),
1,
"a file counts as one"
);
delete_item(&root, &root.join("keep.hurl")).expect("the file is deleted");
assert!(!root.join("keep.hurl").exists(), "the file is gone");
delete_item(&root, &root.join("apis")).expect("the folder is deleted");
assert!(
!root.join("apis").exists(),
"the folder and everything under it is gone"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn deleting_refuses_to_escape_the_root_or_delete_the_root() {
let root = tmp_dir("delete_guard");
fs::write(root.join("a.hurl"), "GET https://x\n").expect("collection");
assert!(
matches!(
delete_item(&root, Path::new("/tmp")),
Err(DeleteError::Escapes(_))
),
"nothing outside the workspace may be deleted"
);
assert!(
matches!(delete_item(&root, &root), Err(DeleteError::IsRoot)),
"the workspace root itself is never deleted"
);
assert!(
root.join("a.hurl").exists() && root.exists(),
"and every refusal leaves the workspace untouched"
);
let _ = fs::remove_dir_all(&root);
}
fn tmp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"paperboy_workspace_test_{name}_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn scans_files_and_subfolders_recursively_dirs_first_then_files_alphabetically() {
let root = tmp_dir("basic");
fs::write(root.join("b.hurl"), "").unwrap();
fs::write(root.join("a.hurl"), "").unwrap();
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(root.join("sub/c.hurl"), "").unwrap();
let entries = scan_workspace(&root, true);
let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
assert_eq!(names, vec!["sub", "c.hurl", "a.hurl", "b.hurl"]);
assert_eq!(entries[0].depth, 0);
assert!(entries[0].is_dir);
assert_eq!(entries[1].depth, 1);
assert!(!entries[1].is_dir);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn collect_files_for_commit_gathers_the_whole_visible_tree_with_slash_paths() {
let root = tmp_dir("collect_commit");
fs::write(root.join("a.hurl"), "GET a\n").unwrap();
fs::create_dir_all(root.join("api")).unwrap();
fs::write(root.join("api/b.hurl"), "GET b\n").unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".git/config"), "secret\n").unwrap();
let mut files = collect_files_for_commit(&root).unwrap();
files.sort();
let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(paths, vec!["a.hurl", "api/b.hurl"]);
assert_eq!(files[1].1, "GET b\n");
assert!(
!paths.iter().any(|p| p.contains(".git")),
"dot-prefixed entries are never collected"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn collect_files_for_commit_skips_non_utf8_files_rather_than_failing() {
let root = tmp_dir("collect_binary");
fs::write(root.join("ok.hurl"), "GET ok\n").unwrap();
fs::write(root.join("blob.bin"), [0xff, 0xfe, 0x00, 0x01]).unwrap();
let files = collect_files_for_commit(&root).unwrap();
let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(paths, vec!["ok.hurl"], "the binary file is skipped");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn filter_on_only_includes_hurl_and_json_files_case_insensitively() {
let root = tmp_dir("filter_on");
fs::write(root.join("keep.hurl"), "").unwrap();
fs::write(root.join("KEEP.JSON"), "").unwrap();
fs::write(root.join("env.vars"), "").unwrap();
fs::write(root.join("run.trail"), "").unwrap();
fs::write(root.join("skip.txt"), "").unwrap();
fs::write(root.join("skip.png"), "").unwrap();
let entries = scan_workspace(&root, true);
let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
assert!(names.contains(&"keep.hurl"));
assert!(names.contains(&"KEEP.JSON"));
assert!(names.contains(&"env.vars"));
assert!(names.contains(&"run.trail"));
assert!(!names.contains(&"skip.txt"));
assert!(!names.contains(&"skip.png"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn filter_off_shows_every_non_hidden_file_and_never_hides_empty_directories() {
let root = tmp_dir("filter_off");
fs::write(root.join("notes.txt"), "").unwrap();
fs::create_dir_all(root.join("empty_sub")).unwrap();
let entries = scan_workspace(&root, false);
let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
assert!(names.contains(&"notes.txt"));
assert!(names.contains(&"empty_sub"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn filter_on_still_lists_folders_that_hold_nothing_it_matches() {
let root = tmp_dir("hide_empty");
fs::create_dir_all(root.join("irrelevant")).unwrap();
fs::write(root.join("irrelevant/notes.txt"), "").unwrap();
fs::create_dir_all(root.join("brand_new")).unwrap();
fs::create_dir_all(root.join("relevant")).unwrap();
fs::write(root.join("relevant/req.hurl"), "").unwrap();
let entries = scan_workspace(&root, true);
let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
assert!(
names.contains(&"brand_new"),
"a folder just created to organise into is still shown when filtered"
);
assert!(
names.contains(&"irrelevant"),
"and so is one whose only contents the filter rejects"
);
assert!(
!names.contains(&"notes.txt"),
"the rejected file itself stays hidden -- the filter still applies to files"
);
assert!(names.contains(&"relevant"));
assert!(names.contains(&"req.hurl"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn creating_a_folder_makes_a_directory_and_never_appends_an_extension() {
let root = tmp_dir("new_folder");
let made = create_item(&root, &root, "v2 endpoints", NewItemKind::Folder)
.expect("a plain name inside the root is allowed");
assert!(made.is_dir(), "a folder was created, not a file");
assert_eq!(
made.file_name().unwrap(),
"v2 endpoints",
"the name is exactly what was typed -- no .hurl was appended"
);
let dotted = create_item(&root, &root, "v1.2", NewItemKind::Folder).unwrap();
assert_eq!(dotted.file_name().unwrap(), "v1.2");
assert!(
matches!(
create_item(&root, &root, "../escape", NewItemKind::Folder),
Err(NewItemError::Escapes(_))
),
"a folder cannot be created outside the workspace"
);
assert!(
matches!(
create_item(&root, &root, "v2 endpoints", NewItemKind::Folder),
Err(NewItemError::Exists(_))
),
"and an existing folder is not silently reused"
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn hidden_dot_prefixed_entries_are_always_excluded() {
let root = tmp_dir("hidden");
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".git/config"), "").unwrap();
fs::write(root.join(".hidden.hurl"), "").unwrap();
fs::write(root.join("visible.hurl"), "").unwrap();
for filter in [true, false] {
let entries = scan_workspace(&root, filter);
let names: Vec<&str> = entries.iter().map(|e| e.display_name.as_str()).collect();
assert!(!names.contains(&".git"));
assert!(!names.contains(&".hidden.hurl"));
assert!(names.contains(&"visible.hurl"));
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn deeply_nested_folders_are_flattened_depth_first_with_correct_depth_numbers() {
let root = tmp_dir("nested");
fs::create_dir_all(root.join("a/b/c")).unwrap();
fs::write(root.join("a/b/c/deep.hurl"), "").unwrap();
let entries = scan_workspace(&root, true);
let depths: Vec<(String, usize)> = entries
.iter()
.map(|e| (e.display_name.clone(), e.depth))
.collect();
assert_eq!(
depths,
vec![
("a".to_string(), 0),
("b".to_string(), 1),
("c".to_string(), 2),
("deep.hurl".to_string(), 3)
]
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn scanning_a_missing_root_yields_an_empty_list_rather_than_panicking() {
let root = std::env::temp_dir().join("paperboy_workspace_test_definitely_missing_xyz");
let _ = fs::remove_dir_all(&root);
assert_eq!(scan_workspace(&root, true), Vec::new());
}
#[test]
fn copy_dir_all_copies_nested_files_and_folders_but_skips_hidden_entries() {
let src = tmp_dir("copy_src");
let dst = std::env::temp_dir().join(format!(
"paperboy_workspace_test_copy_dst_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dst);
fs::write(src.join("a.hurl"), "GET https://example.com/a\n").unwrap();
fs::create_dir_all(src.join("sub")).unwrap();
fs::write(src.join("sub/b.json"), "{}").unwrap();
fs::create_dir_all(src.join(".git")).unwrap();
fs::write(src.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
fs::write(src.join(".hidden"), "nope").unwrap();
copy_dir_all(&src, &dst).unwrap();
assert_eq!(
fs::read_to_string(dst.join("a.hurl")).unwrap(),
"GET https://example.com/a\n"
);
assert_eq!(fs::read_to_string(dst.join("sub/b.json")).unwrap(), "{}");
assert!(
!dst.join(".git").exists(),
"hidden dot-prefixed folders (like .git) are never copied"
);
assert!(!dst.join(".hidden").exists());
let _ = fs::remove_dir_all(&src);
let _ = fs::remove_dir_all(&dst);
}
#[test]
fn copy_dir_all_creates_the_destination_even_for_an_empty_source() {
let src = tmp_dir("copy_empty_src");
let dst = std::env::temp_dir().join(format!(
"paperboy_workspace_test_copy_empty_dst_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dst);
copy_dir_all(&src, &dst).unwrap();
assert!(dst.is_dir());
let _ = fs::remove_dir_all(&src);
let _ = fs::remove_dir_all(&dst);
}
}