tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Proof tests for the 1Password field-label → vault-key mapping contract.
//!
//! These tests verify the normalisation rule directly via the always-compiled
//! `tsafe_cli::op_mapping::op_field_label_to_key` function and the collision
//! detection logic embedded in `cmd_op_pull`.  They do not require the `op`
//! binary or the `cloud-pull-1password` feature flag.
//!
//! Contract (from docs/decisions/1password-mapping-contract.md):
//!   field label → KEY_NAME
//!   - spaces and hyphens become underscores
//!   - result is uppercased
//!   - no item-name prefix is prepended
//!   - two fields normalising to the same key in one item → non-zero exit
//!     (unless --overwrite is passed)

use tsafe_cli::op_mapping::op_field_label_to_key;

// ── Normalisation rules ────────────────────────────────────────────────────────

#[test]
fn spaces_become_underscores_and_label_is_uppercased() {
    assert_eq!(op_field_label_to_key("My Secret"), "MY_SECRET");
}

#[test]
fn hyphens_become_underscores_and_label_is_uppercased() {
    assert_eq!(op_field_label_to_key("db-password"), "DB_PASSWORD");
}

#[test]
fn already_screaming_snake_is_unchanged() {
    assert_eq!(op_field_label_to_key("API_KEY"), "API_KEY");
}

#[test]
fn mixed_spaces_and_hyphens_both_become_underscores() {
    assert_eq!(op_field_label_to_key("my-API key"), "MY_API_KEY");
}

#[test]
fn no_item_name_prefix_is_added() {
    // The old (incorrect) behaviour would have produced "DATABASE_CREDENTIALS_PASSWORD".
    // The correct behaviour is "PASSWORD" — label only, no item-name prefix.
    assert_eq!(op_field_label_to_key("password"), "PASSWORD");
    assert_eq!(op_field_label_to_key("username"), "USERNAME");
}

// ── Collision detection ───────────────────────────────────────────────────────
//
// Collision detection runs before the vault is opened, so we can verify it
// purely by constructing the candidate list the same way cmd_op_pull does.

fn candidates_collide(labels: &[&str]) -> Option<Vec<String>> {
    use std::collections::HashMap;
    let candidates: Vec<String> = labels.iter().map(|l| op_field_label_to_key(l)).collect();
    let mut seen: HashMap<&str, usize> = HashMap::new();
    for key in &candidates {
        *seen.entry(key.as_str()).or_insert(0) += 1;
    }
    let mut colliding: Vec<String> = seen
        .iter()
        .filter(|(_, &count)| count > 1)
        .map(|(key, _)| key.to_string())
        .collect();
    if colliding.is_empty() {
        None
    } else {
        colliding.sort_unstable();
        Some(colliding)
    }
}

#[test]
fn two_labels_with_same_normalised_form_are_detected_as_collision() {
    // "API Key" and "api_key" both normalise to "API_KEY".
    let result = candidates_collide(&["API Key", "api_key"]);
    assert!(
        result.is_some(),
        "expected collision detection for 'API Key' and 'api_key'"
    );
    assert_eq!(result.unwrap(), vec!["API_KEY"]);
}

#[test]
fn distinct_normalised_forms_produce_no_collision() {
    let result = candidates_collide(&["username", "password", "API Key"]);
    assert!(
        result.is_none(),
        "expected no collision for distinct labels, got {result:?}"
    );
}

#[test]
fn three_fields_with_same_normalised_form_are_detected() {
    let result = candidates_collide(&["My Secret", "my-secret", "MY_SECRET"]);
    assert!(
        result.is_some(),
        "expected collision detection for three equivalent labels"
    );
    assert_eq!(result.unwrap(), vec!["MY_SECRET"]);
}