omni-dev 0.46.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, Datadog, Gmail, and Drive.
Documentation
//! Shared helpers for Drive CLI commands.

use anyhow::Result;
use clap::Parser;

use crate::cli::drive::format::OutputFormat;
use crate::drive::account::ResolvedAccount;
use crate::drive::auth;
use crate::drive::client::DriveClient;
use crate::drive::write_gate::FolderPermissionRule;
use crate::utils::settings::{DriveAccountSettings, Settings};

/// The `--lease` flag, flattened into every Drive write command that needs
/// a lease token (ADR-0080 §1/§9/§13) — previously each of the seven
/// reimplemented the identical arg and doc comment by hand.
#[derive(Parser)]
pub struct LeaseTokenArg {
    /// The lease token from `drive lease acquire`, required unless the
    /// deciding write-permission rule sets `require_lease: false` — a
    /// token presented anyway is still validated and consumed
    /// ([ADR-0080](../../../docs/adrs/adr-0080.md) §1/§9/§13). Never
    /// needed with `--dry-run`.
    #[arg(long, value_name = "TOKEN")]
    pub lease: Option<String>,
}

/// The `--dry-run`/`--lease`/`-o` trio most Drive write commands accept,
/// flattened as the struct's **last** field — clap flattens in declaration
/// order, so this keeps every command's help text in the order it printed
/// before. Originally local to `sheets/structure.rs` (#1854/PR #1987);
/// moved here (#1990) so the other `sheets/*.rs` modules that repeat the
/// same trio can share it too. A handful of write commands whose
/// `--dry-run` doc text is genuinely command-specific (e.g.
/// `format.rs`'s `MergeCellsCommand`) keep their own hand-written trio
/// rather than flattening this, since doing so would silently replace
/// that text with the generic wording below.
#[derive(Parser)]
pub struct StructureWriteArgs {
    /// Reports the gate verdict and the change that would be made, without
    /// calling `spreadsheets.batchUpdate`.
    #[arg(long)]
    pub dry_run: bool,

    #[command(flatten)]
    pub lease: LeaseTokenArg,

    /// Output format.
    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
    pub output: OutputFormat,
}

/// Creates an authenticated Drive API client from environment/settings-resolved credentials.
pub fn create_client() -> Result<DriveClient> {
    create_client_for(None)
}

/// [`create_client`], but honoring the named-account resolution
/// ([ADR-0069](../../../docs/adrs/adr-0069.md)). `account` is `Some(name)`
/// to force that account (the CLI's resolved `--account` value, or an MCP
/// tool's per-call override) or `None` to fall through to ambient
/// `--account`/`OMNI_DEV_DRIVE_ACCOUNT` resolution — [`create_client`]'s
/// exact behavior.
pub fn create_client_for(account: Option<&str>) -> Result<DriveClient> {
    create_client_from(auth::load_credentials_for(account)?)
}

/// Builds a client from already-resolved credentials.
///
/// The dependency-injection seam: commands resolve credentials via
/// [`create_client`] in production, while tests construct a
/// [`DriveCredentials`](auth::DriveCredentials) value (or a wiremock
/// client) directly and never touch the environment.
pub fn create_client_from(credentials: auth::DriveCredentials) -> Result<DriveClient> {
    DriveClient::from_credentials(&credentials)
}

/// Reads one field off the active account's settings — `Some` for a
/// [`ResolvedAccount::Named`] account that has one configured, `None` for
/// an [`ResolvedAccount::Unconfigured`] account (nothing configured at all)
/// or a named one with no value set for `f` to read. The load/resolve/match
/// dance every "active account's X" helper needs, factored out once so it
/// cannot drift between them the way copied-by-hand code eventually does.
fn account_field<T>(
    account: Option<&str>,
    f: impl FnOnce(&DriveAccountSettings) -> Option<T>,
) -> Result<Option<T>> {
    let settings = Settings::load_or_warn_default();
    let resolved = auth::resolve(&settings.drive, account)?;
    Ok(match &resolved {
        ResolvedAccount::Named(name) => settings.drive.accounts.get(name).and_then(f),
        ResolvedAccount::Unconfigured => None,
    })
}

/// Reads the active account's `write_permissions.rules` from
/// `~/.omni-dev/settings.json` (issue #1574). An
/// [`ResolvedAccount::Unconfigured`] account has no `write_permissions`
/// block to read, so it resolves to an empty rule set — every write is
/// refused, per the gate's default policy.
///
/// Shared by `drive create`/`upload`/`edit`/`permissions show`/
/// `permissions check` — previously each of the five reimplemented this
/// identically.
pub fn active_account_rules() -> Result<Vec<FolderPermissionRule>> {
    account_rules(None)
}

