tw-cli 0.1.0

One command for a Markdoc corpus
//! `tw setup` and `tw update`.
//!
//! Setup is one command, safe to re-run, replacing a corpus's
//! `.tooling/scripts/setup.ts` and the two shell hooks beside it. The
//! TypeScript explained itself this way: *"TypeScript rather than a shell
//! script because most writers are on Windows, and `npm run setup` behaves
//! the same on all three platforms while a .sh does not."* That argument is
//! sound against a shell script and not against a native binary, which
//! behaves the same on all three and needs no Node installed first.

use crate::tools;
use std::io::Write;
use std::path::Path;
use std::process::Command;

/// The hook bodies. One line each, identical on all three platforms, which
/// retires the constraint `.githooks/pre-commit` works around in a comment --
/// *"POSIX sh, not bash, and no arrays or process substitution: most writers
/// are on Windows"* -- along with the 200-file branch inside it, because `tw`
/// can decide for itself when a list has stopped being a list.
///
/// Prose is deliberately absent from pre-commit. Measured on this corpus one
/// file through the prose engine costs 1.5 s against 0.15 s for the
/// structural rules, and a hook that takes a second is a hook people learn to
/// pass `--no-verify` to. It also matches what the corpus already does:
/// markdoc-prose is in CI and in neither hook today.
const PRE_COMMIT: &str = "#!/bin/sh\nexec tw check --no-prose --staged\n";
const PRE_PUSH: &str = "#!/bin/sh\nexec tw check --no-prose\n";

pub fn run(root: &Path, skip_tools: bool) -> Result<(), String> {
    let root = root
        .canonicalize()
        .map_err(|e| format!("{}: {e}", root.display()))?;

    heading("git hooks");
    hooks(&root)?;

    heading("pinned tools");
    if skip_tools {
        println!("    skipped (--no-tools)");
    } else {
        prefetch(&root);
    }

    heading("completions");
    println!("    tw completions <bash|zsh|fish|powershell|elvish>");
    println!("    see `tw completions --help` for where your shell wants it");

    println!(
        "\nReady. Day to day:\n\n  \
         tw check              structure and prose\n  \
         tw check --no-prose   structure only, what the hooks run\n  \
         tw fmt --write        format the tags\n  \
         tw preview            build and serve the site locally\n  \
         tw build pdfs         render every manual\n"
    );
    Ok(())
}

/// Git does not use a repository's hooks by default, and should not: a clone
/// cannot be allowed to run code on your machine because you fetched it. This
/// is the opt-in, and it is why setup has to be run rather than inferred.
fn hooks(root: &Path) -> Result<(), String> {
    let dir = root.join(".githooks");
    std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;

    for (name, body) in [("pre-commit", PRE_COMMIT), ("pre-push", PRE_PUSH)] {
        let path = dir.join(name);
        std::fs::write(&path, body).map_err(|e| format!("{}: {e}", path.display()))?;
        executable(&path)?;
        println!("    {name}: {}", body.lines().nth(1).unwrap_or("").trim());
    }

    let status = Command::new("git")
        .arg("-C")
        .arg(root)
        .args(["config", "core.hooksPath", ".githooks"])
        .status()
        .map_err(|e| format!("git: {e}"))?;
    if !status.success() {
        return Err("git config core.hooksPath failed".to_string());
    }
    println!("    core.hooksPath = .githooks");
    Ok(())
}

/// Download what `tw` launches -- its own and what `tw.toml` pins under
/// `[tools]` -- so the first `tw preview` is not also the first download.
/// Failures are reported and not fatal: a writer who only ever lints should
/// not be stopped by a PDF renderer they will never run.
fn prefetch(root: &Path) {
    let config = match crate::external::Config::load(&tools::corpus(root)) {
        Ok(config) => config,
        // Reported, and tw's own tools fetched regardless.
        Err(e) => {
            println!("    tw.toml: {e}");
            crate::external::Config::default()
        }
    };
    for tool in config.all_launched() {
        match tool.resolve(root) {
            Ok(path) => println!("    {}: {}", tool.stem, path.display()),
            Err(e) => println!("    {}: not available -- {e}", tool.stem),
        }
    }
    prefetch_extensions(root, &config);
}

/// The archives this corpus says carry `tw` commands.
///
/// Without this they are pinned and unreachable: `tw foo` resolves `tw-foo`
/// on `PATH`, and the archive's `bin/` under `.tooling/bin/` is on nobody's.
/// Fetching them here is what makes `tw xlate` work after `tw setup`
/// without anybody editing a shell profile -- see `extension.rs` on why
/// that directory is allowed to count and an arbitrary corpus directory is
/// not.
fn prefetch_extensions(root: &Path, config: &crate::external::Config) {
    if config.extensions.is_empty() {
        return;
    }
    // Reported, not fatal, like every other line of this function: a
    // writer who only ever lints should not be stopped by it.
    let tooling = match tools::Tooling::find(root) {
        Ok(tooling) => tooling,
        Err(e) => {
            println!("    extensions: {e}");
            return;
        }
    };

    let slug = match tools::platform_slug() {
        Ok(slug) => slug,
        Err(e) => {
            println!("    extensions: {e}");
            return;
        }
    };

    for name in &config.extensions {
        let fetched = tooling.pin(name).and_then(|pin| {
            tooling.release(name, &pin, &tools::extension_asset(name, &slug), true)
        });
        match fetched {
            Ok(dir) => {
                let bin = dir.join("bin");
                let commands = commands_in(&bin);
                if commands.is_empty() {
                    println!(
                        "    {name}: fetched, but {} holds no tw-* commands",
                        bin.display()
                    );
                } else {
                    println!("    {name}: {}", commands.join(", "));
                }
            }
            Err(e) => println!("    {name}: not available -- {e}"),
        }
    }
}

