offline-cli 0.3.3

The Offline Protocol developer CLI
//! `offline apply`: converge the application to the `app` block of
//! `offline.config.json` (native apps, allowed origins, features). It only
//! adds and turns on/off what the file says; it never removes a registration,
//! so running it again changes nothing.

use std::path::PathBuf;

use anyhow::{Context, Result};
use clap::Args;
use serde_json::{json, Value};

use crate::commands::apps::{app_context, print_detected, print_status, project_config_app_id};
use crate::output;
use crate::setup::{self, detect};

#[derive(Args)]
pub struct ApplyArgs {
    /// The project config (default: offline.config.json in --dir)
    #[arg(long)]
    pub file: Option<PathBuf>,
    /// Project directory
    #[arg(long, default_value = ".")]
    pub dir: PathBuf,
    /// Also register the native apps found in the project (app.json, Xcode, Gradle, assetlinks.json)
    #[arg(long)]
    pub detect: bool,
    /// With --detect, also register the Android debug keystore's certificate (for debug builds)
    #[arg(long)]
    pub debug_cert: bool,
    /// Application id (default: `appId` in the config file, then ~/.offline/config.json)
    #[arg(long)]
    pub app_id: Option<String>,
    /// Show what would change without changing anything
    #[arg(long)]
    pub dry_run: bool,
    #[arg(long)]
    pub json: bool,
}

pub async fn run(args: ApplyArgs) -> Result<()> {
    let file = args
        .file
        .clone()
        .unwrap_or_else(|| args.dir.join("offline.config.json"));
    let config: Value = if file.exists() {
        let text = std::fs::read_to_string(&file)
            .with_context(|| format!("reading {}", file.display()))?;
        serde_json::from_str(&text)
            .with_context(|| format!("{} is not valid JSON", file.display()))?
    } else if args.detect {
        json!({})
    } else {
        anyhow::bail!(
            "{} not found: run `offline init` to create it, add an \"app\" block, or pass --detect",
            file.display()
        );
    };
    let mut desired = setup::desired_from_config(&config);
    let detected = args
        .detect
        .then(|| detect::detect(&args.dir, &detect::DetectOptions::default()));
    if let Some(found) = &detected {
        setup::merge_detected(&mut desired, found, args.debug_cert);
    }
    let app_flag = args
        .app_id
        .clone()
        .or_else(|| {
            config
                .get("appId")
                .and_then(Value::as_str)
                .map(str::to_string)
        })
        .or_else(|| project_config_app_id(&args.dir));
    let ctx = app_context(app_flag.as_deref(), "apply").await?;
    let changes = setup::apply(&ctx, &desired, args.dry_run).await?;
    let report = ctx.status(detected.as_ref()).await?;

    if args.json {
        return output::json(&json!({
            "appId": ctx.app_id,
            "dryRun": args.dry_run,
            "changes": changes,
            "detected": detected,
            "status": report,
        }));
    }
    if let Some(found) = &detected {
        print_detected(found);
        println!();
    }
    if changes.is_empty() {
        output::success(&format!(
            "{} already matches {}",
            ctx.app_id,
            file.display()
        ));
    } else {
        let verb = if args.dry_run {
            "would change"
        } else {
            "changed"
        };
        output::heading(&format!("{verb} {}", ctx.app_id));
        for c in &changes {
            println!("  {c}");
        }
    }
    println!();
    print_status(&report);
    Ok(())
}