moon 0.1.1

universal linux-only tool.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::config::{Config, ico};
use crate::naming::capitalize;
use crate::paths::Dirs;
use crate::util::Res;

#[derive(Default)]
struct Bundled {
    name: Option<String>,
    comment: Option<String>,
    categories: Option<String>,
    icon: Option<String>,
    wmclass: Option<String>,
    terminal: Option<String>,
    codes: Vec<String>,
}

fn parse_desktop(path: &Path) -> Bundled {
    let text = fs::read_to_string(path).unwrap_or_default();
    let mut b = Bundled::default();
    let mut in_entry = false;
    for line in text.lines() {
        let l = line.trim();
        if l.starts_with('[') {
            in_entry = l == "[Desktop Entry]";
            continue;
        }
        if !in_entry || l.starts_with('#') {
            continue;
        }
        let Some((k, v)) = l.split_once('=') else { continue };
        let v = v.trim().to_string();
        match k.trim() {
            "Name" => {
                b.name.get_or_insert(v);
            }
            "Comment" => {
                b.comment.get_or_insert(v);
            }
            "Categories" => {
                b.categories.get_or_insert(v);
            }
            "Icon" => {
                b.icon.get_or_insert(v);
            }
            "StartupWMClass" => {
                b.wmclass.get_or_insert(v);
            }
            "Terminal" => {
                b.terminal.get_or_insert(v);
            }
            "Exec" if b.codes.is_empty() => {
                b.codes = v
                    .split_whitespace()
                    .filter(|t| t.len() == 2 && t.starts_with('%') && *t != "%%")
                    .map(String::from)
                    .collect();
            }
            _ => {}
        }
    }
    b
}

fn best_icon(c: Vec<&PathBuf>) -> Option<String> {
    c.into_iter()
        .max_by_key(|p| {
            let svg = p.extension().map_or(false, |e| e.eq_ignore_ascii_case("svg"));
            (svg, fs::metadata(p).map(|m| m.len()).unwrap_or(0))
        })
        .map(|p| p.to_string_lossy().into_owned())
}

fn resolve_icon(files: &[PathBuf], name: &str, hint: Option<&str>) -> Option<String> {
    let imgs: Vec<&PathBuf> = files
        .iter()
        .filter(|p| {
            p.extension().and_then(|e| e.to_str()).map_or(false, |e| {
                matches!(e.to_ascii_lowercase().as_str(), "png" | "svg" | "xpm")
            })
        })
        .collect();
    let stem_of = |p: &PathBuf| {
        p.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase()
    };

    if let Some(h) = hint {
        if h.starts_with('/') {
            let fname = Path::new(h).file_name().map(|s| s.to_os_string());
            let c: Vec<&PathBuf> =
                imgs.iter().copied().filter(|p| p.file_name().map(|s| s.to_os_string()) == fname).collect();
            if let Some(i) = best_icon(c) {
                return Some(i);
            }
        } else {
            let hl = h.to_ascii_lowercase();
            let c: Vec<&PathBuf> = imgs.iter().copied().filter(|p| stem_of(p) == hl).collect();
            return best_icon(c).or_else(|| Some(h.to_string()));
        }
    }
    let n = name.to_ascii_lowercase();
    best_icon(imgs.iter().copied().filter(|p| stem_of(p).contains(&n)).collect())
}

pub fn exec_quote(p: &Path) -> String {
    let s = p.to_string_lossy();
    if s.chars().any(|c| " \t\n\"'\\><~|&;$*?#()`%".contains(c)) {
        let e = s
            .replace('\\', "\\\\\\\\")
            .replace('"', "\\\"")
            .replace('`', "\\`")
            .replace('$', "\\$")
            .replace('%', "%%");
        format!("\"{e}\"")
    } else {
        s.into_owned()
    }
}

pub struct Entry {
    pub text: String,
    pub icon: Option<PathBuf>,
}

pub fn build_entry(
    name: &str,
    display: Option<&str>,
    files: &[PathBuf],
    main: &Path,
    force: bool,
) -> Option<Entry> {
    let n = name.to_ascii_lowercase();
    let deskfiles: Vec<&PathBuf> = files
        .iter()
        .filter(|p| p.extension().map_or(false, |e| e == "desktop"))
        .collect();
    let chosen = deskfiles
        .iter()
        .find(|p| p.file_stem().and_then(|s| s.to_str()).map_or(false, |s| s.to_ascii_lowercase().contains(&n)))
        .or(deskfiles.first())
        .map(|p| (*p).clone());
    let b = chosen.as_deref().map(parse_desktop).unwrap_or_default();
    let icon = resolve_icon(files, name, b.icon.as_deref());

    if chosen.is_none() && icon.is_none() && !force {
        return None;
    }

    let icon_src = icon.as_deref().filter(|i| i.starts_with('/')).map(PathBuf::from);

    let disp = display
        .map(String::from)
        .or(b.name.clone())
        .unwrap_or_else(|| capitalize(name));
    let mut exec = exec_quote(main);
    for c in &b.codes {
        exec.push(' ');
        exec.push_str(c);
    }

    let mut out = String::from("[Desktop Entry]\nType=Application\nVersion=1.0\n");
    out += &format!("Name={disp}\n");
    if let Some(c) = &b.comment {
        out += &format!("Comment={c}\n");
    }
    out += &format!("Exec={exec}\n");
    if let Some(i) = &icon {
        out += &format!("Icon={i}\n");
    }
    out += &format!("Terminal={}\n", b.terminal.as_deref().unwrap_or("false"));
    out += &format!("Categories={}\n", b.categories.as_deref().unwrap_or("Utility;"));
    if let Some(w) = &b.wmclass {
        out += &format!("StartupWMClass={w}\n");
    }
    Some(Entry { text: out, icon: icon_src })
}

pub fn write_desktop(
    d: &Dirs,
    name: &str,
    display: Option<&str>,
    files: &[PathBuf],
    main: &Path,
    force: bool,
) -> Res<Option<PathBuf>> {
    let Some(e) = build_entry(name, display, files, main, force) else {
        return Ok(None);
    };
    let path = d.desktop.join(format!("{name}.desktop"));
    fs::write(&path, e.text).map_err(|err| format!("cannot write {}: {err}", path.display()))?;
    Ok(Some(path))
}

pub fn update_desktop_db(d: &Dirs) {
    let cfg = Config::load();
    match Command::new("update-desktop-database").arg(&d.desktop).status() {
        Ok(s) if s.success() => println!("{} {} Desktop database updated", cfg.step(), cfg.icon(ico::CHECK)),
        Ok(_) => eprintln!(
            "warning: {} update-desktop-database failed (menu may need a relogin)",
            cfg.icon(ico::WARN)
        ),
        Err(_) => println!(
            "{} {} update-desktop-database not found (install desktop-file-utils); most menus refresh on their own",
            cfg.step(), cfg.icon(ico::INFO)
        ),
    }
}