use crate::errors::prelude::{CliError, Result as CliResult};
use std::fs;
use std::path::{Path, PathBuf};
pub fn ensure_directory_exists(path: &Path) -> CliResult<()> {
if !path.exists() {
fs::create_dir_all(path).map_err(|e| {
CliError::FileError(format!(
"Failed to create directory {}: {}",
path.display(),
e
))
})?;
} else if !path.is_dir() {
return Err(CliError::FileError(format!(
"Path exists but is not a directory: {}",
path.display()
)));
}
Ok(())
}
pub fn get_file_name_from_path(path: &str) -> String {
Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
.to_string()
}
pub fn get_file_extension(path: &str) -> Option<String> {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase())
}
pub fn is_supported_voice_format(extension: &str) -> bool {
matches!(
extension.to_lowercase().as_str(),
"ogg" | "mp3" | "wav" | "m4a" | "aac"
)
}
pub fn is_supported_file_format(extension: &str) -> bool {
let blocked_extensions = ["exe", "bat", "cmd", "com", "scr", "pif", "msi"];
!blocked_extensions.contains(&extension.to_lowercase().as_str())
}
pub fn sanitize_filename(filename: &str) -> String {
let unsafe_chars = ['<', '>', ':', '"', '|', '?', '*', '/', '\\'];
let mut sanitized = filename.to_string();
for unsafe_char in &unsafe_chars {
sanitized = sanitized.replace(*unsafe_char, "_");
}
sanitized = sanitized.trim_matches(|c| c == '.' || c == ' ').to_string();
if sanitized.is_empty() {
sanitized = "unnamed_file".to_string();
}
if sanitized.len() > 255 {
sanitized = sanitized[..255].to_string();
}
sanitized
}
pub fn get_file_size(path: &Path) -> CliResult<u64> {
let metadata = fs::metadata(path).map_err(|e| {
CliError::FileError(format!(
"Failed to get file metadata for {}: {}",
path.display(),
e
))
})?;
Ok(metadata.len())
}
pub fn normalize_path(path: &str) -> String {
Path::new(path).to_string_lossy().replace('\\', "/")
}
pub fn get_parent_dir(path: &Path) -> Option<PathBuf> {
path.parent().map(|p| p.to_path_buf())
}
pub fn join_path_components(base: &Path, components: &[&str]) -> PathBuf {
let mut path = base.to_path_buf();
for component in components {
path.push(component);
}
path
}
pub fn is_file_readable(path: &Path) -> bool {
path.exists() && path.is_file() && fs::metadata(path).is_ok()
}
pub fn is_directory_writable(path: &Path) -> bool {
if !path.exists() || !path.is_dir() {
return false;
}
let test_file = path.join(".write_test_temp");
match fs::write(&test_file, b"test") {
Ok(_) => {
let _ = fs::remove_file(&test_file);
true
}
Err(_) => false,
}
}
pub fn get_unique_filename(base_path: &Path) -> PathBuf {
if !base_path.exists() {
return base_path.to_path_buf();
}
let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
let stem = base_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("file");
let extension = base_path.extension().and_then(|s| s.to_str()).unwrap_or("");
for i in 1..1000 {
let new_filename = if extension.is_empty() {
format!("{stem}_{i}")
} else {
format!("{stem}_{i}.{extension}")
};
let new_path = parent.join(new_filename);
if !new_path.exists() {
return new_path;
}
}
base_path.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn test_get_file_name_from_path() {
assert_eq!(get_file_name_from_path("/path/to/file.txt"), "file.txt");
assert_eq!(get_file_name_from_path("file.txt"), "file.txt");
assert_eq!(get_file_name_from_path("/path/to/"), "to");
}
#[test]
fn test_get_file_extension() {
assert_eq!(get_file_extension("file.txt"), Some("txt".to_string()));
assert_eq!(get_file_extension("file.TAR.GZ"), Some("gz".to_string()));
assert_eq!(get_file_extension("file"), None);
assert_eq!(get_file_extension(".hidden"), None);
}
#[test]
fn test_is_supported_voice_format() {
assert!(is_supported_voice_format("ogg"));
assert!(is_supported_voice_format("mp3"));
assert!(is_supported_voice_format("OGG"));
assert!(!is_supported_voice_format("txt"));
assert!(!is_supported_voice_format("exe"));
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("normal_file.txt"), "normal_file.txt");
assert_eq!(sanitize_filename("file<>name.txt"), "file__name.txt");
assert_eq!(sanitize_filename(" file.txt "), "file.txt");
assert_eq!(sanitize_filename(""), "unnamed_file");
}
#[test]
fn test_validate_safe_path() {
use crate::utils::validation::validate_safe_path;
assert!(validate_safe_path("safe/path/file.txt").is_ok());
assert!(validate_safe_path("../unsafe/path").is_err());
assert!(validate_safe_path("~/home/path").is_err());
assert!(validate_safe_path("path/../file").is_err());
}
#[test]
fn test_normalize_path() {
assert_eq!(normalize_path("path\\to\\file"), "path/to/file");
assert_eq!(normalize_path("path/to/file"), "path/to/file");
}
#[test]
fn test_ensure_directory_exists() {
let temp_dir = tempdir().unwrap();
let new_dir = temp_dir.path().join("new_directory");
assert!(ensure_directory_exists(&new_dir).is_ok());
assert!(new_dir.exists() && new_dir.is_dir());
}
#[test]
fn test_get_unique_filename() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt");
let unique1 = get_unique_filename(&file_path);
assert_eq!(unique1, file_path);
fs::write(&file_path, "test").unwrap();
let unique2 = get_unique_filename(&file_path);
assert_ne!(unique2, file_path);
assert!(!unique2.exists());
}
#[test]
fn test_is_file_readable_cases() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("file.txt");
let dir_path = temp_dir.path().join("subdir");
let non_existent = temp_dir.path().join("nope.txt");
assert!(!is_file_readable(&non_existent));
fs::write(&file_path, "test").unwrap();
assert!(is_file_readable(&file_path));
fs::create_dir(&dir_path).unwrap();
assert!(!is_file_readable(&dir_path));
}
#[test]
fn test_is_directory_writable_cases() {
let temp_dir = tempdir().unwrap();
let dir_path = temp_dir.path().join("writedir");
let file_path = temp_dir.path().join("file.txt");
let non_existent = temp_dir.path().join("nope");
assert!(!is_directory_writable(&non_existent));
fs::create_dir(&dir_path).unwrap();
assert!(is_directory_writable(&dir_path));
fs::write(&file_path, "test").unwrap();
assert!(!is_directory_writable(&file_path));
#[cfg(unix)]
{
use std::fs::Permissions;
fs::set_permissions(&dir_path, Permissions::from_mode(0o400)).unwrap();
assert!(!is_directory_writable(&dir_path));
fs::set_permissions(&dir_path, Permissions::from_mode(0o700)).unwrap();
}
}
#[test]
fn test_join_path_components_cases() {
let base = PathBuf::from("/tmp");
let empty: [&str; 0] = [];
let single = ["foo"];
let multi = ["foo", "bar", "baz.txt"];
assert_eq!(join_path_components(&base, &empty), base);
assert_eq!(join_path_components(&base, &single), base.join("foo"));
assert_eq!(
join_path_components(&base, &multi),
base.join("foo/bar/baz.txt")
);
let abs = ["/abs", "file.txt"];
let joined = join_path_components(&base, &abs);
assert!(joined.ends_with("abs/file.txt"));
}
proptest! {
#[test]
fn prop_sanitize_filename_random(s in ".{0,512}") {
let mut s_trunc = String::new();
let mut total_bytes = 0;
for c in s.chars() {
let c_len = c.len_utf8();
if total_bytes + c_len > 255 {
break;
}
s_trunc.push(c);
total_bytes += c_len;
}
let sanitized = sanitize_filename(&s_trunc);
prop_assert!(!sanitized.is_empty());
for c in ['<', '>', ':', '"', '|', '?', '*', '/', '\\'] {
prop_assert!(!sanitized.contains(c));
}
prop_assert!(sanitized.len() <= 255);
}
#[test]
fn prop_get_file_name_from_path_random(s in ".{0,512}") {
let name = get_file_name_from_path(&s);
if s.is_empty() {
prop_assert!(name.is_empty());
} else {
prop_assert!(!name.is_empty());
}
}
#[test]
fn prop_get_file_extension_random(s in ".{0,512}") {
let _ = get_file_extension(&s);
}
#[test]
fn prop_normalize_path_random(s in ".{0,512}") {
let norm = normalize_path(&s);
prop_assert!(norm.len() <= s.len() + 10);
}
}
}