tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! `tsafe ns` — list namespaces; copy or move all keys under a prefix.

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_core::{
    audit::AuditEntry,
    errors::SafeError,
    namespace_bulk::{apply_namespace_copy, apply_namespace_move, plan_namespace_bulk},
};

use tsafe_cli::cli::NsAction;

use crate::helpers::{audit, open_vault};

pub(crate) fn cmd_ns(profile: &str, action: NsAction) -> Result<()> {
    match action {
        NsAction::List => {
            let vault = open_vault(profile)?;
            let mut namespaces: std::collections::BTreeSet<String> =
                std::collections::BTreeSet::new();
            for key in vault.list() {
                if let Some((ns, _)) = key.split_once('/') {
                    namespaces.insert(ns.to_string());
                }
            }
            if namespaces.is_empty() {
                println!(
                    "{} No namespaces in profile '{profile}' — all keys are in the root namespace",
                    "i".blue()
                );
            } else {
                for ns in &namespaces {
                    let count = vault
                        .list()
                        .iter()
                        .filter(|k| k.starts_with(&format!("{ns}/")))
                        .count();
                    println!("{ns}  ({count} key{})", if count == 1 { "" } else { "s" });
                }
            }
            Ok(())
        }
        NsAction::Copy { from, to, force } => ns_copy_or_move(profile, &from, &to, force, false),
        NsAction::Move { from, to, force } => ns_copy_or_move(profile, &from, &to, force, true),
    }
}

fn ns_copy_or_move(profile: &str, from: &str, to: &str, force: bool, is_move: bool) -> Result<()> {
    let mut vault = open_vault(profile)?;
    let pairs = plan_namespace_bulk(&vault, from, to, force).map_err(|e| anyhow::anyhow!("{e}"))?;
    if pairs.is_empty() {
        println!(
            "{} No keys under namespace '{}' in profile '{}'",
            "i".blue(),
            from,
            profile
        );
        return Ok(());
    }

    if is_move {
        apply_namespace_move(&mut vault, &pairs, force).map_err(|e| match &e {
            SafeError::SecretNotFound { .. } => {
                anyhow::anyhow!("secret not found (vault changed?)")
            }
            SafeError::SecretAlreadyExists { .. } => {
                anyhow::anyhow!("destination already exists — use --force to overwrite")
            }
            _ => anyhow::anyhow!("{e}"),
        })?;
        let op = "ns-move";
        let detail = format!("{from}->{to} ({} keys)", pairs.len());
        let _ = audit(profile).append(&AuditEntry::success(profile, op, Some(&detail)));
        println!(
            "{} Moved {} key(s) from '{}/' → '{}/'",
            "".green(),
            pairs.len(),
            from,
            to
        );
    } else {
        apply_namespace_copy(&mut vault, &pairs).with_context(|| "namespace copy")?;
        let op = "ns-copy";
        let detail = format!("{from}->{to} ({} keys)", pairs.len());
        let _ = audit(profile).append(&AuditEntry::success(profile, op, Some(&detail)));
        println!(
            "{} Copied {} key(s) from '{}/' → '{}/' (sources unchanged)",
            "".green(),
            pairs.len(),
            from,
            to
        );
    }

    Ok(())
}