tftio-org-gdocs 0.1.3

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
#![cfg_attr(
    not(test),
    deny(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)
)]
//! `org-gdocs` binary entrypoint.
//!
//! The imperative shell: read the process-edge environment, route shared metadata
//! commands through `cli-common`, and dispatch domain commands. Domain logic lives
//! in the library; this file stays thin.

use std::path::Path;

use org_gdocs::cli::{Cli, Command};
use org_gdocs::config::{Config, EnvInputs, Paths};
use org_gdocs::doctor::OrgGdocsDoctor;
use org_gdocs::google::client::GoogleClient;
use org_gdocs::{Error, Result, auth, clean, envelope, open, pull, push, sexp::Sexp};
use tftio_cli_common::{
    AgentModeContext, FatalCliError, JsonOutput, ProcessEnv, StandardCommand, map_standard_command,
    run_cli_from,
};

fn main() {
    let env = process_env();
    std::process::exit(run_cli_from::<Cli, _, OrgGdocsDoctor, _, _>(
        &org_gdocs::TOOL_SPEC,
        &env,
        std::env::args_os(),
        &OrgGdocsDoctor,
        metadata_command,
        run,
    ));
}

/// Read process-edge environment values once at the binary edge.
#[allow(
    clippy::disallowed_methods,
    reason = "agent token / HOME read once at the process edge (REPO_INVARIANTS.md #5)"
)]
fn process_env() -> ProcessEnv {
    ProcessEnv {
        agent: AgentModeContext::from_tokens(
            std::env::var(tftio_cli_common::AGENT_TOKEN_ENV).ok(),
            std::env::var(tftio_cli_common::AGENT_TOKEN_EXPECTED_ENV).ok(),
        ),
        home: std::env::var_os("HOME").map(std::path::PathBuf::from),
    }
}

fn metadata_command(cli: &Cli) -> Option<StandardCommand> {
    match &cli.command {
        Command::Meta { command } => Some(map_standard_command(
            command,
            JsonOutput::from_flag(cli.json),
        )),
        _ => None,
    }
}

#[allow(
    clippy::unnecessary_wraps,
    reason = "signature is fixed by cli-common's command runner (FnOnce(Cli) -> Result<i32, FatalCliError>)"
)]
fn run(cli: Cli) -> std::result::Result<i32, FatalCliError> {
    // Domain commands print the A5 s-expression envelope; the `--json` flag only
    // affects the shared metadata commands (routed by `metadata_command`).
    let exit = match cli.command {
        Command::Meta { .. } => unreachable!("meta commands are routed by metadata_command"),
        Command::Auth => emit("auth", run_auth()),
        Command::Push { file } => emit("push", run_push(&file)),
        Command::Pull { file } => emit("pull", run_pull(&file)),
        Command::Clean { file } => emit("clean", run_clean(&file)),
        Command::Open { file } => emit("open", run_open(&file)),
    };
    Ok(exit)
}

/// Print a domain result as the A5 sexp envelope and return the exit code.
fn emit(command: &str, result: Result<Sexp>) -> i32 {
    match result {
        Ok(value) => {
            println!("{}", value.render());
            0
        }
        Err(err) => {
            println!("{}", envelope::error(command, &err.to_string()).render());
            1
        }
    }
}

/// Run the interactive OAuth loopback flow; return an `(ok auth …)` envelope.
fn run_auth() -> Result<Sexp> {
    let config = load_config()?;
    block_on(async {
        let authenticator =
            auth::authenticator(&config.credentials_path, &config.token_path).await?;
        auth::obtain_token(&authenticator).await
    })?;
    Ok(envelope::ok(
        "auth",
        vec![(
            "token-path",
            Sexp::string(config.token_path.display().to_string()),
        )],
    ))
}

/// Read the org file, push its projection, write the result back, and return the
/// push envelope.
fn run_push(file: &Path) -> Result<Sexp> {
    let config = load_config()?;
    let content = read_file(file)?;
    let title = file
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("Untitled")
        .to_owned();
    let now = chrono::Utc::now().to_rfc3339();

    let outcome = block_on(async {
        let authenticator =
            auth::authenticator(&config.credentials_path, &config.token_path).await?;
        let client = GoogleClient::new(authenticator)?;
        push::push(&client, &content, &title, &now).await
    })?;

    write_file(file, &outcome.new_content)?;
    Ok(push::envelope(&outcome))
}

/// Read the org file, pull its linked doc's comments, merge new ones, write the
/// result back, and return the pull envelope.
fn run_pull(file: &Path) -> Result<Sexp> {
    let config = load_config()?;
    let content = read_file(file)?;
    let now = chrono::Utc::now().to_rfc3339();

    let outcome = block_on(async {
        let authenticator =
            auth::authenticator(&config.credentials_path, &config.token_path).await?;
        let client = GoogleClient::new(authenticator)?;
        pull::pull(&client, &content, &now).await
    })?;

    write_file(file, &outcome.new_content)?;
    Ok(pull::envelope(&outcome))
}

/// Archive the file's `DONE` comments (offline), write the result back, and return
/// the clean envelope.
fn run_clean(file: &Path) -> Result<Sexp> {
    let content = read_file(file)?;
    let outcome = clean::clean(&content)?;
    write_file(file, &outcome.new_content)?;
    Ok(clean::envelope(&outcome))
}

/// Resolve the file's linked-doc URL (offline, read-only) and return the open
/// envelope.
fn run_open(file: &Path) -> Result<Sexp> {
    let content = read_file(file)?;
    Ok(open::envelope(&open::open(&content)?))
}

/// Resolve paths from the environment edge and load the config.
fn load_config() -> Result<Config> {
    let paths = Paths::resolve(&EnvInputs::from_env())?;
    Config::load(&paths)
}

fn read_file(path: &Path) -> Result<String> {
    std::fs::read_to_string(path).map_err(|source| Error::Io {
        path: path.to_path_buf(),
        source,
    })
}

fn write_file(path: &Path, content: &str) -> Result<()> {
    std::fs::write(path, content).map_err(|source| Error::Io {
        path: path.to_path_buf(),
        source,
    })
}

/// Drive an async effect to completion on a current-thread runtime (binary edge).
fn block_on<F: std::future::Future<Output = Result<T>>, T>(future: F) -> Result<T> {
    let runtime = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .map_err(|err| Error::Auth(format!("build async runtime: {err}")))?;
    runtime.block_on(future)
}