ztheme 1.1.0

Fast asynchronous Zsh prompt
use std::fs::{self, OpenOptions};
use std::io::{self, BufRead as _, BufReader, Write as _};
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::process::Command;

use super::install::{
    InstallLock, TemporaryDirectory, data_root, download, require_command, run, verify_sha256,
};

const COMPONENT: &str = "zsh-syntax-highlighting";
const VERSION: &str = "0.8.0";
const REVISION: &str = "db085e4661f6aafd24e5acb5b2e17e4dd5dddf3e";
const SHA256: &str = "874999413d147b7ad6776077e1b756d31d68284f9ef2ef240b33a4c842f0e1d8";
const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024;

pub fn managed_script() -> PathBuf {
    managed_root().join("zsh-syntax-highlighting.zsh")
}

pub fn ensure_installed(assume_yes: bool) -> io::Result<bool> {
    let target = managed_root();
    if installed(&target) {
        return Ok(true);
    }
    if !assume_yes && !confirm_install()? {
        return Ok(false);
    }

    let parent = target
        .parent()
        .ok_or_else(|| io::Error::other("syntax highlighter destination has no parent"))?;
    fs::create_dir_all(parent)?;
    fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;

    let lock = InstallLock::acquire(
        &parent.join(".install.lock"),
        || installed(&target),
        COMPONENT,
    )?;
    if installed(&target) {
        return Ok(true);
    }

    install(&target)?;
    drop(lock);
    Ok(true)
}

fn managed_root() -> PathBuf {
    data_root()
        .join("ztheme/zsh-syntax-highlighting")
        .join(VERSION)
}

fn confirm_install() -> io::Result<bool> {
    let Ok(mut tty) = OpenOptions::new().read(true).write(true).open("/dev/tty") else {
        return Ok(false);
    };
    write!(
        tty,
        "ztheme can provide themed command-line syntax highlighting with \
         zsh-syntax-highlighting {VERSION}.\n\
         Install the pinned managed copy now? [y/N] "
    )?;
    tty.flush()?;
    let mut answer = String::new();
    BufReader::new(tty).read_line(&mut answer)?;
    Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "YES"))
}

fn install(target: &Path) -> io::Result<()> {
    require_command("tar", COMPONENT)?;
    let parent = target
        .parent()
        .ok_or_else(|| io::Error::other("syntax highlighter destination has no parent"))?;
    let temporary = TemporaryDirectory::create(parent, COMPONENT)?;
    let archive = temporary.path().join("source.tar.gz");
    let url =
        format!("https://github.com/zsh-users/zsh-syntax-highlighting/archive/{REVISION}.tar.gz");

    download(&url, &archive, MAX_ARCHIVE_BYTES, COMPONENT)?;
    verify_sha256(&archive, SHA256, COMPONENT)?;
    run(
        Command::new("tar")
            .arg("-xzf")
            .arg(&archive)
            .arg("-C")
            .arg(temporary.path()),
        "extract zsh-syntax-highlighting",
    )?;

    let extracted = temporary
        .path()
        .join(format!("zsh-syntax-highlighting-{REVISION}"));
    if !installed(&extracted) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "downloaded zsh-syntax-highlighting archive is incomplete",
        ));
    }
    remove_existing(target)?;
    fs::rename(extracted, target)
}

fn installed(root: &Path) -> bool {
    is_directory(root)
        && is_file(&root.join("zsh-syntax-highlighting.zsh"))
        && is_file(&root.join("highlighters/main/main-highlighter.zsh"))
        && is_file(&root.join("COPYING.md"))
        && has_contents(&root.join(".version"), VERSION)
        && has_contents(&root.join(".revision-hash"), REVISION)
}

fn is_directory(path: &Path) -> bool {
    path.symlink_metadata()
        .is_ok_and(|metadata| metadata.file_type().is_dir())
}

fn is_file(path: &Path) -> bool {
    path.symlink_metadata()
        .is_ok_and(|metadata| metadata.file_type().is_file())
}

fn has_contents(path: &Path, expected: &str) -> bool {
    fs::read_to_string(path).is_ok_and(|contents| contents.trim() == expected)
}

fn remove_existing(path: &Path) -> io::Result<()> {
    let Ok(metadata) = path.symlink_metadata() else {
        return Ok(());
    };
    if metadata.file_type().is_dir() {
        return fs::remove_dir_all(path);
    }
    fs::remove_file(path)
}