lsuser 0.2.2

List system users in a clean, columnar layout using raw POSIX libc calls.
use clap::Parser;
use serde::Serialize;
use std::collections::HashMap;
use std::ffi::CStr;
use std::ffi::CString;
use std::os::raw::c_char;

#[derive(Parser, Debug)]
#[command(
    name = "lsuser",
    author = "Vincent Yang",
    version,
    about = "List system users in a clean, block-device-like columnar layout.",
    help_template = "Usage:\n    {usage}\n\nOptions:\n{options}"
)]
struct Args {
    #[arg(short, long, help = "Disable built-in filters and list all system daemon accounts.")]
    all: bool,

    #[arg(short, long, help = "Do not print a header line.")]
    noheadings: bool,

    #[arg(short, long, value_name = "LIST", help = "Specify which output columns to print (comma-separated).")]
    output: Option<String>,

    #[arg(short = 'O', long, help = "Output all available columns.")]
    output_all: bool,

    #[arg(short = 'J', long, help = "Use JSON output format.")]
    json: bool,

    #[arg(long, value_name = "RANGE", help = "Filter by UID range (e.g. 0, 0-1000, 1000-).")]
    uid: Option<String>,

    #[arg(short = 'g', long, value_name = "RANGE", help = "Filter by GID range (e.g. 0, 0-1000, 1000-).")]
    gid: Option<String>,

    #[arg(long, value_name = "NAME", help = "Filter by group name.")]
    group: Option<String>,
}

#[derive(Serialize, Clone, Debug)]
struct UserInfo {
    user: String,
    uid: String,
    gid: String,
    real_name: String,
    home: String,
    shell: String,
}

fn safe_string(ptr: *mut c_char) -> String {
    if ptr.is_null() {
        return String::new();
    }
    unsafe { CStr::from_ptr(ptr).to_string_lossy().into_owned() }
}

fn parse_range(s: &str) -> (Option<u32>, Option<u32>) {
    let err = |msg: &str| -> ! {
        eprintln!("error: {msg}");
        std::process::exit(1);
    };
    if let Some(pos) = s.find('-') {
        let left = s[..pos].trim();
        let right = s[pos + 1..].trim();
        let min = if left.is_empty() {
            None
        } else {
            Some(left.parse().unwrap_or_else(|_| err(&format!("invalid number '{left}'"))))
        };
        let max = if right.is_empty() {
            None
        } else {
            Some(right.parse().unwrap_or_else(|_| err(&format!("invalid number '{right}'"))))
        };
        (min, max)
    } else {
        let val = s.trim().parse().unwrap_or_else(|_| err(&format!("invalid value '{s}'")));
        (Some(val), Some(val))
    }
}

fn gid_for_group(name: &str) -> Option<u32> {
    let c_name = CString::new(name).ok()?;
    unsafe {
        let grp = libc::getgrnam(c_name.as_ptr());
        if grp.is_null() {
            None
        } else {
            Some((*grp).gr_gid)
        }
    }
}

fn get_users(show_all: bool) -> Vec<UserInfo> {
    let mut users = Vec::new();
    let target_os = std::env::consts::OS;

    unsafe {
        libc::setpwent();
        loop {
            let pwd = libc::getpwent();
            if pwd.is_null() {
                break;
            }

            let name = safe_string((*pwd).pw_name);
            let uid = (*pwd).pw_uid;
            let gid = (*pwd).pw_gid;
            let gecos = safe_string((*pwd).pw_gecos);
            let real_name = gecos.split(',').next().unwrap_or("").to_string();
            let home = safe_string((*pwd).pw_dir);
            let shell = safe_string((*pwd).pw_shell);

            let is_system = if target_os == "macos" {
                name.starts_with('_') || uid < 501
            } else if target_os.ends_with("bsd") || target_os == "dragonfly" {
                uid < 1001 || uid == 65534
            } else {
                uid < 1000 || uid == 65534
            };

            if !show_all && is_system {
                continue;
            }

            users.push(UserInfo {
                user: name,
                uid: uid.to_string(),
                gid: gid.to_string(),
                real_name: if real_name.is_empty() { "N/A".to_string() } else { real_name },
                home,
                shell,
            });
        }
        libc::endpwent();
    }

    users.sort_by(|a, b| a.user.cmp(&b.user));
    users
}