/// Reads the active account's `lease_backup_folder_id`
/// ([ADR-0080](../../../docs/adrs/adr-0080.md) §3/§13) — the destination
/// for a native-document lease's Drive-side backup copy. `None` for an
/// [`ResolvedAccount::Unconfigured`] account, or one with no folder id set,
/// either of which means `drive lease acquire` refuses every native-
/// document target for this account.
pub fn active_account_lease_backup_folder_id() -> Result<Option<String>> {
    account_lease_backup_folder_id(None)
}

/// Reads write rules for the same explicit account used to load credentials.
pub(crate) fn account_rules(account: Option<&str>) -> Result<Vec<FolderPermissionRule>> {
    Ok(account_field(account, |a| Some(a.write_permissions.rules.clone()))?.unwrap_or_default())
}

/// Reads the native backup folder for the selected account.
pub(crate) fn account_lease_backup_folder_id(account: Option<&str>) -> Result<Option<String>> {
    account_field(account, |a| a.lease_backup_folder_id.clone())
}

/// Resolves the lease ledger path for a leased Drive write command.
///
/// A dry run never checks a lease (every leased command's `*_inner`
/// returns its preview outcome before the ledger is ever touched,
/// mirroring `drive edit`'s own `--dry-run` reasoning) — resolving a real
/// path here would make a purely read-only preview depend on the state
/// directory existing at all.
///
/// Shared by `drive edit`/`sheets write`/`sheets format`/`sheets
/// protection`/`sheets structure`/`sheets validation`/`docs write` —
/// previously each of the seven reimplemented this identically.
pub fn resolve_ledger_path(dry_run: bool) -> Result<std::path::PathBuf> {
    if dry_run {
        Ok(std::path::PathBuf::new())
    } else {
        crate::drive::lease::ledger::ledger_path()
    }
}

/// Renders a `list-*` verb's table rows, one per line with control
/// characters stripped, or `empty` (e.g. `No bandings.`) when there are
/// none — so an empty table says so rather than printing nothing, matching
/// `search-developer-metadata`.
pub fn render_list(rows: &[String], empty: &str) -> String {
    if rows.is_empty() {
        return format!("{empty}\n");
    }
    let mut out = String::new();
    for row in rows {
        out.push_str(&crate::cli::drive::format::sanitize_for_terminal(row));
        out.push('\n');
    }
    out
}

