use crate::{
classify::{self, split_suffix, Category},
config,
};
use anyhow::Result;
use colored::Colorize;
use console::Term;
use dialoguer::{theme::ColorfulTheme, MultiSelect};
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
struct Move {
src: PathBuf,
dest: PathBuf, cat: Category,
name: String,
group: Option<String>, }
pub fn run(dir: &Path, dry_run: bool) -> Result<()> {
run_with_base(dir, &config::load().base, dry_run)
}
pub fn run_with_base(dir: &Path, base: &Path, dry_run: bool) -> Result<()> {
let term = Term::stderr();
if !dir.exists() {
anyhow::bail!("Directory not found: {}", dir.display());
}
if dry_run {
term.write_line(&format!(
"\n {}",
"[dry-run] nothing will be moved".dimmed()
))?;
}
let mut raw: Vec<_> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
if !path.is_dir() {
return false;
}
let name_os = e.file_name();
let name = name_os.to_string_lossy();
if name.starts_with('.') {
return false;
}
if name == "node_modules" || name == "target" || name == "dist" || name == "build" {
return false;
}
true
})
.collect();
raw.sort_by_key(|e| e.file_name());
if raw.is_empty() {
term.write_line(&format!(" {}", "No subdirectories found.".yellow()))?;
return Ok(());
}
let custom_rules = crate::rules::load_rules();
let classified: Vec<(PathBuf, String, Category)> = raw
.iter()
.map(|e| {
let src = e.path();
let name = e.file_name().to_string_lossy().to_string();
let cat = classify::classify(&src, &custom_rules);
(src, name, cat)
})
.collect();
let mut prefix_count: HashMap<String, usize> = HashMap::new();
for (_, name, _) in &classified {
if let Some((prefix, _)) = split_suffix(name) {
*prefix_count.entry(prefix.to_string()).or_insert(0) += 1;
}
}
let moves: Vec<Move> = classified
.into_iter()
.map(|(src, name, cat)| {
let (dest, group) = resolve_dest(&base, &name, &cat, &prefix_count);
Move {
src,
dest,
cat,
name,
group,
}
})
.collect();
term.write_line("")?;
term.write_line(&format!(
" {:<8} {:<18} {}",
"category".bold().underline(),
"group".bold().underline(),
"name".bold().underline(),
))?;
term.write_line(&format!(" {}", "─".repeat(54).dimmed()))?;
for m in &moves {
let group_label = m.group.as_deref().unwrap_or("─");
term.write_line(&format!(
" {} {:<18} {}",
m.cat.label(),
group_label.dimmed(),
m.name.bold(),
))?;
}
term.write_line(&format!(" {}", "─".repeat(54).dimmed()))?;
term.write_line(&format!(
" {} project(s) → {}\n",
moves.len().to_string().bold(),
base.display().to_string().dimmed()
))?;
if dry_run {
term.write_line(&format!(
" {}",
"Dry-run complete. Re-run without -n to apply.".dimmed()
))?;
return Ok(());
}
let pending: Vec<&Move> = moves.iter().filter(|m| m.src != m.dest).collect();
if pending.is_empty() {
term.write_line(&format!(" {}", "Everything is already organised.".green()))?;
return Ok(());
}
let item_labels: Vec<String> = pending
.iter()
.map(|m| {
let group_label = m.group.as_deref().unwrap_or("─");
format!(
"{:<8} {:<18} {}",
m.cat.label(),
group_label,
m.name,
)
})
.collect();
let defaults = vec![true; pending.len()];
let selections = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt("Select projects to move")
.items(&item_labels)
.defaults(&defaults)
.interact_on(&term)?;
if selections.is_empty() {
term.write_line(&format!(" {}", "Aborted.".red()))?;
return Ok(());
}
let (mut moved, mut skipped) = (0usize, 0usize);
for idx in selections {
let m = pending[idx];
if let Some(parent) = m.dest.parent() {
std::fs::create_dir_all(parent)?;
}
if m.dest.exists() {
term.write_line(&format!(
" {} {} (destination exists, skipping)",
"─".yellow(),
m.name
))?;
skipped += 1;
continue;
}
match do_move(&m.src, &m.dest) {
Ok(()) => {
term.write_line(&format!(" {} {}", "✓".green(), m.name))?;
moved += 1;
}
Err(e) => {
term.write_line(&format!(" {} {}: {}", "✗".red(), m.name, e))?;
skipped += 1;
}
}
}
term.write_line("")?;
term.write_line(&format!(
" {} moved {} skipped",
moved.to_string().green().bold(),
skipped.to_string().yellow(),
))?;
Ok(())
}
pub fn resolve_dest(
base: &Path,
name: &str,
cat: &Category,
prefix_count: &HashMap<String, usize>,
) -> (PathBuf, Option<String>) {
if let Some((prefix, _)) = split_suffix(name) {
if prefix_count.get(prefix).copied().unwrap_or(0) >= 2 {
let dest = base
.join(cat.dir_name())
.join(prefix) .join(name); return (dest, Some(prefix.to_string()));
}
}
(base.join(cat.dir_name()).join(name), None)
}
fn do_move(src: &Path, dest: &Path) -> Result<()> {
match std::fs::rename(src, dest) {
Ok(()) => Ok(()),
Err(e) if e.raw_os_error() == Some(18) => {
copy_dir_all(src, dest)?;
std::fs::remove_dir_all(src)?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
} else {
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
}
}
Ok(())
}
pub fn organize_single(src: &Path) -> Result<PathBuf> {
let base = config::load().base;
if !src.exists() {
anyhow::bail!("Source directory does not exist: {}", src.display());
}
let name = src
.file_name()
.ok_or_else(|| anyhow::anyhow!("invalid directory path"))?
.to_string_lossy()
.to_string();
let custom_rules = crate::rules::load_rules();
let cat = classify::classify(src, &custom_rules);
let prefix_count = HashMap::new();
let (dest, _) = resolve_dest(&base, &name, &cat, &prefix_count);
if src == dest {
return Ok(dest);
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
if dest.exists() {
anyhow::bail!("Destination directory already exists: {}", dest.display());
}
do_move(src, &dest)?;
Ok(dest)
}