moon 0.1.22

universal linux-only tool.
use std::env;
use std::path::PathBuf;

use crate::config::{Config, IconMode, cmd_config, ico};
use crate::bundle::cmd_bundle;
use crate::desktop::update_desktop_db;
use crate::doctor::cmd_doctor;
use crate::history::cmd_history;
use crate::inspect::cmd_inspect;
use crate::install::{InstallOpts, cmd_undo, install, list, remove_app};
use crate::paths::Dirs;
use crate::util::Res;
use crate::tui::tui_pick;

const VERSION: &str = env!("CARGO_PKG_VERSION");

fn usage() {
    println!(
        "moon {VERSION} - install archives, AppImages and .deb packages into ~/.local

USAGE:
    moon install <archive|url|deb> [opts]  install or upgrade an app
    moon bundle <name|folder> [out.moon]    pack an installed app, or a bundle
                                           folder, into one .moon file
    moon inspect <archive|url|deb|last>    show what installing would do
    moon list                              show installed apps
    moon remove <name>...                  uninstall (binaries, menu entry, files)
    moon undo [name]                       remove the app installed last (asks first)
    moon doctor [--fix]                    find apps you deleted by hand, repair them
    moon export <name> [dir]               copy an installed app as a portable bundle
    moon history [-n N] [--clear]          what was installed, and when
    moon config [--check]                  settings (icons, Nerd Font, update)
    moon help | --version

INSTALL OPTIONS:
    --name <name>       app/command name (default: guessed from the file name)
    --bin <rel/path>    main executable inside the archive (default: auto-detected)
    --desktop           always create a menu entry
    --no-desktop        never create a menu entry
    --force             overwrite entries not managed by moon
    --portable-dir <p>  keep the app on another disk (USB stick) instead of ~/.local

.deb OPTIONS:
    --root              install to the real /usr, /opt, ... (needs root, dpkg-style)
    --run-scripts       run the package's preinst/postinst (never run by default)
    --no-rewrite        don't rewrite absolute paths in desktop files/wrappers
    --dry-run, -n       show what would be installed, change nothing

EXPORT OPTIONS:
    --force             overwrite an existing bundle

BUNDLE OPTIONS:
    --force             overwrite the .moon file if it is there already

UNDO / CONFIG OPTIONS:
    --yes, -y           don't ask for confirmation
    --icons <mode>      auto | always | never   (default: auto = if Nerd Font found)
    --check             only report the newest release, install nothing
    --update            update without opening the menu
    --script <path|url> use another installer instead of the official one

UPDATE:
    moon config                 pick 2: fetch docs/scripts/install.sh into the tmp dir and
                                run it - moon closes itself first, so the new binary can
                                take over ~/.local/bin/moon

SUPPORTED: .tar.gz .tar.xz .tar.bz2 .tar.zst .tgz .tar .zip .7z .AppImage .deb .moon,
           or a bare executable. Apps live in ~/.local/share/moon/apps/<name>,
           commands are symlinked into ~/.local/bin."
    );
}

