fix: Add Windows compatibility for file operations

- Fix home directory detection using BaseDirs from directories crate
- Add cross-platform file moving with copy+delete fallback
- Replace Unix-specific HOME env var usage
- Handle cross-filesystem moves on Windows automatically
This commit is contained in:
2025-12-28 23:54:53 +05:30
parent 5a92ecdeb7
commit 868ef57498
4 changed files with 716 additions and 102 deletions

271
src/config.rs Normal file
View File

@@ -0,0 +1,271 @@
use colored::*;
use directories::{BaseDirs, ProjectDirs};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
const MAX_RETRIES: u32 = 3;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub api_key: String,
pub download_folder: PathBuf,
}
impl Config {
fn get_config_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
if let Some(proj_dirs) = ProjectDirs::from("dev", "noentropy", "NoEntropy") {
let config_dir = proj_dirs.config_dir().to_path_buf();
fs::create_dir_all(&config_dir)?;
Ok(config_dir)
} else {
Err("Failed to determine config directory".into())
}
}
fn get_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(Self::get_config_dir()?.join("config.toml"))
}
pub fn load() -> Result<Config, Box<dyn std::error::Error>> {
let config_path = Self::get_config_path()?;
if !config_path.exists() {
return Err("Config file not found".into());
}
let content = fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&content)?;
if config.api_key.is_empty() {
return Err("API key not found in config file".into());
}
Ok(config)
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let config_path = Self::get_config_path()?;
let toml_string = toml::to_string_pretty(self)?;
fs::write(&config_path, toml_string)?;
println!(
"{} Configuration saved to {}",
"".green(),
config_path.display().to_string().yellow()
);
Ok(())
}
pub fn get_api_key() -> Result<String, Box<dyn std::error::Error>> {
match Self::load() {
Ok(config) => Ok(config.api_key),
Err(_) => Err("API key not configured".into()),
}
}
pub fn get_download_folder() -> Result<PathBuf, Box<dyn std::error::Error>> {
match Self::load() {
Ok(config) => Ok(config.download_folder),
Err(_) => Err("Download folder not configured".into()),
}
}
}
pub fn get_or_prompt_api_key() -> Result<String, Box<dyn std::error::Error>> {
if let Ok(config) = Config::load() {
if !config.api_key.is_empty() {
return Ok(config.api_key);
}
}
println!();
println!("{}", "🔑 NoEntropy Configuration".bold().cyan());
println!("{}", "─────────────────────────────".cyan());
let api_key = prompt_api_key()?;
let mut config = if let Ok(cfg) = Config::load() {
cfg
} else {
Config {
api_key: api_key.clone(),
download_folder: PathBuf::new(),
}
};
config.api_key = api_key.clone();
config.save()?;
println!();
Ok(api_key)
}
pub fn get_or_prompt_download_folder() -> Result<PathBuf, Box<dyn std::error::Error>> {
if let Ok(config) = Config::load() {
if !config.download_folder.as_os_str().is_empty() && config.download_folder.exists() {
return Ok(config.download_folder);
}
}
println!();
println!("{}", "📁 Download folder not configured.".yellow());
let folder_path = prompt_download_folder()?;
let mut config = if let Ok(cfg) = Config::load() {
cfg
} else {
Config {
api_key: String::new(),
download_folder: folder_path.clone(),
}
};
config.download_folder = folder_path.clone();
config.save()?;
println!();
Ok(folder_path)
}
fn prompt_api_key() -> Result<String, Box<dyn std::error::Error>> {
let mut attempts = 0;
println!();
println!("Get your API key at: {}", "https://ai.google.dev/".cyan().underline());
println!("Enter your API Key (starts with 'AIza'):");
while attempts < MAX_RETRIES {
print!("API Key: ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let key = input.trim();
if validate_api_key(key) {
return Ok(key.to_string());
}
attempts += 1;
let remaining = MAX_RETRIES - attempts;
eprintln!(
"{} Invalid API key format. Must start with 'AIza' and be around 39 characters.",
"".red()
);
if remaining > 0 {
eprintln!("Try again ({} attempts remaining):", remaining);
}
}
Err("Max retries exceeded. Please run again with a valid API key.".into())
}
fn prompt_download_folder() -> Result<PathBuf, Box<dyn std::error::Error>> {
let default_path = get_default_downloads_folder();
let default_display = default_path.to_string_lossy();
let mut attempts = 0;
println!(
"Enter path to folder to organize (e.g., {}):",
default_display.yellow()
);
println!("Or press Enter to use default: {}", default_display.green());
println!("Folder path: ");
while attempts < MAX_RETRIES {
print!("> ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let input = input.trim();
let path = if input.is_empty() {
default_path.clone()
} else {
let expanded = expand_home(input);
PathBuf::from(expanded)
};
if validate_folder_path(&path) {
return Ok(path);
}
attempts += 1;
let remaining = MAX_RETRIES - attempts;
eprintln!("{} Invalid folder path.", "".red());
if !path.exists() {
eprintln!(" Path does not exist: {}", path.display());
} else if !path.is_dir() {
eprintln!(" Path is not a directory: {}", path.display());
}
if remaining > 0 {
eprintln!("Try again ({} attempts remaining):", remaining);
println!("Folder path: ");
}
}
Err("Max retries exceeded. Please run again with a valid folder path.".into())
}
fn validate_api_key(key: &str) -> bool {
if key.is_empty() {
return false;
}
if !key.starts_with("AIza") {
return false;
}
if key.len() < 35 || key.len() > 50 {
return false;
}
true
}
fn validate_folder_path(path: &Path) -> bool {
if !path.exists() {
return false;
}
if !path.is_dir() {
return false;
}
true
}
fn get_default_downloads_folder() -> PathBuf {
if let Some(base_dirs) = BaseDirs::new() {
let home = base_dirs.home_dir();
return home.join("Downloads");
}
PathBuf::from("./Downloads")
}
fn expand_home(path: &str) -> String {
if path.starts_with("~/")
&& let Some(base_dirs) = BaseDirs::new()
{
let home = base_dirs.home_dir();
return path.replacen("~", &home.to_string_lossy(), 1);
}
path.to_string()
}

View File

@@ -2,7 +2,6 @@ use colored::*;
use serde::{Deserialize, Serialize};
use std::io;
use std::{ffi::OsStr, fs, path::Path, path::PathBuf};
use walkdir::WalkDir;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FileCategory {
@@ -25,28 +24,54 @@ impl FileBatch {
pub fn from_path(root_path: PathBuf) -> Self {
let mut filenames = Vec::new();
let mut paths = Vec::new();
for entry in WalkDir::new(&root_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
{
if let Ok(relative_path) = entry.path().strip_prefix(&root_path) {
filenames.push(relative_path.to_string_lossy().into_owned());
paths.push(entry.path().to_path_buf());
let entries = match fs::read_dir(&root_path) {
Ok(entries) => entries,
Err(e) => {
eprintln!("Error reading directory {:?}: {}", root_path, e);
return FileBatch {
filenames: Vec::new(),
paths: Vec::new(),
};
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_file()
&& let Ok(relative_path) = path.strip_prefix(&root_path) {
filenames.push(relative_path.to_string_lossy().into_owned());
paths.push(path);
}
}
FileBatch { filenames, paths }
}
/// Helper to get the number of files found
pub fn count(&self) -> usize {
self.filenames.len()
}
}
/// Move a file with cross-platform compatibility
/// Tries rename first (fastest), falls back to copy+delete if needed (e.g., cross-filesystem on Windows)
fn move_file_cross_platform(source: &Path, target: &Path) -> io::Result<()> {
match fs::rename(source, target) {
Ok(()) => Ok(()),
Err(e) => {
if cfg!(windows) || e.kind() == io::ErrorKind::CrossesDevices {
fs::copy(source, target)?;
fs::remove_file(source)?;
Ok(())
} else {
Err(e)
}
}
}
}
pub fn execute_move(base_path: &Path, plan: OrganizationPlan) {
// ---------------------------------------------------------
// PHASE 1: PREVIEW (Show the plan)
// ---------------------------------------------------------
println!("\n{}", "--- EXECUTION PLAN ---".bold().underline());
if plan.files.is_empty() {
@@ -54,7 +79,6 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) {
return;
}
// Iterate by reference (&) so we don't consume the data yet
for item in &plan.files {
let mut target_display = format!("{}", item.category.green());
if !item.sub_category.is_empty() {
@@ -64,9 +88,6 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) {
println!("Plan: {} -> {}/", item.filename, target_display);
}
// ---------------------------------------------------------
// PHASE 2: PROMPT (Ask for permission)
// ---------------------------------------------------------
eprint!("\nDo you want to apply these changes? [y/N]: ");
let mut input = String::new();
@@ -74,27 +95,25 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) {
.read_line(&mut input)
.is_err()
{
println!("\n{}", "Failed to read input. Operation cancelled.".red());
eprintln!("\n{}", "Failed to read input. Operation cancelled.".red());
return;
}
let input = input.trim().to_lowercase();
// If input is not "y" or "yes", abort.
if input != "y" && input != "yes" {
println!("\n{}", "Operation cancelled.".red());
return;
}
// ---------------------------------------------------------
// PHASE 3: EXECUTION (Actually move files)
// ---------------------------------------------------------
println!("\n{}", "--- MOVING FILES ---".bold().underline());
let mut moved_count = 0;
let mut error_count = 0;
for item in plan.files {
let source = base_path.join(&item.filename);
// Logic: Destination / Parent Category / Sub Category
let mut final_path = base_path.join(&item.category);
if !item.sub_category.is_empty() {
@@ -109,57 +128,111 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) {
let target = final_path.join(&file_name);
// 1. Create the category/sub-category folder
// (Only need to call this once per file path)
if let Err(e) = fs::create_dir_all(&final_path) {
println!(
eprintln!(
"{} Failed to create dir {:?}: {}",
"ERROR:".red(),
final_path,
e
);
continue; // Skip moving this file if we can't make the folder
error_count += 1;
continue;
}
// 2. Move the file
if source.exists() {
match fs::rename(&source, &target) {
Ok(_) => {
// Formatting the success message
if item.sub_category.is_empty() {
println!("Moved: {} -> {}/", item.filename, item.category.green());
} else {
println!(
"Moved: {} -> {}/{}",
item.filename,
item.category.green(),
item.sub_category.blue()
);
if let Ok(metadata) = fs::metadata(&source) {
if metadata.is_file() {
match move_file_cross_platform(&source, &target) {
Ok(_) => {
if item.sub_category.is_empty() {
println!(
"Moved: {} -> {}/",
item.filename,
item.category.green()
);
} else {
println!(
"Moved: {} -> {}/{}",
item.filename,
item.category.green(),
item.sub_category.blue()
);
}
moved_count += 1;
}
Err(e) => {
eprintln!("{} Failed to move {}: {}", "ERROR:".red(), item.filename, e);
error_count += 1;
}
}
Err(e) => println!("{} Failed to move {}: {}", "ERROR:".red(), item.filename, e),
} else {
eprintln!(
"{} Skipping {}: Not a file",
"WARN:".yellow(),
item.filename
);
}
} else {
println!(
eprintln!(
"{} Skipping {}: File not found",
"WARN:".yellow(),
item.filename
);
error_count += 1;
}
}
println!("\n{}", "Organization Complete!".bold().green());
} // --- 1. Helper to check if file is likely text ---
pub fn is_text_file(path: &Path) -> bool {
println!(
"Files moved: {}, Errors: {}",
moved_count.to_string().green(),
error_count.to_string().red()
);
} pub fn is_text_file(path: &Path) -> bool {
let text_extensions = [
"txt", "md", "rs", "py", "js", "html", "css", "json", "xml", "csv",
"txt",
"md",
"rs",
"py",
"js",
"ts",
"jsx",
"tsx",
"html",
"css",
"json",
"xml",
"csv",
"yaml",
"yml",
"toml",
"ini",
"cfg",
"conf",
"log",
"sh",
"bat",
"ps1",
"sql",
"c",
"cpp",
"h",
"hpp",
"java",
"go",
"rb",
"php",
"swift",
"kt",
"scala",
"lua",
"r",
"m",
];
if let Some(ext) = path.extension() {
if let Some(ext_str) = ext.to_str() {
if let Some(ext) = path.extension()
&& let Some(ext_str) = ext.to_str() {
return text_extensions.contains(&ext_str.to_lowercase().as_str());
}
}
false
}