omni-dev 0.46.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, Datadog, Gmail, and Drive.
Documentation
//! CLI command for `omni-dev drive permissions show`.

use std::io::Write;

use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};

use crate::cli::drive::format::{output_as, sanitize_for_terminal, OutputFormat};
use crate::cli::drive::helpers::active_account_rules;
use crate::cli::drive::permissions::check::OperationArg;
use crate::drive::write_gate::{DriveOperation, FolderPermissionRule, Verdict};

/// Prints the active account's configured write-permission rules.
#[derive(Parser)]
pub struct ShowCommand {
    /// Output format.
    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Table)]
    pub output: OutputFormat,
}

impl ShowCommand {
    /// Reads `write_permissions.rules` from `~/.omni-dev/settings.json`
    /// only — no network call, mirroring `drive account list`. Unlike that
    /// command, rules are per-account data (see
    /// `crate::drive::write_gate`'s module doc), so this resolves the
    /// active account first and shows nothing for an unconfigured account
    /// — there is no account whose `write_permissions` block could apply.
    pub fn execute(self) -> Result<()> {
        let rules = active_account_rules()?;
        run_show(&rules, &self.output)
    }
}

/// Emits `rules` in the requested format.
///
/// Split from [`ShowCommand::execute`] so tests can exercise rendering
/// directly against a constructed rule list, without touching `HOME`.
fn run_show(rules: &[FolderPermissionRule], output: &OutputFormat) -> Result<()> {
    if output_as(&rules.to_vec(), output)? {
        return Ok(());
    }
    let stdout = std::io::stdout();
    let mut handle = stdout.lock();
    render_rules_table(rules, &mut handle)
}

/// Renders rules as an aligned text table: `SCOPE | TARGET_ID | RECURSIVE
/// | LEASE | ALLOW | DENY`. An empty input prints a message explaining that
/// every write is refused everywhere until a rule is configured.
///
/// `LEASE` renders `require_lease` directly (`true`/`false`) — unlike
/// `RECURSIVE` it is meaningful for both a folder and a file rule, so it
/// needs no rule-kind-dependent `-` case (ADR-0080 §13). Surfacing it here
/// matters because it is the one field on this rule that silently relaxes a
/// *different* gate (the write lease, not this one) — an operator auditing
/// configured policy through this table would otherwise have no way to see
/// that a folder has opted out of the Touch ID/backup requirement without
/// switching to `--output json`.
///
/// `RECURSIVE` renders `-` for a file rule rather than `false`: a file has
/// no descendants, so `false` would imply the column means something there
/// (issue #1612).
fn render_rules_table(rules: &[FolderPermissionRule], out: &mut dyn Write) -> Result<()> {
    if rules.is_empty() {
        writeln!(
            out,
            "No write-permission rules configured for this account — every \
             {} is refused everywhere. Add rules under \
             drive.accounts.<name>.write_permissions.rules in \
             ~/.omni-dev/settings.json, keyed on either a folder_id or a file_id.",
            default_deny_operations_list()
        )
        .context("Failed to write empty-table message")?;
        return Ok(());
    }

    let scopes: Vec<&str> = rules.iter().map(rule_scope).collect();
    let scope_width = "SCOPE"
        .len()
        .max(scopes.iter().map(|s| s.len()).max().unwrap_or(0));
    let ids: Vec<String> = rules
        .iter()
        .map(|r| sanitize_for_terminal(rule_target_id(r)))
        .collect();
    let id_width = "TARGET_ID"
        .len()
        .max(ids.iter().map(String::len).max().unwrap_or(0));
    let recursives: Vec<String> = rules.iter().map(format_recursive).collect();
    let leases: Vec<String> = rules.iter().map(|r| r.require_lease.to_string()).collect();
    let allow_strings: Vec<String> = rules.iter().map(|r| format_op_set(&r.allow)).collect();
    let allow_width = "ALLOW"
        .len()
        .max(allow_strings.iter().map(String::len).max().unwrap_or(0));
    let deny_strings: Vec<String> = rules.iter().map(|r| format_op_set(&r.deny)).collect();
    let deny_width = "DENY"
        .len()
        .max(deny_strings.iter().map(String::len).max().unwrap_or(0));

    writeln!(
        out,
        "{:<scope_width$}  {:<id_width$}  RECURSIVE  LEASE  {:<allow_width$}  {:<deny_width$}",
        "SCOPE", "TARGET_ID", "ALLOW", "DENY"
    )
    .context("Failed to write header row")?;
    for i in 0..rules.len() {
        writeln!(
            out,
            "{:<scope_width$}  {:<id_width$}  {:<9}  {:<5}  {:<allow_width$}  {:<deny_width$}",
            scopes[i], ids[i], recursives[i], leases[i], allow_strings[i], deny_strings[i],
        )
        .context("Failed to write rule row")?;
    }
    Ok(())
}