fn print_table(users: &[UserInfo], columns: &[&str], no_headings: bool) {
    if users.is_empty() {
        return;
    }

    let mut widths = HashMap::new();
    for col in columns {
        widths.insert(*col, col.len());
    }

    for user in users {
        for col in columns {
            let val_len = match *col {
                "USER" => user.user.len(),
                "UID" => user.uid.len(),
                "GID" => user.gid.len(),
                "REAL_NAME" => user.real_name.len(),
                "HOME" => user.home.len(),
                "SHELL" => user.shell.len(),
                _ => 0,
            };
            if let Some(current_max) = widths.get_mut(col) {
                if val_len > *current_max {
                    *current_max = val_len;
                }
            }
        }
    }

    if !no_headings {
        let header = columns
            .iter()
            .map(|col| format!("{:<width$}", col, width = widths[col]))
            .collect::<Vec<_>>()
            .join("  ");
        println!("{}", header);
    }

    for user in users {
        let row = columns
            .iter()
            .map(|col| {
                let val = match *col {
                    "USER" => &user.user,
                    "UID" => &user.uid,
                    "GID" => &user.gid,
                    "REAL_NAME" => &user.real_name,
                    "HOME" => &user.home,
                    "SHELL" => &user.shell,
                    _ => "",
                };
                format!("{:<width$}", val, width = widths[col])
            })
            .collect::<Vec<_>>()
            .join("  ");
        println!("{}", row);
    }
}

fn main() {
    let args = Args::parse();
    let users = get_users(args.all);

    let users: Vec<UserInfo> = users.into_iter().filter(|u| {
        if let Some(ref uid_range) = args.uid {
            let (min, max) = parse_range(uid_range);
            let uid: u32 = u.uid.parse().unwrap_or(0);
            if let Some(lo) = min { if uid < lo { return false; } }
            if let Some(hi) = max { if uid > hi { return false; } }
        }
        if let Some(ref gid_range) = args.gid {
            let (min, max) = parse_range(gid_range);
            let gid: u32 = u.gid.parse().unwrap_or(0);
            if let Some(lo) = min { if gid < lo { return false; } }
            if let Some(hi) = max { if gid > hi { return false; } }
        }
        if let Some(ref group_name) = args.group {
            match gid_for_group(group_name) {
                Some(target_gid) => {
                    let gid: u32 = u.gid.parse().unwrap_or(0);
                    if gid != target_gid { return false; }
                }
                None => {
                    eprintln!("error: group '{group_name}' does not exist");
                    std::process::exit(1);
                }
            }
        }
        true
    }).collect();

    let all_available = vec!["USER", "UID", "GID", "REAL_NAME", "HOME", "SHELL"];
    let default_columns = vec!["USER", "UID", "HOME", "SHELL"];

    let custom_cols;
    let active_columns: Vec<&str> = if args.output_all {
        all_available.clone()
    } else if let Some(ref o) = args.output {
        custom_cols = o.split(',')
            .map(|s| s.trim().to_uppercase())
            .collect::<Vec<String>>();
        
        let filtered: Vec<&str> = custom_cols.iter()
            .map(|s| s.as_str())
            .filter(|s| all_available.contains(s))
            .collect();
            
        if filtered.is_empty() { default_columns } else { filtered }
    } else {
        default_columns
    };

    if args.json {
        let mut json_users = Vec::new();
        for user in users {
            let mut map = serde_json::Map::new();
            for col in &active_columns {
                let val = match *col {
                    "USER" => user.user.clone(),
                    "UID" => user.uid.clone(),
                    "GID" => user.gid.clone(),
                    "REAL_NAME" => user.real_name.clone(),
                    "HOME" => user.home.clone(),
                    "SHELL" => user.shell.clone(),
                    _ => String::new(),
                };
                map.insert(col.to_string(), serde_json::Value::String(val));
            }
            json_users.push(serde_json::Value::Object(map));
        }
        
        let wrapper = serde_json::json!({ "users": json_users });
        println!("{}", serde_json::to_string_pretty(&wrapper).unwrap());
        return;
    }

    print_table(&users, &active_columns, args.noheadings);
}