braze-sync 0.13.0

GitOps CLI for managing Braze configuration as code
Documentation
//! File I/O for the Tag registry.
//!
//! Single-file YAML at `tags/registry.yaml`, mirroring the
//! `custom_attributes/registry.yaml` layout. Tags have no per-entity
//! file artefact — they are pure metadata referenced by other resources.

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

use super::write_atomic;

const REGISTRY_HEADER: &str = "# Generated by braze-sync.\n\
     #\n\
     # This is a REGISTRY of Tags expected to exist in the target Braze\n\
     # workspace. Braze does not expose a public REST API for managing\n\
     # tags — they must be created in the Braze dashboard. This file\n\
     # makes the dependency explicit so apply can fail fast (before any\n\
     # mutation) when a referenced tag is missing.\n";

pub fn load_registry(path: &Path) -> Result<Option<TagRegistry>> {
    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: TagRegistry =
        serde_norway::from_str(&bytes).map_err(|source| Error::YamlParse {
            path: path.to_path_buf(),
            source,
        })?;
    Ok(Some(registry))
}

pub fn save_registry(path: &Path, registry: &TagRegistry) -> 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::{Tag, TagRegistry};

    fn sample() -> TagRegistry {
        TagRegistry {
            tags: vec![
                Tag {
                    name: "campaign".into(),
                    description: None,
                },
                Tag {
                    name: "ad_slot".into(),
                    description: Some("Ad slot tag".into()),
                },
                Tag {
                    name: "ad_slot/dialog".into(),
                    description: None,
                },
            ],
        }
    }

    #[test]
    fn save_load_roundtrip() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        let r = sample();
        save_registry(&path, &r).unwrap();
        let loaded = load_registry(&path).unwrap().unwrap();
        assert_eq!(loaded, r.normalized());
    }

    #[test]
    fn save_normalizes_order() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        save_registry(&path, &sample()).unwrap();
        let content = std::fs::read_to_string(&path).unwrap();
        let pos_ad = content.find("name: ad_slot\n").unwrap();
        let pos_dialog = content.find("ad_slot/dialog").unwrap();
        let pos_campaign = content.find("campaign").unwrap();
        assert!(pos_ad < pos_dialog);
        assert!(pos_dialog < pos_campaign);
    }

    #[test]
    fn save_includes_header_comment() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        save_registry(&path, &sample()).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");
        assert!(load_registry(&path).unwrap().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 empty_registry_roundtrips() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("registry.yaml");
        let r = TagRegistry { tags: vec![] };
        save_registry(&path, &r).unwrap();
        let loaded = load_registry(&path).unwrap().unwrap();
        assert!(loaded.tags.is_empty());
    }
}