fcoreutils 0.22.0

High-performance GNU coreutils replacement with SIMD and parallelism
Documentation
#[cfg(not(unix))]
fn main() {
    eprintln!("chgrp: only available on Unix");
    std::process::exit(1);
}

// fchgrp -- change group ownership
//
// Usage: chgrp [OPTION]... GROUP FILE...
//        chgrp [OPTION]... --reference=RFILE FILE...

#[cfg(unix)]
use std::process;

#[cfg(unix)]
const TOOL_NAME: &str = "chgrp";
#[cfg(unix)]
const VERSION: &str = env!("CARGO_PKG_VERSION");

#[cfg(unix)]
fn main() {
    coreutils_rs::common::reset_sigpipe();

    let args: Vec<String> = std::env::args().skip(1).collect();

    let mut config = coreutils_rs::chgrp::ChgrpConfig::default();
    let mut reference: Option<String> = None;
    let mut positional: Vec<String> = Vec::new();
    let mut saw_dashdash = false;
    let mut no_preserve_root = false;

    let mut i = 0;
    while i < args.len() {
        let arg = &args[i];

        if saw_dashdash {
            positional.push(arg.clone());
            i += 1;
            continue;
        }

        match arg.as_str() {
            "--help" => {
                print_help();
                return;
            }
            "--version" => {
                println!("{} (fcoreutils) {}", TOOL_NAME, VERSION);
                return;
            }
            "-c" | "--changes" => config.changes = true,
            "-f" | "--silent" | "--quiet" => config.silent = true,
            "-v" | "--verbose" => config.verbose = true,
            "--dereference" => config.no_dereference = false,
            "-h" | "--no-dereference" => config.no_dereference = true,
            "--preserve-root" => config.preserve_root = true,
            "--no-preserve-root" => no_preserve_root = true,
            "-R" | "--recursive" => config.recursive = true,
            "-H" => {
                config.symlink_follow = coreutils_rs::chown::SymlinkFollow::CommandLine;
            }
            "-L" => {
                config.symlink_follow = coreutils_rs::chown::SymlinkFollow::Always;
            }
            "-P" => {
                config.symlink_follow = coreutils_rs::chown::SymlinkFollow::Never;
            }
            "--" => saw_dashdash = true,
            s if s.starts_with("--reference=") => {
                reference = Some(s["--reference=".len()..].to_string());
            }
            s if s.starts_with('-') && s.len() > 1 && !s.starts_with("--") => {
                for ch in s[1..].chars() {
                    match ch {
                        'c' => config.changes = true,
                        'f' => config.silent = true,
                        'v' => config.verbose = true,
                        'h' => config.no_dereference = true,
                        'R' => config.recursive = true,
                        'H' => {
                            config.symlink_follow = coreutils_rs::chown::SymlinkFollow::CommandLine;
                        }
                        'L' => {
                            config.symlink_follow = coreutils_rs::chown::SymlinkFollow::Always;
                        }
                        'P' => {
                            config.symlink_follow = coreutils_rs::chown::SymlinkFollow::Never;
                        }
                        _ => {
                            eprintln!("{}: invalid option -- '{}'", TOOL_NAME, ch);
                            eprintln!("Try '{} --help' for more information.", TOOL_NAME);
                            process::exit(1);
                        }
                    }
                }
            }
            _ => positional.push(arg.clone()),
        }
        i += 1;
    }

    if no_preserve_root {
        config.preserve_root = false;
    }

    // Determine gid from reference or positional[0]
    let (gid, file_start) = if let Some(ref rfile) = reference {
        match coreutils_rs::chown::get_reference_ids(std::path::Path::new(rfile)) {
            Ok((_u, g)) => (g, 0),
            Err(e) => {
                eprintln!(
                    "{}: failed to get attributes of '{}': {}",
                    TOOL_NAME,
                    rfile,
                    coreutils_rs::common::io_error_msg(&e)
                );
                process::exit(1);
            }
        }
    } else {
        if positional.is_empty() {
            eprintln!("{}: missing operand", TOOL_NAME);
            eprintln!("Try '{} --help' for more information.", TOOL_NAME);
            process::exit(1);
        }
        let group_spec = &positional[0];
        if group_spec.is_empty() {
            // GNU chgrp treats '' as a no-op: no group change, just validate files exist
            let files = &positional[1..];
            if files.is_empty() {
                eprintln!("{}: missing operand after '{}'", TOOL_NAME, group_spec);
                eprintln!("Try '{} --help' for more information.", TOOL_NAME);
                process::exit(1);
            }
            let mut errors = 0;
            for file in files {
                let path = std::path::Path::new(file);
                let exists = if config.no_dereference {
                    std::fs::symlink_metadata(path).is_ok()
                } else {
                    std::fs::metadata(path).is_ok()
                };
                if !exists {
                    if !config.silent {
                        eprintln!(
                            "{}: cannot access '{}': No such file or directory",
                            TOOL_NAME, file
                        );
                    }
                    errors += 1;
                }
            }
            if errors > 0 {
                process::exit(1);
            }
            return;
        }
        match coreutils_rs::chown::resolve_group(group_spec) {
            Some(g) => (g, 1),
            None => {
                eprintln!("{}: invalid group: '{}'", TOOL_NAME, group_spec);
                process::exit(1);
            }
        }
    };

    let files = &positional[file_start..];
    if files.is_empty() {
        eprintln!(
            "{}: missing operand after '{}'",
            TOOL_NAME,
            positional.first().map(|s| s.as_str()).unwrap_or("")
        );
        eprintln!("Try '{} --help' for more information.", TOOL_NAME);
        process::exit(1);
    }

    let mut errors = 0;
    for file in files {
        let path = std::path::Path::new(file);
        if config.recursive {
            errors += coreutils_rs::chgrp::chgrp_recursive(path, gid, &config, true, TOOL_NAME);
        } else {
            match coreutils_rs::chgrp::chgrp_file(path, gid, &config) {
                Ok(_) => {}
                Err(e) => {
                    if !config.silent {
                        eprintln!(
                            "{}: changing group of '{}': {}",
                            TOOL_NAME,
                            file,
                            coreutils_rs::common::io_error_msg(&e)
                        );
                    }
                    errors += 1;
                }
            }
        }
    }

    if errors > 0 {
        process::exit(1);
    }
}

