use polars::prelude::*;
use std::path::{Path, PathBuf};
const FALLBACK_NAME: &str = "export.csv";
pub fn default_filename(source: &str) -> String {
Path::new(source)
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| *stem != crate::config::STDIN_LABEL)
.map(|stem| format!("{}.export.csv", stem))
.unwrap_or_else(|| FALLBACK_NAME.to_string())
}
pub fn resolve_path(input: &str) -> Option<PathBuf> {
let trimmed = input.trim();
if trimmed.is_empty() {
return None;
}
let path = expand_home(trimmed);
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("csv"))
{
return Some(path);
}
let mut name = path.into_os_string();
name.push(".csv");
Some(PathBuf::from(name))
}
fn expand_home(path: &str) -> PathBuf {
let rest = match path.strip_prefix('~') {
Some("") => "",
Some(rest) => match rest.strip_prefix('/') {
Some(rest) => rest,
None => return PathBuf::from(path),
},
None => return PathBuf::from(path),
};
match dirs::home_dir() {
Some(home) => home.join(rest),
None => PathBuf::from(path),
}
}
pub fn write_csv(df: &mut DataFrame, path: &Path) -> std::io::Result<()> {
if let Some(scheme) = path.to_str().and_then(remote_scheme) {
return Err(std::io::Error::other(format!(
"cannot write to {} paths — export writes local files only",
scheme
)));
}
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
let scratch = scratch_sibling(path);
match write_all(df, &scratch) {
Ok(()) => std::fs::rename(&scratch, path),
Err(e) => {
let _ = std::fs::remove_file(&scratch);
Err(e)
}
}
}
fn write_all(df: &mut DataFrame, path: &Path) -> std::io::Result<()> {
let mut file = std::fs::File::create(path)?;
CsvWriter::new(&mut file)
.finish(df)
.map_err(std::io::Error::other)
}
fn scratch_sibling(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(".tmp");
PathBuf::from(name)
}
pub fn remote_scheme(path: &str) -> Option<&'static str> {
["az://", "s3://"]
.into_iter()
.find(|prefix| path.starts_with(prefix))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_filename_derives_from_the_source_stem() {
assert_eq!(default_filename("orders.csv"), "orders.export.csv");
assert_eq!(default_filename("data/orders.parquet"), "orders.export.csv");
}
#[test]
fn default_filename_keeps_only_the_basename() {
assert_eq!(
default_filename("az://bucket/data/orders.parquet"),
"orders.export.csv"
);
}
#[test]
fn default_filename_falls_back_for_stdin() {
assert_eq!(default_filename(crate::config::STDIN_LABEL), FALLBACK_NAME);
}
#[test]
fn default_filename_falls_back_when_there_is_no_stem() {
assert_eq!(default_filename("/"), FALLBACK_NAME);
assert_eq!(default_filename(""), FALLBACK_NAME);
}
#[test]
fn resolve_path_appends_csv_when_missing() {
assert_eq!(resolve_path("out").unwrap(), PathBuf::from("out.csv"));
assert_eq!(
resolve_path("out.parquet").unwrap(),
PathBuf::from("out.parquet.csv")
);
}
#[test]
fn resolve_path_leaves_an_existing_csv_extension_alone() {
assert_eq!(resolve_path("out.csv").unwrap(), PathBuf::from("out.csv"));
assert_eq!(resolve_path("out.CSV").unwrap(), PathBuf::from("out.CSV"));
}
#[test]
fn resolve_path_trims_input_and_rejects_blank() {
assert_eq!(
resolve_path(" out.csv ").unwrap(),
PathBuf::from("out.csv")
);
assert!(resolve_path(" ").is_none());
assert!(resolve_path("").is_none());
}
#[test]
fn resolve_path_expands_a_leading_home_tilde() {
let home = dirs::home_dir().unwrap();
assert_eq!(resolve_path("~/out.csv").unwrap(), home.join("out.csv"));
assert_eq!(resolve_path("~/a/b").unwrap(), home.join("a/b.csv"));
assert_eq!(resolve_path("~").unwrap(), home.join(".csv"));
}
#[test]
fn resolve_path_leaves_a_tilde_that_is_not_a_home_prefix_alone() {
assert_eq!(
resolve_path("~someone/out.csv").unwrap(),
PathBuf::from("~someone/out.csv")
);
assert_eq!(
resolve_path("dir/~/out.csv").unwrap(),
PathBuf::from("dir/~/out.csv")
);
}
fn nested_df() -> DataFrame {
let inner = Series::new("".into(), &[1i64, 2]);
DataFrame::new(vec![Series::new("b".into(), &[inner]).into()]).unwrap()
}
#[test]
fn a_failed_write_leaves_the_destination_untouched() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("precious.csv");
std::fs::write(&target, "keep,me\n1,2\n").unwrap();
let err = write_csv(&mut nested_df(), &target).unwrap_err();
assert!(err.to_string().contains("nested"), "{}", err);
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"keep,me\n1,2\n",
"a failed export must not truncate the file it was overwriting"
);
}
#[test]
fn a_failed_write_leaves_no_scratch_file_behind() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("out.csv");
assert!(write_csv(&mut nested_df(), &target).is_err());
assert!(!target.exists(), "no destination should be created");
assert!(!scratch_sibling(&target).exists(), "scratch file leaked");
}
#[test]
fn a_successful_write_leaves_no_scratch_file_behind() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("out.csv");
let mut df = df!("a" => [1i64, 2]).unwrap();
write_csv(&mut df, &target).unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "a\n1\n2\n");
assert!(!scratch_sibling(&target).exists(), "scratch file leaked");
}
#[test]
fn write_csv_creates_missing_parent_directories() {
let dir = tempfile::TempDir::new().unwrap();
let target = dir.path().join("reports").join("q3").join("out.csv");
let mut df = df!("a" => [1i64]).unwrap();
write_csv(&mut df, &target).unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "a\n1\n");
}
#[test]
fn write_csv_reports_a_parent_that_is_a_file() {
let dir = tempfile::TempDir::new().unwrap();
let blocker = dir.path().join("notadir");
std::fs::write(&blocker, "x").unwrap();
let mut df = df!("a" => [1i64]).unwrap();
assert!(write_csv(&mut df, &blocker.join("out.csv")).is_err());
}
#[test]
fn write_csv_refuses_remote_paths() {
let mut df = df!("a" => [1i64]).unwrap();
for path in ["az://bucket/out.csv", "s3://bucket/out.csv"] {
let err = write_csv(&mut df, Path::new(path)).unwrap_err();
assert!(
err.to_string().contains("local files only"),
"unexpected error for {}: {}",
path,
err
);
}
}
}