/// `"create/upload/edit/.../docs-write"` — every operation the gate
/// refuses by default absent a configured rule, built from
/// [`OperationArg::value_variants`] (`permissions check`'s own `--operation`
/// value set) filtered by [`DriveOperation::default_policy`], rather than
/// a hardcoded string, so it cannot silently omit an operation the way
/// the empty-rules message once did (issue #1919). `value_variants` is
/// clap's own `ValueEnum` derive output — a real enumeration of
/// `OperationArg`'s variants, not a second hand-maintained list that could
/// itself drift from `DriveOperation`'s.
///
/// Panics (via the `debug_assert!`) only if `default_policy` ever stopped
/// denying anything by default — a real bug, not a reachable input, since
/// `default_policy`'s own exhaustive match keeps every write closed.
fn default_deny_operations_list() -> String {
    let list = OperationArg::value_variants()
        .iter()
        .map(|&arg| DriveOperation::from(arg))
        .filter(|op| op.default_policy() == Verdict::Deny)
        .map(|op| op.to_string())
        .collect::<Vec<_>>();
    debug_assert!(
        !list.is_empty(),
        "at least one DriveOperation must default-deny, or this message's wording breaks"
    );
    list.join("/")
}

/// `"folder"` or `"file"` — which id this rule keys on.
///
/// Deserialization guarantees exactly one is set, so the fallback is
/// unreachable for any rule that came from a settings file.
fn rule_scope(rule: &FolderPermissionRule) -> &'static str {
    if rule.file_id.is_some() {
        "file"
    } else {
        "folder"
    }
}

/// The id this rule keys on, whichever kind it is.
fn rule_target_id(rule: &FolderPermissionRule) -> &str {
    rule.file_id
        .as_deref()
        .or(rule.folder_id.as_deref())
        .unwrap_or("-")
}

/// `true`/`false` for a folder rule, `-` for a file rule.
fn format_recursive(rule: &FolderPermissionRule) -> String {
    if rule.file_id.is_some() {
        "-".to_string()
    } else {
        rule.recursive.to_string()
    }
}