/// Prints [`render_list`]'s output.
pub fn print_list(rows: &[String], empty: &str) {
    print!("{}", render_list(rows, empty));
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
    use crate::utils::secret::Secret;

    #[test]
    fn render_list_says_so_when_there_are_no_rows() {
        assert_eq!(render_list(&[], "No bandings."), "No bandings.\n");
    }

    #[test]
    fn render_list_prints_each_row_sanitised() {
        let rows = vec!["id 1".to_string(), "id \u{1b}[31m2".to_string()];
        assert_eq!(render_list(&rows, "No bandings."), "id 1\nid [31m2\n");
    }

    #[test]
    fn create_client_from_uses_drive_api_host() {
        let creds = DriveCredentials {
            client_id: "client".to_string(),
            client_secret: Secret::new("secret"),
            refresh_token: Secret::new("refresh"),
            scope: DriveGrantedScopes::READONLY,
        };
        let client = create_client_from(creds).unwrap();
        assert_eq!(client.base_url(), "https://www.googleapis.com");
    }

    #[test]
    fn create_client_for_named_account_uses_that_accounts_credentials() {
        let guard = crate::drive::test_support::EnvGuard::take();
        let dir = guard.clear_credentials();
        let settings_path = dir.path().join(".omni-dev").join("settings.json");
        crate::utils::settings::Settings::upsert_drive_account(
            &settings_path,
            "work",
            &[
                (
                    "client_id",
                    serde_json::Value::String("work-id".to_string()),
                ),
                (
                    "client_secret",
                    serde_json::Value::String("work-secret".to_string()),
                ),
                (
                    "refresh_token",
                    serde_json::Value::String("work-refresh".to_string()),
                ),
            ],
        )
        .unwrap();

        let client = create_client_for(Some("work")).unwrap();
        assert_eq!(client.base_url(), "https://www.googleapis.com");
    }

    #[test]
    fn create_client_for_unknown_account_errors() {
        let guard = crate::drive::test_support::EnvGuard::take();
        let _dir = guard.clear_credentials();

        let err = create_client_for(Some("bogus")).unwrap_err();
        assert!(err.to_string().contains("unknown Drive account 'bogus'"));
    }

    // ── active_account_rules ────────────────────────────────────────────

    #[test]
    fn active_account_rules_unconfigured_account_is_empty() {
        let guard = crate::drive::test_support::EnvGuard::take();
        let _dir = guard.clear_credentials();

        assert!(active_account_rules().unwrap().is_empty());
    }

    #[test]
    fn active_account_rules_reads_the_sole_configured_accounts_rules() {
        let guard = crate::drive::test_support::EnvGuard::take();
        let dir = guard.clear_credentials();
        let settings_path = dir.path().join(".omni-dev").join("settings.json");
        crate::utils::settings::Settings::upsert_drive_account(
            &settings_path,
            "work",
            &[(
                "write_permissions",
                serde_json::json!({
                    "rules": [{
                        "folder_id": "folder-1",
                        "recursive": true,
                        "allow": ["create"],
                    }],
                }),
            )],
        )
        .unwrap();

        let rules = active_account_rules().unwrap();
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].folder_id.as_deref(), Some("folder-1"));
        assert!(rules[0]
            .allow
            .contains(&crate::drive::write_gate::DriveOperation::Create));
    }

    #[test]
    fn active_account_rules_warns_when_settings_json_fails_to_parse() {
        // `active_account_field` re-parses settings.json independently of
        // `LeaseFlags::resolve` (issue #1695's fix), so a broken file must
        // warn here too rather than silently reading back an empty rule
        // set — closing the gap named in issue #1744.
        let guard = crate::drive::test_support::EnvGuard::take();
        let dir = guard.clear_credentials();
        let settings_dir = dir.path().join(".omni-dev");
        std::fs::create_dir_all(&settings_dir).unwrap();
        std::fs::write(settings_dir.join("settings.json"), "{not valid json").unwrap();

        let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
            assert!(active_account_rules().unwrap().is_empty());
        });
        assert!(logs.contains("settings.json"), "{logs}");
    }

    // ── active_account_lease_backup_folder_id ───────────────────────────

    #[test]
    fn active_account_lease_backup_folder_id_is_none_for_an_unconfigured_account() {
        let guard = crate::drive::test_support::EnvGuard::take();
        let _dir = guard.clear_credentials();

        assert_eq!(active_account_lease_backup_folder_id().unwrap(), None);
    }

    #[test]
    fn active_account_lease_backup_folder_id_is_none_when_the_account_sets_none() {
        let guard = crate::drive::test_support::EnvGuard::take();
        let dir = guard.clear_credentials();
        let settings_path = dir.path().join(".omni-dev").join("settings.json");
        crate::utils::settings::Settings::upsert_drive_account(
            &settings_path,
            "work",
            &[(
                "client_id",
                serde_json::Value::String("work-id".to_string()),
            )],
        )
        .unwrap();

        assert_eq!(active_account_lease_backup_folder_id().unwrap(), None);
    }

    #[test]
    fn active_account_lease_backup_folder_id_reads_the_sole_configured_accounts_folder() {
        let guard = crate::drive::test_support::EnvGuard::take();
        let dir = guard.clear_credentials();
        let settings_path = dir.path().join(".omni-dev").join("settings.json");
        crate::utils::settings::Settings::upsert_drive_account(
            &settings_path,
            "work",
            &[(
                "lease_backup_folder_id",
                serde_json::Value::String("backup-folder-1".to_string()),
            )],
        )
        .unwrap();

        assert_eq!(
            active_account_lease_backup_folder_id().unwrap().as_deref(),
            Some("backup-folder-1")
        );
    }

    // ── resolve_ledger_path ──────────────────────────────────────────────

    #[test]
    fn resolve_ledger_path_for_a_real_run_resolves_the_ledger_path() {
        // A dry run short-circuits to an empty path; a real run delegates
        // to the shared ledger path resolver.
        let path = resolve_ledger_path(false).unwrap();
        assert!(path.ends_with("lease-ledger.jsonl"), "{}", path.display());
        assert_eq!(
            resolve_ledger_path(true).unwrap(),
            std::path::PathBuf::new()
        );
    }
}