pub fn run() -> Res<()> {
    let all: Vec<String> = env::args().skip(1).collect();
    let Some(cmd) = all.first().map(String::as_str) else {
        usage();
        return Ok(());
    };
    let mut cfg = Config::load();

    let mut icons_set = false;
    let mut args: Vec<String> = Vec::with_capacity(all.len());
    let mut i = 0;
    while i < all.len() {
        if all[i] == "--icons" {
            if let Some(v) = all.get(i + 1) {
                cfg.icons = match v.as_str() {
                    "always" | "on" | "yes" => IconMode::Always,
                    "never" | "off" | "no" => IconMode::Never,
                    _ => IconMode::Auto,
                };
            }
            icons_set = true;
            i += 2;
            continue;
        }
        args.push(all[i].clone());
        i += 1;
    }
    match cmd {
        "help" | "-h" | "--help" => {
            usage();
            Ok(())
        }
        "-V" | "--version" | "version" => {
            println!("{} moon {VERSION}", cfg.icon(ico::MOON));
            Ok(())
        }
        "config" | "settings" | "prefs" => cmd_config(&mut cfg, icons_set, &args[1..]),
        "inspect" | "show" | "what" => {
            let d = Dirs::new()?;
            match args[1..].iter().find(|a| !a.starts_with('-')) {
                Some(t) => cmd_inspect(&d, &cfg, t),
                None => Err("usage: moon inspect <archive|url|deb|last>".into()),
            }
        }
        "undo" | "u" => {
            let d = Dirs::new()?;
            let name = args[1..].iter().find(|a| !a.starts_with('-')).map(|s| s.as_str());
            cmd_undo(&d, &cfg, name, args.iter().any(|a| a == "-y" || a == "--yes"))
        }
        "history" | "log" | "hist" => {
            let d = Dirs::new()?;
            let mut limit = None;
            let mut clear = false;
            let mut it = args[1..].iter();
            while let Some(a) = it.next() {
                match a.as_str() {
                    "--clear" => clear = true,
                    "-n" | "--limit" => {
                        limit = it.next().and_then(|v| v.parse().ok());
                    }
                    _ => {}
                }
            }
            cmd_history(&d, &cfg, limit, clear)
        }
        "list" | "ls" => list(&Dirs::new()?),
        "remove" | "uninstall" | "rm" => {
            if args.len() < 2 {
                return Err("usage: moon remove <name>...".into());
            }
            let d = Dirs::new()?;
            let mut failed = false;
            for n in &args[1..] {
                if let Err(e) = remove_app(&d, n, false) {
                    eprintln!("error: {} {e}", cfg.icon(ico::CROSS));
                    failed = true;
                }
            }
            update_desktop_db(&d);
            if failed { Err("some removals failed".into()) } else { Ok(()) }
        }
        "install" | "i" | "add" => {
            let mut o = InstallOpts {
                source: String::new(),
                name: None,
                bin: None,
                no_desktop: false,
                force_desktop: false,
                force: false,
                root: false,
                run_scripts: false,
                no_rewrite: false,
                dry_run: false,
                portable: None,
            };
            let mut deb_only: Vec<&str> = Vec::new();
            let mut it = args[1..].iter();
            while let Some(a) = it.next() {
                match a.as_str() {
                    "--name" => o.name = Some(it.next().ok_or("--name needs a value")?.clone()),
                    "--bin" => o.bin = Some(it.next().ok_or("--bin needs a value")?.clone()),
                    "--no-desktop" => o.no_desktop = true,
                    "--desktop" => o.force_desktop = true,
                    "--force" | "-f" => o.force = true,
                    "--root" => {
                        o.root = true;
                        deb_only.push("--root");
                    }
                    "--run-scripts" => {
                        o.run_scripts = true;
                        deb_only.push("--run-scripts");
                    }
                    "--no-rewrite" => {
                        o.no_rewrite = true;
                        deb_only.push("--no-rewrite");
                    }
                    "--dry-run" | "-n" => {
                        o.dry_run = true;
                        deb_only.push("--dry-run");
                    }
                    "--portable-dir" => {
                        let v = it.next().ok_or("--portable-dir needs a path")?;
                        o.portable = Some(PathBuf::from(v));
                    }
                    s if s.starts_with('-') => return Err(format!("unknown option '{s}'")),
                    s => {
                        if !o.source.is_empty() {
                            return Err("only one archive at a time".into());
                        }
                        o.source = s.to_string();
                    }
                }
            }
            if o.source.is_empty() && o.portable.is_none() {
                let d = Dirs::new()?;
                let start = env::current_dir().map_err(|e| e.to_string())?;
                let Some(picked) = tui_pick(&d, &cfg, &start)? else {
                    println!("{} Cancelled.", cfg.icon(ico::CROSS));
                    return Ok(());
                };
                o.source = picked.display().to_string();
            }
            if o.source.is_empty() {
                return Err("usage: moon install <archive|url|deb> [opts]".into());
            }
            install(&Dirs::new()?, &mut o)
        }
        "doctor" | "check" | "verify" | "repair" => {
            let d = Dirs::new()?;
            cmd_doctor(
                &d,
                &cfg,
                args.iter().any(|a| a == "--fix"),
                args.iter().any(|a| a == "-y" || a == "--yes"),
            )
        }
        "bundle" | "pack" => {
            let pos: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with('-')).collect();
            let Some(name) = pos.first().map(|s| s.as_str()) else {
                return Err("usage: moon bundle <name|folder> [out.moon]".into());
            };
            let d = Dirs::new()?;
            let force = args.iter().any(|a| a == "--force" || a == "-f");
            let out = pos.get(1).map(|p| PathBuf::from(p.as_str()));
            cmd_bundle(&d, &cfg, name, out.as_deref(), force)
        }
        "export" | "unexport" => {
            let pos: Vec<&String> = args[1..].iter().filter(|a| !a.starts_with('-')).collect();
            let Some(name) = pos.first().map(|s| s.as_str()) else {
                return Err(format!("usage: moon {cmd} <name> [dir]"));
            };
            let d = Dirs::new()?;
            let yes = args.iter().any(|a| a == "-y" || a == "--yes");
            let force = args.iter().any(|a| a == "--force" || a == "-f");
            match pos.get(1) {
                Some(p) => crate::portable::cmd_export(&d, &cfg, name, &PathBuf::from(p.as_str()), force),
                None => crate::portable::cmd_unexport(
                    &d,
                    &cfg,
                    name,
                    None,
                    yes || force,
                ),
            }
        }
        other => Err(format!("unknown command '{other}', try `moon help`")),
    }
}