/// Renders an operation set as a stable, comma-joined, alphabetically
/// sorted string (`create,edit`), or `-` when empty.
fn format_op_set(
    ops: &std::collections::HashSet<crate::drive::write_gate::DriveOperation>,
) -> String {
    if ops.is_empty() {
        return "-".to_string();
    }
    let mut rendered: Vec<String> = ops.iter().map(ToString::to_string).collect();
    rendered.sort();
    rendered.join(",")
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::drive::write_gate::DriveOperation;

    fn sample_rule() -> FolderPermissionRule {
        FolderPermissionRule {
            folder_id: Some("folder-1".to_string()),
            file_id: None,
            recursive: true,
            allow: [DriveOperation::Create, DriveOperation::Upload]
                .into_iter()
                .collect(),
            deny: std::iter::once(DriveOperation::Edit).collect(),
            require_lease: true,
        }
    }

    #[test]
    fn render_table_empty_explains_default_deny() {
        let mut buf = Vec::new();
        render_rules_table(&[], &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(out.contains("No write-permission rules configured"));
        assert!(out.contains("write_permissions"));
    }

    #[test]
    fn render_table_writes_header_and_rows() {
        let rules = [sample_rule()];
        let mut buf = Vec::new();
        render_rules_table(&rules, &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(out.contains("SCOPE"));
        assert!(out.contains("TARGET_ID"));
        assert!(out.contains("RECURSIVE"));
        assert!(out.contains("LEASE"));
        assert!(out.contains("ALLOW"));
        assert!(out.contains("DENY"));
        assert!(out.contains("folder-1"));
        assert!(out.contains("true"));
        assert!(out.contains("create,upload"));
        assert!(out.contains("edit"));
    }

    #[test]
    fn render_table_shows_require_lease_false_for_an_opted_out_rule() {
        let rules = [FolderPermissionRule::folder("folder-1")
            .allowing([DriveOperation::Edit])
            .requiring_lease(false)];
        let mut buf = Vec::new();
        render_rules_table(&rules, &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(out.contains("LEASE"), "{out}");
        assert!(out.contains("false"), "{out}");
    }

    #[test]
    fn render_table_uses_dash_for_empty_op_sets() {
        let rules = [FolderPermissionRule {
            folder_id: Some("folder-1".to_string()),
            file_id: None,
            recursive: false,
            allow: std::collections::HashSet::default(),
            deny: std::collections::HashSet::default(),
            require_lease: true,
        }];
        let mut buf = Vec::new();
        render_rules_table(&rules, &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(out.contains('-'));
    }

    #[test]
    fn render_table_strips_control_bytes_from_folder_id() {
        let rules = [FolderPermissionRule {
            folder_id: Some("fo\rlder\x1b[31m".to_string()),
            file_id: None,
            recursive: false,
            allow: std::collections::HashSet::default(),
            deny: std::collections::HashSet::default(),
            require_lease: true,
        }];
        let mut buf = Vec::new();
        render_rules_table(&rules, &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(
            !out.contains(|c: char| c.is_control() && c != '\n'),
            "{out:?}"
        );
    }

    #[test]
    fn format_op_set_sorts_alphabetically_and_dedupes_via_hashset() {
        let ops: std::collections::HashSet<_> = [DriveOperation::Upload, DriveOperation::Create]
            .into_iter()
            .collect();
        assert_eq!(format_op_set(&ops), "create,upload");
    }

    #[test]
    fn run_show_table_path_writes_to_stdout() {
        run_show(&[sample_rule()], &OutputFormat::Table).unwrap();
    }

    #[test]
    fn run_show_json_path_returns_ok() {
        run_show(&[sample_rule()], &OutputFormat::Json).unwrap();
    }

    // ── file-id rules (issue #1612) ────────────────────────────────────

    fn sample_file_rule() -> FolderPermissionRule {
        FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsWrite])
    }

    #[test]
    fn render_table_labels_each_rule_with_its_scope() {
        let rules = [sample_rule(), sample_file_rule()];
        let mut buf = Vec::new();
        render_rules_table(&rules, &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(out.contains("SCOPE"), "{out}");
        assert!(out.contains("TARGET_ID"), "{out}");
        assert!(out.contains("folder"), "{out}");
        assert!(out.contains("file"), "{out}");
        assert!(out.contains("sheet-1"), "{out}");
    }

    #[test]
    fn render_table_shows_a_dash_not_false_for_a_file_rules_recursive() {
        // `false` would imply the column means something for a file rule.
        let rules = [sample_file_rule()];
        let mut buf = Vec::new();
        render_rules_table(&rules, &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(!out.contains("false"), "{out}");
    }

    #[test]
    fn render_table_strips_control_bytes_from_a_file_id_too() {
        let rules = [FolderPermissionRule::file("sh\reet\x1b[31m")];
        let mut buf = Vec::new();
        render_rules_table(&rules, &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(
            !out.contains(|c: char| c.is_control() && c != '\n'),
            "{out:?}"
        );
    }

    #[test]
    fn the_empty_table_message_names_every_gated_operation() {
        // issue #1919: the message previously hardcoded a five-operation
        // string that silently omitted sheets-delete, sheets-protection
        // and docs-write despite all three being gated the same way.
        let mut buf = Vec::new();
        render_rules_table(&[], &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        let list = default_deny_operations_list();
        assert!(
            out.contains(&list),
            "{out} should embed the operations list {list} verbatim"
        );
        let named: std::collections::HashSet<&str> = list.split('/').collect();
        assert!(
            !named.contains("read"),
            "{out} should not name read, which is allowed by default"
        );
        for op in [
            "create",
            "upload",
            "edit",
            "sheets-write",
            "sheets-structure",
            "sheets-delete",
            "sheets-protection",
            "docs-write",
            "trash",
            "slides-write",
        ] {
            assert!(named.contains(op), "{out} should name {op}");
        }
    }

    #[test]
    fn default_deny_operations_list_matches_default_policy() {
        let list = default_deny_operations_list();
        let named: std::collections::HashSet<&str> = list.split('/').collect();
        for &arg in OperationArg::value_variants() {
            let op = DriveOperation::from(arg);
            assert_eq!(
                named.contains(op.to_string().as_str()),
                op.default_policy() == Verdict::Deny,
                "{op:?}: list={list}"
            );
        }
    }

    #[test]
    fn the_empty_table_message_names_both_rule_keys() {
        let mut buf = Vec::new();
        render_rules_table(&[], &mut buf).unwrap();
        let out = String::from_utf8(buf).unwrap();
        assert!(out.contains("folder_id"), "{out}");
        assert!(out.contains("file_id"), "{out}");
    }
}