#[cfg(unix)]
fn print_help() {
    println!("Usage: {} [OPTION]... GROUP FILE...", TOOL_NAME);
    println!("  or:  {} [OPTION]... --reference=RFILE FILE...", TOOL_NAME);
    println!("Change the group of each FILE to GROUP.");
    println!("With --reference, change the group of each FILE to that of RFILE.");
    println!();
    println!("  -c, --changes          like verbose but report only when a change is made");
    println!("  -f, --silent, --quiet   suppress most error messages");
    println!("  -v, --verbose          output a diagnostic for every file processed");
    println!("      --dereference      affect the referent of each symbolic link (default)");
    println!("  -h, --no-dereference   affect symbolic links instead of any referenced file");
    println!("      --no-preserve-root  do not treat '/' specially (the default)");
    println!("      --preserve-root    fail to operate recursively on '/'");
    println!("      --reference=RFILE  use RFILE's group rather than specifying a GROUP value");
    println!("  -R, --recursive        operate on files and directories recursively");
    println!();
    println!("The following options modify how a hierarchy is traversed when -R is specified:");
    println!("  -H                     if a command line argument is a symbolic link to a");
    println!("                         directory, traverse it");
    println!("  -L                     traverse every symbolic link to a directory encountered");
    println!("  -P                     do not traverse any symbolic links (default)");
    println!();
    println!("      --help     display this help and exit");
    println!("      --version  output version information and exit");
}

#[cfg(test)]
mod tests {
    use std::process::Command;

    fn cmd() -> Command {
        let mut path = std::env::current_exe().unwrap();
        path.pop();
        path.pop();
        path.push("fchgrp");
        Command::new(path)
    }

