use std::path::{Component, Path, PathBuf};
const MAX_DEPTH: usize = 32;
#[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,
}
impl NewItemKind {
pub fn extension(self) -> &'static str {
match self {
NewItemKind::Collection => "hurl",
NewItemKind::Report => "trail",
NewItemKind::Environment => "vars",
}
}
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"),
}
}
}
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 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())))?;
}
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())))?;
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())))?;
Ok(dest)
}
#[derive(Clone, Debug, PartialEq)]
pub enum MoveError {
Escapes(String),
Exists(String),
IntoItself,
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);
if filter_hurl_json && sub.is_empty() {
continue;
}
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 {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("vars"))
}
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);
}
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_hides_directories_whose_subtree_has_no_matching_files() {
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("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(&"irrelevant"),
"a folder with no matching descendants is hidden when filtered"
);
assert!(names.contains(&"relevant"));
assert!(names.contains(&"req.hurl"));
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);
}
}