appimg 0.3.0

Install, update and remove AppImages as proper desktop applications
use anyhow::Result;
use appimg_core::json::escape;
use appimg_core::list::{Health, InstalledApp};
use appimg_core::{list, update, Paths};

use crate::cli::ListArgs;
use crate::ui::{human_size, table, Ui};
use crate::Outcome;

pub fn run(paths: &Paths, ui: &Ui, args: &ListArgs) -> Result<Outcome> {
    let apps = list::list(paths)?;

    if args.json {
        ui.info(&to_json(&apps));
        return Ok(Outcome::Done);
    }

    if apps.is_empty() {
        ui.info("Nothing installed yet. Try: appimg install <PATH|URL>");
        return Ok(Outcome::NothingToDo);
    }

    let rows: Vec<Vec<String>> = apps
        .iter()
        .map(|app| {
            vec![
                app.name.clone(),
                app.version.clone().unwrap_or_else(|| "-".to_string()),
                app.size_bytes.map(human_size).unwrap_or_else(|| "-".to_string()),
                first_category(app),
                describe_health(ui, app),
            ]
        })
        .collect();

    ui.info(&table(ui, &["NAME", "VERSION", "SIZE", "CATEGORY", "STATUS"], &rows));
    Ok(Outcome::Done)
}

fn first_category(app: &InstalledApp) -> String {
    app.categories.first().cloned().unwrap_or_else(|| "-".to_string())
}

fn describe_health(ui: &Ui, app: &InstalledApp) -> String {
    match app.health {
        Health::Ok => ui.dim(&update::source_for(app).describe()),
        Health::MissingBinary => ui.bold("broken: binary missing"),
        Health::Incomplete => ui.bold("broken: entry incomplete"),
    }
}

fn to_json(apps: &[InstalledApp]) -> String {
    let items: Vec<String> = apps.iter().map(app_to_json).collect();
    format!("[{}]", items.join(","))
}

fn app_to_json(app: &InstalledApp) -> String {
    let categories: Vec<String> =
        app.categories.iter().map(|c| format!("\"{}\"", escape(c))).collect();

    format!(
        concat!(
            "{{\"slug\":\"{slug}\",\"name\":\"{name}\",\"comment\":{comment},",
            "\"version\":{version},\"categories\":[{categories}],\"source\":{source},",
            "\"update_info\":{update_info},\"installed_at\":{installed_at},",
            "\"appimage\":\"{appimage}\",\"desktop_entry\":\"{entry}\",",
            "\"size_bytes\":{size},\"health\":\"{health}\",\"update_source\":\"{update_source}\"}}"
        ),
        slug = escape(&app.slug),
        name = escape(&app.name),
        comment = optional(app.comment.as_deref()),
        version = optional(app.version.as_deref()),
        categories = categories.join(","),
        source = optional(app.origin.as_deref()),
        update_info = optional(app.update_info.as_deref()),
        installed_at = optional(app.installed_at.as_deref()),
        appimage = escape(&app.appimage_path.to_string_lossy()),
        entry = escape(&app.desktop_entry_path.to_string_lossy()),
        size = app.size_bytes.map(|s| s.to_string()).unwrap_or_else(|| "null".to_string()),
        health = health_name(app.health),
        update_source = update::source_for(app).describe(),
    )
}

fn health_name(health: Health) -> &'static str {
    match health {
        Health::Ok => "ok",
        Health::MissingBinary => "missing-binary",
        Health::Incomplete => "incomplete",
    }
}

fn optional(value: Option<&str>) -> String {
    match value {
        Some(value) => format!("\"{}\"", escape(value)),
        None => "null".to_string(),
    }
}