braze-sync 0.8.0

GitOps CLI for managing Braze configuration as code
Documentation
//! File I/O for the Custom Attribute registry.
//!
//! The registry is a single YAML file (default path:
//! `custom_attributes/registry.yaml`). Unlike other resources that live
//! in a directory-per-entity layout, Custom Attributes use a flat
//! single-file registry because there is no per-attribute file artefact
//! (no body, no template parts).

use crate::error::{Error, Result};
use crate::resource::CustomAttributeRegistry;
use std::path::Path;

use super::write_atomic;

/// Header prepended to every `registry.yaml` written by braze-sync.
/// Spells out why this registry is read-mostly so humans browsing the
/// file tree don't expect `apply` to create attributes.
const REGISTRY_HEADER: &str = "# Generated by braze-sync.\n\
     #\n\
     # This is a REGISTRY of Custom Attributes that exist in Braze.\n\
     # braze-sync cannot create new Custom Attributes — Braze creates them\n\
     # implicitly when /users/track receives data containing them.\n\
     # braze-sync can only mark them as deprecated.\n";

/// Load the Custom Attribute registry from `path`. Returns `Ok(None)`
/// when the file does not exist (valid for a fresh project that has
/// never exported).
pub fn load_registry(path: &Path) -> Result<Option<CustomAttributeRegistry>> {
    let bytes = match std::fs::read_to_string(path) {
        Ok(b) => b,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(e.into()),
    };
    let registry: CustomAttributeRegistry =
        serde_norway::from_str(&bytes).map_err(|source| Error::YamlParse {
            path: path.to_path_buf(),
            source,
        })?;
    Ok(Some(registry))
}

/// Save the Custom Attribute registry to `path`. Attributes are sorted
/// by name so the file is deterministic across runs.
pub fn save_registry(path: &Path, registry: &CustomAttributeRegistry) -> Result<()> {
    let normalized = registry.normalized();
    let yaml = serde_norway::to_string(&normalized).map_err(|e| Error::InvalidFormat {
        path: path.to_path_buf(),
        message: format!("yaml serialization failed: {e}"),
    })?;

    let mut out = String::with_capacity(REGISTRY_HEADER.len() + yaml.len());
    out.push_str(REGISTRY_HEADER);
    out.push_str(&yaml);

    write_atomic(path, out.as_bytes())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::resource::{CustomAttribute, CustomAttributeRegistry, CustomAttributeType};

    fn sample_registry() -> CustomAttributeRegistry {
        CustomAttributeRegistry {
            attributes: vec![
                CustomAttribute {
                    name: "preferred_clinic_id".into(),
                    attribute_type: CustomAttributeType::String,
                    description: Some("User's preferred clinic".into()),
                    deprecated: false,
                },
                CustomAttribute {
                    name: "last_visit_date".into(),
                    attribute_type: CustomAttributeType::Time,
                    description: Some("Most recent visit timestamp".into()),
                    deprecated: false,
                },
                CustomAttribute {
                    name: "legacy_segment".into(),
                    attribute_type: CustomAttributeType::String,
                    description: None,
                    deprecated: true,
                },
            ],
        }
    }

    #[test]
    fn save_load_roundtrip() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        let registry = sample_registry();

        save_registry(&path, &registry).unwrap();
        let loaded = load_registry(&path).unwrap().unwrap();

        // Loaded registry should be normalized (sorted by name).
        let normalized = registry.normalized();
        assert_eq!(loaded, normalized);
    }

    #[test]
    fn save_normalizes_order() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        let registry = sample_registry();

        save_registry(&path, &registry).unwrap();
        let content = std::fs::read_to_string(&path).unwrap();

        // "last_visit_date" should appear before "legacy_segment" and
        // "preferred_clinic_id" (alphabetical order).
        let pos_last = content.find("last_visit_date").unwrap();
        let pos_legacy = content.find("legacy_segment").unwrap();
        let pos_pref = content.find("preferred_clinic_id").unwrap();
        assert!(pos_last < pos_legacy);
        assert!(pos_legacy < pos_pref);
    }

    #[test]
    fn save_includes_header_comment() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        save_registry(&path, &sample_registry()).unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.starts_with("# Generated by braze-sync."));
        assert!(content.contains("REGISTRY"));
    }

    #[test]
    fn load_missing_file_returns_none() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("nonexistent.yaml");
        let result = load_registry(&path).unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn load_invalid_yaml_returns_error() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        std::fs::write(&path, "not: valid: yaml: [").unwrap();
        let err = load_registry(&path).unwrap_err();
        assert!(matches!(err, Error::YamlParse { .. }));
    }

    #[test]
    fn deprecated_false_is_omitted_in_output() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        let registry = CustomAttributeRegistry {
            attributes: vec![CustomAttribute {
                name: "active_attr".into(),
                attribute_type: CustomAttributeType::String,
                description: None,
                deprecated: false,
            }],
        };
        save_registry(&path, &registry).unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        // Header comment mentions "deprecated" in prose; match the YAML
        // key form to assert serde actually skipped the default.
        assert!(!content.contains("deprecated:"));
    }

    #[test]
    fn deprecated_true_is_preserved() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        let registry = CustomAttributeRegistry {
            attributes: vec![CustomAttribute {
                name: "old_attr".into(),
                attribute_type: CustomAttributeType::String,
                description: None,
                deprecated: true,
            }],
        };
        save_registry(&path, &registry).unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("deprecated: true"));
    }

    #[test]
    fn empty_registry_roundtrips() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        let registry = CustomAttributeRegistry { attributes: vec![] };
        save_registry(&path, &registry).unwrap();
        let loaded = load_registry(&path).unwrap().unwrap();
        assert!(loaded.attributes.is_empty());
    }
}