dl-cleaner 0.1.0

A tool to clean up the contents of your Downloads folder.
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use crate::scan::FileInfo;

use anyhow::Result;

pub fn organize_by_extension(download_dir: &Path, files: &[FileInfo]) -> Result<()> {
	let mut category_dirs: HashMap<String, PathBuf> = HashMap::new();

	for file in files {
		// 拡張子からカテゴリを判定
		let category = match_category(&file.name);

		// カテゴリが決まらない場合はスキップ(今は何もしない)
		if category.is_none() {
			continue;
		}

		let category = category.unwrap();

		// カテゴリ用ディレクトリのパスを取得 or 作成
		let target_dir = category_dirs
			.entry(category.to_string())
			.or_insert_with(|| download_dir.join(&category));

		if !target_dir.exists() {
			fs::create_dir_all(&target_dir)?;
		}

		// 移動先パスを組み立て
		let target_path = target_dir.join(&file.name);

		// 既に同名ファイルが存在する場合はいったんスキップ
		if target_path.exists() {
			// TODO: 衝突時のリネーム戦略などを後で検討
			continue;
		}

		fs::rename(&file.path, &target_path)?;
	}

	Ok(())
}

/// 拡張子からカテゴリ(ディレクトリ名)を判定する
/// 例: jpg, png → "Image" / mp3 → "Music" / dmg, exe → "Apps"
fn match_category(file_name: &str) -> Option<&'static str> {
	let ext = Path::new(file_name)
		.extension()
		.and_then(|e| e.to_str())?
		.to_ascii_lowercase();

	// 画像系
	const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "tiff", "svg", "avif"];    
	if IMAGE_EXTS.contains(&ext.as_str()) {
		return Some("Image");
	}

	// 音楽系
	const MUSIC_EXTS: &[&str] = &["mp3", "wav", "flac", "aac", "ogg", "m4a"];    
	if MUSIC_EXTS.contains(&ext.as_str()) {
		return Some("Music");
	}

	// 動画系
	const VIDEO_EXTS: &[&str] = &["mp4", "mov", "avi", "mkv", "wmv", "webm"];    
	if VIDEO_EXTS.contains(&ext.as_str()) {
		return Some("Video");
	}

	// アプリ・バイナリ系
	const APPS_EXTS: &[&str] = &["dmg", "exe", "msi", "pkg", "deb", "rpm", "appimage"];    
	if APPS_EXTS.contains(&ext.as_str()) {
		return Some("Apps");
	}

	// ドキュメント系
	const DOC_EXTS: &[&str] = &["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md"];    
	if DOC_EXTS.contains(&ext.as_str()) {
		return Some("Docs");
	}

	// アーカイブ/圧縮系
	const ARCHIVE_EXTS: &[&str] = &["zip", "tar", "gz", "tgz", "bz2", "7z", "rar", "xz", "lz4", "zst", "lz", "xz"];    
	if ARCHIVE_EXTS.contains(&ext.as_str()) {
		return Some("Archive");
	}

	// 圧縮形式の複合拡張子(例: .tar.gz)を検出するためにファイル名末尾もチェック
	let fname_lower = file_name.to_ascii_lowercase();
	const MULTI_PART_ARCHIVES: &[&str] = &[".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst"];
	for p in MULTI_PART_ARCHIVES {
		if fname_lower.ends_with(p) {
			return Some("Archive");
		}
	}
	if ARCHIVE_EXTS.contains(&ext.as_str()) {
		return Some("Archive");
	}

	// シェルスクリプト系
	const SHELL_EXTS: &[&str] = &["sh", "bash", "zsh", "ps1", "bat", "cmd", "ksh", "fish"];    
	if SHELL_EXTS.contains(&ext.as_str()) {
		return Some("Scripts");
	}

	// JSON系
	if ext == "json" {
		return Some("JSON");
	}

	None
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn test_match_category_basic() {
		assert_eq!(match_category("photo.JPG"), Some("Image"));
		assert_eq!(match_category("song.mp3"), Some("Music"));
		assert_eq!(match_category("movie.mkv"), Some("Video"));
		assert_eq!(match_category("installer.dmg"), Some("Apps"));
		assert_eq!(match_category("document.pdf"), Some("Docs"));
		assert_eq!(match_category("archive.zip"), Some("Archive"));
		assert_eq!(match_category("unknown.xyz"), None);
	}

	#[test]
	fn test_match_category_new_types() {
		assert_eq!(match_category("script.sh"), Some("Scripts"));
		assert_eq!(match_category("powershell.ps1"), Some("Scripts"));
		assert_eq!(match_category("setup.BAT"), Some("Scripts"));
		assert_eq!(match_category("data.json"), Some("JSON"));
		assert_eq!(match_category("archive.tar.gz"), Some("Archive"));
		assert_eq!(match_category("backup.tar.xz"), Some("Archive"));
		assert_eq!(match_category("image.PNG"), Some("Image"));
	}
}