1use crate::resource::{Tag, TagRegistry};
23use std::collections::{BTreeMap, BTreeSet};
24
25#[derive(Debug, Clone)]
26pub struct TagDiff {
27 pub name: String,
28 pub op: TagOp,
29 pub hints: Vec<String>,
30}
31
32#[derive(Debug, Clone)]
33pub enum TagOp {
34 ReferencedButUnregistered,
37 RegisteredButUnreferenced,
39 Unchanged,
40}
41
42impl TagDiff {
43 pub fn has_changes(&self) -> bool {
44 !matches!(self.op, TagOp::Unchanged)
45 }
46}
47
48pub fn diff(local: Option<&TagRegistry>, referenced: &BTreeSet<String>) -> Vec<TagDiff> {
55 let registry_by_name: BTreeMap<&str, &Tag> = local
56 .map(|r| {
57 let mut map = BTreeMap::new();
58 for t in &r.tags {
59 if map.insert(t.name.as_str(), t).is_some() {
60 tracing::warn!(
61 name = t.name.as_str(),
62 "duplicate tag name in local registry; \
63 last entry wins (run `validate` to catch this)"
64 );
65 }
66 }
67 map
68 })
69 .unwrap_or_default();
70
71 let mut all_names: BTreeSet<&str> = BTreeSet::new();
72 all_names.extend(registry_by_name.keys().copied());
73 all_names.extend(referenced.iter().map(String::as_str));
74
75 let mut diffs = Vec::new();
76 for name in all_names {
77 let in_registry = registry_by_name.contains_key(name);
78 let is_referenced = referenced.contains(name);
79 let op = match (in_registry, is_referenced) {
80 (true, true) => TagOp::Unchanged,
81 (true, false) => TagOp::RegisteredButUnreferenced,
82 (false, true) => TagOp::ReferencedButUnregistered,
83 (false, false) => unreachable!("name came from one of the two maps"),
84 };
85 diffs.push(TagDiff {
86 name: name.to_string(),
87 op,
88 hints: Vec::new(),
89 });
90 }
91
92 diffs
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use crate::resource::Tag;
99
100 fn registry(names: &[&str]) -> TagRegistry {
101 TagRegistry {
102 tags: names
103 .iter()
104 .map(|n| Tag {
105 name: (*n).to_string(),
106 description: None,
107 })
108 .collect(),
109 }
110 }
111
112 fn referenced(names: &[&str]) -> BTreeSet<String> {
113 names.iter().map(|s| s.to_string()).collect()
114 }
115
116 #[test]
117 fn no_diff_when_registry_matches_references() {
118 let r = registry(&["a", "b"]);
119 let used = referenced(&["a", "b"]);
120 let diffs = diff(Some(&r), &used);
121 assert_eq!(diffs.len(), 2);
122 assert!(diffs.iter().all(|d| !d.has_changes()));
123 }
124
125 #[test]
126 fn referenced_but_unregistered_tag_is_flagged() {
127 let r = registry(&["a"]);
128 let used = referenced(&["a", "missing"]);
129 let diffs = diff(Some(&r), &used);
130 let missing = diffs.iter().find(|d| d.name == "missing").unwrap();
131 assert!(matches!(missing.op, TagOp::ReferencedButUnregistered));
132 }
133
134 #[test]
135 fn registered_but_unreferenced_tag_is_flagged() {
136 let r = registry(&["a", "orphan"]);
137 let used = referenced(&["a"]);
138 let diffs = diff(Some(&r), &used);
139 let orphan = diffs.iter().find(|d| d.name == "orphan").unwrap();
140 assert!(matches!(orphan.op, TagOp::RegisteredButUnreferenced));
141 }
142
143 #[test]
144 fn missing_registry_treats_all_references_as_unregistered() {
145 let used = referenced(&["a", "b"]);
146 let diffs = diff(None, &used);
147 assert_eq!(diffs.len(), 2);
148 assert!(diffs
149 .iter()
150 .all(|d| matches!(d.op, TagOp::ReferencedButUnregistered)));
151 }
152
153 #[test]
154 fn empty_when_neither_side_has_tags() {
155 let diffs = diff(None, &referenced(&[]));
156 assert!(diffs.is_empty());
157 }
158
159 #[test]
160 fn diffs_are_sorted_by_name() {
161 let r = registry(&["zebra", "apple"]);
162 let used = referenced(&["mango"]);
163 let diffs = diff(Some(&r), &used);
164 let names: Vec<&str> = diffs.iter().map(|d| d.name.as_str()).collect();
165 assert_eq!(names, vec!["apple", "mango", "zebra"]);
166 }
167}