whycant 0.1.0

CLI tool to figure out why you can't do something.
Documentation
mod access_check;
mod cli;
mod config;
mod engine;
mod fix;
mod identity;
mod layers;
mod op;
mod render;
mod report;
mod term;

use anyhow::{Result, anyhow};
use clap::Parser;
use cli::{Action, Cli, Format};
use op::{Op, OpArg};
use report::Verdict;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use term::TermCtx;

enum AppError {
    Usage(String),
    Internal(anyhow::Error),
}

impl From<anyhow::Error> for AppError {
    fn from(e: anyhow::Error) -> Self {
        Self::Internal(e)
    }
}

fn main() {
    let code = match real_main() {
        Ok(c) => c,
        Err(AppError::Usage(m)) => {
            eprintln!("whycant: {m}");
            64
        }
        Err(AppError::Internal(e)) => {
            eprintln!("whycant: {e:#}");
            70
        }
    };
    std::process::exit(code);
}

fn real_main() -> Result<i32, AppError> {
    let cli = match Cli::try_parse() {
        Ok(c) => c,
        Err(e) => {
            let code = if e.use_stderr() { 64 } else { 0 };
            e.print().ok();
            return Ok(code);
        }
    };
    if let Some(shell) = cli.completions {
        cli::print_completions(shell)?;
        return Ok(0);
    }
    if cli.man {
        cli::print_manpage()?;
        return Ok(0);
    }

    let settings = config::load()?;
    let resolved = cli.resolve(&settings);
    let term = TermCtx::detect(resolved.color, resolved.ascii);
    let id = identity::resolve_target(cli.user.as_deref())
        .map_err(|e| AppError::Usage(e.to_string()))?;
    let (op, path) = dispatch(&cli).map_err(|e| AppError::Usage(e.to_string()))?;

    let tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
    let interactive = resolved.interactive.unwrap_or(tty);
    let action = cli
        .action(interactive)
        .map_err(|e| AppError::Usage(e.to_string()))?;

    let chain = engine::default_chain();
    let mut report = engine::run(&chain, &id, &path, op, resolved.all);
    if !resolved.print_fixes {
        report.fixes.clear();
    }

    let machine = matches!(resolved.format, Format::Json | Format::Toml);
    if !machine && !resolved.quiet {
        term.banner(&report.identity.privilege_note);
    }
    for w in &report.warnings {
        let _ = writeln!(term.err(), "whycant: {w}");
    }

    let out = render::render(&report, resolved.format, &term, resolved.all)?;
    writeln!(term.out(), "{out}").map_err(anyhow::Error::from)?;

    let blocked = report.verdict == Verdict::Blocked;
    match action {
        Action::PrintOnly => {}
        Action::ApplyFirst => fix::auto_apply(&report, &chain, &id, &path, op, &term)?,
        Action::Prompt if blocked && !report.fixes.is_empty() => {
            fix::interactive(&report, &chain, &id, &path, op, &term)?
        }
        Action::Prompt => {}
    }
    Ok(report::exit_code(&report))
}

fn dispatch(cli: &Cli) -> Result<(Op, PathBuf)> {
    if !cli.cmd.is_empty() {
        return op::infer_cmd(&cli.cmd).ok_or_else(|| anyhow!("cannot infer op from command"));
    }
    match (&cli.op, &cli.path) {
        (Some(tok), Some(p)) => match OpArg::parse_keyword(tok) {
            Some(oparg) => Ok((oparg.into(), p.clone())),
            None => Err(anyhow!("unknown op '{tok}'; usage: whycant <op> <path>")),
        },
        (Some(tok), None) => match OpArg::parse_keyword(tok) {
            Some(_) => Err(anyhow!("usage: whycant <op> <path>")),
            None => {
                let p = PathBuf::from(tok);
                let op = match std::fs::symlink_metadata(&p) {
                    Ok(meta) => op::infer_bare(&meta, &p),
                    Err(_) => Op::Read,
                };
                Ok((op, p))
            }
        },
        _ => Err(anyhow!("usage: whycant <op> <path>")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;
    use std::fs::Permissions;
    use std::os::unix::fs::PermissionsExt;

    fn cli(args: &[&str]) -> Cli {
        Cli::try_parse_from(args).unwrap()
    }

    #[test]
    fn bare_dir_infers_traverse() {
        let d = tempfile::tempdir().unwrap();
        let (op, path) =
            dispatch(&cli(&["whycant", "--json", d.path().to_str().unwrap()])).unwrap();
        assert_eq!(op, Op::Traverse);
        assert_eq!(path, d.path());
    }

    #[test]
    fn bare_regular_file_infers_read() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("plain");
        std::fs::write(&f, "x").unwrap();
        std::fs::set_permissions(&f, Permissions::from_mode(0o644)).unwrap();
        let (op, path) = dispatch(&cli(&["whycant", "--json", f.to_str().unwrap()])).unwrap();
        assert_eq!(op, Op::Read);
        assert_eq!(path, f);
    }

    #[test]
    fn bare_exec_file_infers_exec() {
        let d = tempfile::tempdir().unwrap();
        let f = d.path().join("prog");
        std::fs::write(&f, "#!/bin/sh\n").unwrap();
        std::fs::set_permissions(&f, Permissions::from_mode(0o755)).unwrap();
        let (op, _) = dispatch(&cli(&["whycant", "--json", f.to_str().unwrap()])).unwrap();
        assert_eq!(op, Op::Exec);
    }

    #[test]
    fn explicit_read_stays_read() {
        let (op, path) = dispatch(&cli(&["whycant", "read", "/etc/hostname"])).unwrap();
        assert_eq!(op, Op::Read);
        assert_eq!(path, PathBuf::from("/etc/hostname"));
    }

    #[test]
    fn lone_invalid_token_is_path() {
        let (op, path) = dispatch(&cli(&["whycant", "not-an-op-zzz"])).unwrap();
        assert_eq!(op, Op::Read);
        assert_eq!(path, PathBuf::from("not-an-op-zzz"));
    }

    #[test]
    fn lone_op_keyword_errors() {
        assert!(dispatch(&cli(&["whycant", "read"])).is_err());
    }
}