/// What a fetched archive actually provides, so `tw setup` reports the
/// commands a writer can now type rather than a path they cannot use.
fn commands_in(bin: &Path) -> Vec<String> {
    let mut names: Vec<String> = std::fs::read_dir(bin)
        .into_iter()
        .flatten()
        .flatten()
        .filter_map(|e| {
            let file = e.file_name().to_string_lossy().to_string();
            file.strip_prefix("tw-")
                .map(|rest| format!("tw {}", rest.strip_suffix(".exe").unwrap_or(rest)))
        })
        .collect();
    names.sort();
    names
}

#[cfg(unix)]
fn executable(path: &Path) -> Result<(), String> {
    use std::os::unix::fs::PermissionsExt;
    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
        .map_err(|e| format!("{}: {e}", path.display()))
}

#[cfg(not(unix))]
fn executable(_path: &Path) -> Result<(), String> {
    Ok(())
}

/// Replace this binary with the newest release, then re-pin what it launches.
///
/// Windows will not let a running `.exe` be deleted but will let it be
/// renamed, which is what `self_replace` does. The consequence is that `tw`
/// installs per user by default: a `tw` in `Program Files` cannot replace
/// itself without elevation, and a writer-facing tool that needs an
/// administrator to update is a tool that does not get updated.
pub fn update(root: &Path, repo: &str) -> Result<(), String> {
    let current = env!("CARGO_PKG_VERSION");
    let url = format!("https://api.github.com/repos/{repo}/releases/latest");

    let body: serde_json::Value = ureq::get(&url)
        .header("User-Agent", "tw")
        .call()
        .map_err(|e| match e {
            ureq::Error::StatusCode(404) => format!(
                "{repo} has published no releases yet, so there is nothing to \
                 update to. tw is at {current}."
            ),
            other => format!("{url}: {other}"),
        })?
        .body_mut()
        .read_to_string()
        .map_err(|e| format!("{url}: {e}"))
        .and_then(|text| serde_json::from_str(&text).map_err(|e| format!("{url}: {e}")))?;

    let latest = body
        .get("tag_name")
        .and_then(|t| t.as_str())
        .ok_or_else(|| format!("{url}: the release carries no tag_name"))?;

    if latest == current {
        println!("tw {current} is the newest release.");
    } else {
        let slug = tools::platform_slug()?;
        let asset = format!("tw-{slug}{}", if cfg!(windows) { ".exe" } else { "" });
        let assets = body.get("assets").and_then(|a| a.as_array());
        let url_of = |name: &str| -> Option<&str> {
            assets?
                .iter()
                .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(name))?
                .get("browser_download_url")?
                .as_str()
        };

        let download = url_of(&asset).ok_or_else(|| format!("release {latest} has no {asset}"))?;
        // Looked up before anything is downloaded: there is no point
        // fetching 20 MB that is going to replace this binary and only
        // then discovering there is nothing to check it against.
        let checksum_url = url_of(&format!("{asset}.sha256")).ok_or_else(|| {
            format!(
                "release {latest} publishes {asset} and no {asset}.sha256, so \
                 the download that would replace this binary cannot be \
                 verified. Fix the release rather than trust it."
            )
        })?;

        println!("tw {current} -> {latest}");
        let published = ureq::get(checksum_url)
            .header("User-Agent", "tw")
            .call()
            .map_err(|e| format!("{checksum_url}: {e}"))?
            .body_mut()
            .read_to_string()
            .map_err(|e| format!("{checksum_url}: {e}"))?;

        let bytes = ureq::get(download)
            .header("User-Agent", "tw")
            .call()
            .map_err(|e| format!("{download}: {e}"))?
            .body_mut()
            .with_config()
            .limit(128 * 1024 * 1024)
            .read_to_vec()
            .map_err(|e| format!("{download}: {e}"))?;

        // Before it is written anywhere, let alone swapped in. This is the
        // one download in `tw` that replaces the running executable, so it
        // is the one where an unverified byte matters most.
        tools::verify(&bytes, &published, &asset)?;

        // Staged beside the running binary rather than in a temp directory:
        // self_replace renames across the swap, and a rename cannot cross a
        // filesystem boundary.
        let staged = std::env::current_exe()
            .map_err(|e| format!("current_exe: {e}"))?
            .with_extension("new");
        let mut file =
            std::fs::File::create(&staged).map_err(|e| format!("{}: {e}", staged.display()))?;
        file.write_all(&bytes)
            .map_err(|e| format!("{}: {e}", staged.display()))?;
        drop(file);
        executable(&staged)?;

        self_replace::self_replace(&staged)
            .map_err(|e| format!("could not replace this binary: {e}"))?;
        let _ = std::fs::remove_file(&staged);
        println!("tw is now {latest}");
    }

    heading("pinned tools");
    prefetch(root);
    Ok(())
}

fn heading(text: &str) {
    println!("\n{text}");
}