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:
271
src/config.rs
Normal file
271
src/config.rs
Normal 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()
|
||||
}
|
||||
169
src/files.rs
169
src/files.rs
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user