diff --git a/Cargo.lock b/Cargo.lock index 90837d0..7cdaa7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,7 +200,7 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rflatten" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 5a72344..1c55c03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rflatten" -version = "0.1.0" +version = "0.2.0" edition = "2024" description = "Flatten subdirectories by moving all files to the root directory" license = "GPL-3.0" diff --git a/README.md b/README.md index 81a46c6..9faa4cb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +`cargo install rflatten` + ``` RFLATTEN(1) User Commands RFLATTEN(1) @@ -30,6 +32,10 @@ OPTIONS Skip confirmation prompt and proceed immediately with the flatten operation. + -q, --quiet + Quiet mode - suppress all output except errors. Confirmation + prompt is automatically skipped (same behavior as --yes). + -i, --include Include only directories that begin with any of these values. Accepts comma-separated values. Uses case-insensitive prefix @@ -50,6 +56,9 @@ EXAMPLES Skip confirmation prompt: rflatten -y /path/to/directory + Quiet mode (no output except errors): + rflatten -q /path/to/directory + Only flatten first level subdirectories: rflatten --depth 1 /path/to/directory diff --git a/src/main.rs b/src/main.rs index e8e6a01..437a948 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,10 @@ struct Cli { #[arg(short = 'y', long = "yes")] skip_confirmation: bool, + /// Quiet mode - suppress all output except errors + #[arg(short = 'q', long = "quiet")] + quiet: bool, + /// Include only directories that start with these patterns (comma-separated) #[arg(short = 'i', long = "include", value_delimiter = ',')] include: Option>, @@ -184,6 +188,7 @@ fn flatten_directory_by_traversal( max_depth: Option, include: &Option>, exclude: &Option>, + quiet: bool, ) -> io::Result { let mut moved_count = 0; @@ -196,6 +201,7 @@ fn flatten_directory_by_traversal( exclude, &mut moved_count, None, + quiet, )?; Ok(moved_count) @@ -210,6 +216,7 @@ fn flatten_directory_by_traversal_recursive( exclude: &Option>, moved_count: &mut usize, top_level_dir: Option, + quiet: bool, ) -> io::Result<()> { if let Some(max) = max_depth { if current_depth > max { @@ -250,6 +257,7 @@ fn flatten_directory_by_traversal_recursive( exclude, moved_count, new_top_level_dir, + quiet, )?; } else if file_type.is_file() { // Only move files that are in subdirectories (not in root) @@ -265,6 +273,12 @@ fn flatten_directory_by_traversal_recursive( // Handle filename conflicts by appending a number let mut counter = 1; while dest.exists() { + // If the destination exists but is a directory, don't try to rename + // Let fs::rename fail and handle the error below + if dest.is_dir() { + break; + } + let stem = Path::new(file_name) .file_stem() .and_then(|s| s.to_str()) @@ -287,7 +301,9 @@ fn flatten_directory_by_traversal_recursive( match fs::rename(&path, &dest) { Ok(_) => { *moved_count += 1; - println!("Moved: {} -> {}", display_path(&path), display_path(&dest)); + if !quiet { + println!("Moved: {} -> {}", display_path(&path), display_path(&dest)); + } } Err(e) => { eprintln!("Error moving {}: {}", display_path(&path), e); @@ -332,27 +348,32 @@ fn main() -> io::Result<()> { )?; if summary.file_count == 0 { - println!("No files found in subdirectories to flatten."); + if !cli.quiet { + println!("No files found in subdirectories to flatten."); + } return Ok(()); } // Show summary and get confirmation - println!( - "Found {} file(s) to move to '{}'", - summary.file_count, - display_path(&canonical_directory) - ); - - if !summary.top_level_dirs.is_empty() { - println!("Top-level directories to be flattened:"); - let mut dirs: Vec<_> = summary.top_level_dirs.iter().cloned().collect(); - dirs.sort(); - for dir in dirs { - println!(" - {}", dir); + if !cli.quiet { + println!( + "Found {} file(s) to move to '{}'", + summary.file_count, + display_path(&canonical_directory) + ); + + if !summary.top_level_dirs.is_empty() { + println!("Top-level directories to be flattened:"); + let mut dirs: Vec<_> = summary.top_level_dirs.iter().cloned().collect(); + dirs.sort(); + for dir in dirs { + println!(" - {}", dir); + } } } - if !cli.skip_confirmation { + // Skip confirmation if -y or -q is provided + if !cli.skip_confirmation && !cli.quiet { if !get_confirmation()? { println!("Flatten cancelled."); return Ok(()); @@ -365,9 +386,12 @@ fn main() -> io::Result<()> { cli.max_depth, &cli.include, &cli.exclude, + cli.quiet, )?; - println!("\nSuccessfully moved {} file(s)", moved_count); + if !cli.quiet { + println!("\nSuccessfully moved {} file(s)", moved_count); + } // Delete the now-empty top-level directories for dir in &summary.top_level_dirs { @@ -628,7 +652,7 @@ mod tests { fs::write(subdir.join("test1.txt"), "content1").unwrap(); fs::write(subdir.join("test2.txt"), "content2").unwrap(); - let moved_count = flatten_directory_by_traversal(root, None, &None, &None).unwrap(); + let moved_count = flatten_directory_by_traversal(root, None, &None, &None, false).unwrap(); assert_eq!(moved_count, 2); assert!(root.join("test1.txt").exists()); @@ -656,7 +680,7 @@ mod tests { fs::create_dir(&subdir).unwrap(); fs::write(subdir.join("test.txt"), "subdir content").unwrap(); - let moved_count = flatten_directory_by_traversal(root, None, &None, &None).unwrap(); + let moved_count = flatten_directory_by_traversal(root, None, &None, &None, false).unwrap(); assert_eq!(moved_count, 1); // Original file should remain unchanged @@ -690,7 +714,7 @@ mod tests { fs::create_dir(&subdir2).unwrap(); fs::write(subdir2.join("test.txt"), "content2").unwrap(); - let moved_count = flatten_directory_by_traversal(root, None, &None, &None).unwrap(); + let moved_count = flatten_directory_by_traversal(root, None, &None, &None, false).unwrap(); assert_eq!(moved_count, 2); assert!(root.join("test.txt").exists()); @@ -704,7 +728,7 @@ mod tests { let root = temp_dir.path(); create_test_structure(root).unwrap(); - let moved_count = flatten_directory_by_traversal(root, Some(2), &None, &None).unwrap(); + let moved_count = flatten_directory_by_traversal(root, Some(2), &None, &None, false).unwrap(); // Should only move files at depths 1 and 2 assert_eq!(moved_count, 2); @@ -721,7 +745,7 @@ mod tests { create_multi_dir_structure(root).unwrap(); let include = Some(vec!["src".to_string()]); - let moved_count = flatten_directory_by_traversal(root, None, &include, &None).unwrap(); + let moved_count = flatten_directory_by_traversal(root, None, &include, &None, false).unwrap(); // Should only move files from "src" directory assert_eq!(moved_count, 1); @@ -737,7 +761,7 @@ mod tests { create_multi_dir_structure(root).unwrap(); let exclude = Some(vec!["src".to_string()]); - let moved_count = flatten_directory_by_traversal(root, None, &None, &exclude).unwrap(); + let moved_count = flatten_directory_by_traversal(root, None, &None, &exclude, false).unwrap(); // Should move all files except from "src" directory assert_eq!(moved_count, 3); @@ -752,7 +776,212 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let root = temp_dir.path(); - let moved_count = flatten_directory_by_traversal(root, None, &None, &None).unwrap(); + let moved_count = flatten_directory_by_traversal(root, None, &None, &None, false).unwrap(); assert_eq!(moved_count, 0); } + + // Tests for quiet mode + #[test] + fn test_flatten_quiet_mode_basic() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // Create subdirectory with files + let subdir = root.join("subdir"); + fs::create_dir(&subdir).unwrap(); + fs::write(subdir.join("test1.txt"), "content1").unwrap(); + fs::write(subdir.join("test2.txt"), "content2").unwrap(); + + // Test with quiet mode enabled + let moved_count = flatten_directory_by_traversal(root, None, &None, &None, true).unwrap(); + + // Verify files were moved correctly despite quiet mode + assert_eq!(moved_count, 2); + assert!(root.join("test1.txt").exists()); + assert!(root.join("test2.txt").exists()); + assert_eq!( + fs::read_to_string(root.join("test1.txt")).unwrap(), + "content1" + ); + assert_eq!( + fs::read_to_string(root.join("test2.txt")).unwrap(), + "content2" + ); + } + + #[test] + fn test_flatten_quiet_mode_with_conflicts() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // Create a file in root + fs::write(root.join("test.txt"), "root content").unwrap(); + + // Create subdirectory with conflicting filename + let subdir = root.join("subdir"); + fs::create_dir(&subdir).unwrap(); + fs::write(subdir.join("test.txt"), "subdir content").unwrap(); + + // Test with quiet mode enabled + let moved_count = flatten_directory_by_traversal(root, None, &None, &None, true).unwrap(); + + // Verify conflict resolution works in quiet mode + assert_eq!(moved_count, 1); + assert_eq!( + fs::read_to_string(root.join("test.txt")).unwrap(), + "root content" + ); + assert!(root.join("test_1.txt").exists()); + assert_eq!( + fs::read_to_string(root.join("test_1.txt")).unwrap(), + "subdir content" + ); + } + + #[test] + fn test_flatten_quiet_mode_with_depth() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + create_test_structure(root).unwrap(); + + // Test with quiet mode and max depth + let moved_count = flatten_directory_by_traversal(root, Some(2), &None, &None, true).unwrap(); + + // Verify depth limiting works in quiet mode + assert_eq!(moved_count, 2); + assert!(root.join("file1.txt").exists()); + assert!(root.join("file2.txt").exists()); + assert!(!root.join("file3.txt").exists()); + assert!(!root.join("file4.txt").exists()); + } + + #[test] + fn test_flatten_quiet_mode_with_include_filter() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + create_multi_dir_structure(root).unwrap(); + + let include = Some(vec!["src".to_string()]); + // Test with quiet mode and include filter + let moved_count = flatten_directory_by_traversal(root, None, &include, &None, true).unwrap(); + + // Verify filtering works in quiet mode + assert_eq!(moved_count, 1); + assert!(root.join("main.rs").exists()); + assert!(!root.join("readme.txt").exists()); + assert!(!root.join("test1.rs").exists()); + } + + #[test] + fn test_flatten_quiet_mode_with_exclude_filter() { + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + create_multi_dir_structure(root).unwrap(); + + let exclude = Some(vec!["src".to_string()]); + // Test with quiet mode and exclude filter + let moved_count = flatten_directory_by_traversal(root, None, &None, &exclude, true).unwrap(); + + // Verify excluding works in quiet mode + assert_eq!(moved_count, 3); + assert!(!root.join("main.rs").exists()); + assert!(root.join("readme.txt").exists()); + assert!(root.join("test1.rs").exists()); + assert!(root.join("guide.txt").exists()); + } + + #[test] + fn test_flatten_quiet_vs_normal_same_result() { + // Verify that quiet mode produces the same file operations as normal mode + let temp_dir1 = TempDir::new().unwrap(); + let root1 = temp_dir1.path(); + + let temp_dir2 = TempDir::new().unwrap(); + let root2 = temp_dir2.path(); + + // Create identical structures + let subdir1 = root1.join("subdir"); + fs::create_dir(&subdir1).unwrap(); + fs::write(subdir1.join("file1.txt"), "content1").unwrap(); + fs::write(subdir1.join("file2.txt"), "content2").unwrap(); + + let subdir2 = root2.join("subdir"); + fs::create_dir(&subdir2).unwrap(); + fs::write(subdir2.join("file1.txt"), "content1").unwrap(); + fs::write(subdir2.join("file2.txt"), "content2").unwrap(); + + // Run with normal mode + let count1 = flatten_directory_by_traversal(root1, None, &None, &None, false).unwrap(); + + // Run with quiet mode + let count2 = flatten_directory_by_traversal(root2, None, &None, &None, true).unwrap(); + + // Verify same number of files moved + assert_eq!(count1, count2); + assert_eq!(count1, 2); + + // Verify same files exist in both directories + assert!(root1.join("file1.txt").exists()); + assert!(root1.join("file2.txt").exists()); + assert!(root2.join("file1.txt").exists()); + assert!(root2.join("file2.txt").exists()); + + // Verify same content + assert_eq!( + fs::read_to_string(root1.join("file1.txt")).unwrap(), + fs::read_to_string(root2.join("file1.txt")).unwrap() + ); + assert_eq!( + fs::read_to_string(root1.join("file2.txt")).unwrap(), + fs::read_to_string(root2.join("file2.txt")).unwrap() + ); + } + + #[test] + fn test_flatten_quiet_mode_outputs_errors() { + // This test verifies that errors are still output even in quiet mode + // Quiet mode should suppress informational output but NOT error messages + let temp_dir = TempDir::new().unwrap(); + let root = temp_dir.path(); + + // Create a subdirectory with files + let subdir = root.join("subdir"); + fs::create_dir(&subdir).unwrap(); + fs::write(subdir.join("blocked.txt"), "will fail to move").unwrap(); + fs::write(subdir.join("success.txt"), "will move successfully").unwrap(); + + // Create a DIRECTORY (not a file) in root with the same name as one of the files + // This will cause fs::rename to fail for blocked.txt because you can't rename + // a file to a path that already exists as a directory + let blocking_dir = root.join("blocked.txt"); + fs::create_dir(&blocking_dir).unwrap(); + + // Run with quiet mode enabled + // The function should continue despite the error and return Ok + let moved_count = flatten_directory_by_traversal(root, None, &None, &None, true).unwrap(); + + // Verify only the successful file was moved (count should be 1, not 2) + assert_eq!(moved_count, 1); + + // Verify success.txt was moved successfully + assert!(root.join("success.txt").exists()); + assert_eq!( + fs::read_to_string(root.join("success.txt")).unwrap(), + "will move successfully" + ); + + // Verify blocked.txt was NOT moved (still in subdirectory) + assert!(subdir.join("blocked.txt").exists()); + + // Verify the blocking directory still exists + assert!(blocking_dir.exists()); + assert!(blocking_dir.is_dir()); + + // Note: This test verifies the error BEHAVIOR (file not moved, operation continues) + // The actual error message "Error moving..." is written to stderr via eprintln! + // In a real run with quiet mode, you would see: + // stderr: "Error moving /path/to/subdir/blocked.txt: ..." + // stdout: (empty - no "Moved:" messages due to quiet mode) + // To verify stderr output, run: cargo test test_flatten_quiet_mode_outputs_errors -- --nocapture + } }