    #[test]
    #[cfg(unix)]
    fn test_chgrp_matches_gnu_errors_missing_operand() {
        let output = cmd().output().unwrap();
        assert_ne!(output.status.code(), Some(0));
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(stderr.contains("missing operand"));

        let gnu = Command::new("chgrp").output();
        if let Ok(gnu) = gnu {
            assert_ne!(gnu.status.code(), Some(0));
        }
    }

    #[test]
    #[cfg(unix)]
    fn test_chgrp_matches_gnu_errors_missing_file() {
        #[cfg(target_os = "macos")]
        let group = "wheel";
        #[cfg(not(target_os = "macos"))]
        let group = "root";
        let output = cmd().arg(group).output().unwrap();
        assert_ne!(output.status.code(), Some(0));
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(stderr.contains("missing operand"), "stderr was: {}", stderr);
    }

    #[test]
    #[cfg(unix)]
    fn test_chgrp_matches_gnu_errors_invalid_group() {
        let output = cmd()
            .args(["nonexistent_group_xyz_99999", "/tmp/nofile"])
            .output()
            .unwrap();
        assert_ne!(output.status.code(), Some(0));
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(stderr.contains("invalid group"), "stderr was: {}", stderr);
    }
    #[test]
    #[cfg(unix)]
    fn test_chgrp_preserve_root() {
        #[cfg(target_os = "macos")]
        let group = "wheel";
        #[cfg(not(target_os = "macos"))]
        let group = "root";
        let output = cmd()
            .args(["--preserve-root", "-R", group, "/"])
            .output()
            .unwrap();
        assert_ne!(output.status.code(), Some(0));
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(
            stderr.contains("dangerous to operate recursively on '/'"),
            "stderr was: {}",
            stderr
        );
    }

    #[test]
    #[cfg(unix)]
    fn test_chgrp_nonexistent_file() {
        #[cfg(target_os = "macos")]
        let group = "wheel";
        #[cfg(not(target_os = "macos"))]
        let group = "root";
        let output = cmd()
            .args([group, "/nonexistent_file_xyz_99999"])
            .output()
            .unwrap();
        assert_ne!(output.status.code(), Some(0));
    }

    #[test]
    #[cfg(unix)]
    fn test_chgrp_verbose() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("test.txt");
        std::fs::write(&file, "data").unwrap();
        // Get current group
        use std::os::unix::fs::MetadataExt;
        let meta = std::fs::metadata(&file).unwrap();
        let gid = meta.gid();
        // Use numeric GID to change to same group (always works without root)
        let output = cmd()
            .args(["-v", &gid.to_string(), file.to_str().unwrap()])
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "stderr: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    #[test]
    #[cfg(unix)]
    fn test_chgrp_reference() {
        let dir = tempfile::tempdir().unwrap();
        let ref_file = dir.path().join("ref.txt");
        let target = dir.path().join("target.txt");
        std::fs::write(&ref_file, "ref").unwrap();
        std::fs::write(&target, "target").unwrap();
        let output = cmd()
            .args([
                &format!("--reference={}", ref_file.display()),
                target.to_str().unwrap(),
            ])
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "stderr: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    #[test]
    #[cfg(unix)]
    fn test_chgrp_recursive() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("sub");
        std::fs::create_dir(&sub).unwrap();
        std::fs::write(sub.join("f.txt"), "data").unwrap();
        use std::os::unix::fs::MetadataExt;
        let gid = std::fs::metadata(dir.path()).unwrap().gid();
        let output = cmd()
            .args(["-R", &gid.to_string(), dir.path().to_str().unwrap()])
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "stderr: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    #[test]
    #[cfg(unix)]
    fn test_chgrp_empty_group_spec() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("test.txt");
        std::fs::write(&file, "data").unwrap();
        let output = cmd().args(["", file.to_str().unwrap()]).output().unwrap();
        // Empty group spec is treated as a no-op (no group change) — matching GNU behavior
        // Just verify it exits cleanly (0 or 1)
        let code = output.status.code().unwrap();
        assert!(code == 0 || code == 1);
    }
}