1use std::collections::BTreeMap;
27
28use crate::ir::IacResource;
29use crate::sexpr::{FromSExpr, SExpr, SExprError, ToSExpr, parse_struct, struct_expr, take_field};
30
31#[derive(Debug, Clone, Default)]
34pub struct Fleet {
35 pub name: String,
37 pub members: BTreeMap<String, IacResource>,
39}
40
41impl Fleet {
42 #[must_use]
44 pub fn new(name: impl Into<String>) -> Self {
45 Self {
46 name: name.into(),
47 members: BTreeMap::new(),
48 }
49 }
50
51 pub fn insert(
53 &mut self,
54 name: impl Into<String>,
55 resource: IacResource,
56 ) -> Option<IacResource> {
57 self.members.insert(name.into(), resource)
58 }
59
60 pub fn remove(&mut self, name: &str) -> Option<IacResource> {
62 self.members.remove(name)
63 }
64
65 #[must_use]
67 pub fn len(&self) -> usize {
68 self.members.len()
69 }
70
71 #[must_use]
73 pub fn is_empty(&self) -> bool {
74 self.members.is_empty()
75 }
76
77 #[must_use]
79 pub fn get(&self, name: &str) -> Option<&IacResource> {
80 self.members.get(name)
81 }
82
83 #[must_use]
86 pub fn member_hash(&self, name: &str) -> Option<String> {
87 self.members.get(name).map(|r| r.content_hash().to_hex())
88 }
89
90 #[must_use]
92 pub fn member_names(&self) -> Vec<&str> {
93 self.members.keys().map(String::as_str).collect()
94 }
95}
96
97impl ToSExpr for Fleet {
100 fn to_sexpr(&self) -> SExpr {
101 let members: Vec<SExpr> = self
102 .members
103 .iter()
104 .map(|(name, resource)| {
105 struct_expr(
106 "member",
107 vec![("name", name.to_sexpr()), ("resource", resource.to_sexpr())],
108 )
109 })
110 .collect();
111 let mut member_list = Vec::with_capacity(members.len() + 1);
112 member_list.push(SExpr::Symbol("list".into()));
113 member_list.extend(members);
114 struct_expr(
115 "fleet",
116 vec![
117 ("name", self.name.to_sexpr()),
118 ("members", SExpr::List(member_list)),
119 ],
120 )
121 }
122}
123
124impl FromSExpr for Fleet {
125 fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
126 let f = parse_struct(s, "fleet")?;
127 let name = String::from_sexpr(take_field(&f, "name")?)?;
128 let members_sexpr = take_field(&f, "members")?;
129 let items = members_sexpr.as_list()?;
130 let (head, rest) = items
131 .split_first()
132 .ok_or_else(|| SExprError::Shape("fleet members must be a (list …) form".into()))?;
133 let tag = head.as_symbol()?;
134 if tag != "list" {
135 return Err(SExprError::Shape(format!(
136 "expected 'list' for fleet members, got '{tag}'"
137 )));
138 }
139 let mut members = BTreeMap::new();
140 for item in rest {
141 let mf = parse_struct(item, "member")?;
142 let member_name = String::from_sexpr(take_field(&mf, "name")?)?;
143 let resource = IacResource::from_sexpr(take_field(&mf, "resource")?)?;
144 members.insert(member_name, resource);
145 }
146 Ok(Self { name, members })
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::sexpr::SExpr;
154 use crate::testing::test_resource;
155
156 fn two_member_fleet() -> Fleet {
157 let mut fleet = Fleet::new("prod");
158 fleet.insert("api", test_resource("api"));
159 fleet.insert("worker", test_resource("worker"));
160 fleet
161 }
162
163 #[test]
166 fn new_fleet_is_empty() {
167 let fleet = Fleet::new("x");
168 assert!(fleet.is_empty());
169 assert_eq!(fleet.len(), 0);
170 assert_eq!(fleet.name, "x");
171 }
172
173 #[test]
174 fn insert_and_get() {
175 let mut fleet = Fleet::new("x");
176 fleet.insert("a", test_resource("a"));
177 assert_eq!(fleet.len(), 1);
178 assert_eq!(fleet.get("a").unwrap().name, "a");
179 }
180
181 #[test]
182 fn remove_returns_value() {
183 let mut fleet = two_member_fleet();
184 let removed = fleet.remove("api");
185 assert!(removed.is_some());
186 assert_eq!(fleet.len(), 1);
187 }
188
189 #[test]
190 fn insert_replaces_returns_previous() {
191 let mut fleet = Fleet::new("x");
192 let a1 = test_resource("a");
193 let mut a2 = test_resource("a");
194 a2.description = "different".into();
195
196 assert!(fleet.insert("a", a1).is_none());
197 let prev = fleet.insert("a", a2);
198 assert!(prev.is_some());
199 assert_eq!(fleet.len(), 1);
200 }
201
202 #[test]
203 fn member_names_are_sorted() {
204 let mut fleet = Fleet::new("x");
205 fleet.insert("z", test_resource("z"));
206 fleet.insert("a", test_resource("a"));
207 fleet.insert("m", test_resource("m"));
208 assert_eq!(fleet.member_names(), vec!["a", "m", "z"]);
209 }
210
211 #[test]
214 fn round_trip_empty() {
215 let fleet = Fleet::new("empty");
216 let parsed = Fleet::from_sexpr(&fleet.to_sexpr()).unwrap();
217 assert_eq!(parsed.content_hash(), fleet.content_hash());
218 assert_eq!(parsed.name, fleet.name);
219 assert_eq!(parsed.member_names(), fleet.member_names());
220 }
221
222 #[test]
223 fn round_trip_single_member() {
224 let mut fleet = Fleet::new("one");
225 fleet.insert("only", test_resource("only"));
226 let parsed = Fleet::from_sexpr(&fleet.to_sexpr()).unwrap();
227 assert_eq!(parsed.content_hash(), fleet.content_hash());
228 assert_eq!(parsed.name, fleet.name);
229 assert_eq!(parsed.member_names(), fleet.member_names());
230 }
231
232 #[test]
233 fn round_trip_multiple_members() {
234 let fleet = two_member_fleet();
235 let parsed = Fleet::from_sexpr(&fleet.to_sexpr()).unwrap();
236 assert_eq!(parsed.content_hash(), fleet.content_hash());
237 assert_eq!(parsed.name, fleet.name);
238 assert_eq!(parsed.member_names(), fleet.member_names());
239 }
240
241 #[test]
242 fn round_trip_through_text_boundary() {
243 let fleet = two_member_fleet();
244 let emitted = fleet.to_sexpr().emit();
245 let parsed = Fleet::from_sexpr(&SExpr::parse(&emitted).unwrap()).unwrap();
246 assert_eq!(parsed.content_hash(), fleet.content_hash());
247 assert_eq!(parsed.member_names(), fleet.member_names());
248 }
249
250 #[test]
253 fn fleet_content_hash_is_deterministic() {
254 let a = two_member_fleet();
255 let b = two_member_fleet();
256 assert_eq!(a.content_hash(), b.content_hash());
257 }
258
259 #[test]
260 fn different_fleets_differ_in_hash() {
261 let a = two_member_fleet();
262 let b = Fleet::new("prod");
263 assert_ne!(a.content_hash(), b.content_hash());
264 }
265
266 #[test]
267 fn adding_a_member_changes_hash() {
268 let a = two_member_fleet();
269 let mut b = two_member_fleet();
270 b.insert("extra", test_resource("extra"));
271 assert_ne!(a.content_hash(), b.content_hash());
272 }
273
274 #[test]
275 fn insertion_order_does_not_affect_hash() {
276 let mut a = Fleet::new("prod");
278 a.insert("z", test_resource("z"));
279 a.insert("a", test_resource("a"));
280
281 let mut b = Fleet::new("prod");
282 b.insert("a", test_resource("a"));
283 b.insert("z", test_resource("z"));
284
285 assert_eq!(a.content_hash(), b.content_hash());
286 }
287
288 #[test]
289 fn renaming_fleet_changes_hash() {
290 let mut a = Fleet::new("prod");
291 a.insert("api", test_resource("api"));
292 let mut b = Fleet::new("staging");
293 b.insert("api", test_resource("api"));
294 assert_ne!(a.content_hash(), b.content_hash());
295 }
296
297 #[test]
300 fn member_hash_matches_resource_content_hash() {
301 let fleet = two_member_fleet();
302 let expected = test_resource("api").content_hash().to_hex();
303 assert_eq!(fleet.member_hash("api").unwrap(), expected);
304 }
305
306 #[test]
307 fn member_hash_is_none_for_missing() {
308 let fleet = two_member_fleet();
309 assert!(fleet.member_hash("nope").is_none());
310 }
311
312 #[test]
313 fn mutating_one_member_changes_fleet_hash() {
314 let mut a = two_member_fleet();
315 let mut b = two_member_fleet();
316 assert_eq!(a.content_hash(), b.content_hash());
317
318 if let Some(r) = b.members.get_mut("api") {
320 r.description = "mutated".into();
321 }
322 assert_ne!(a.content_hash(), b.content_hash());
323
324 assert_ne!(a.member_hash("api"), b.member_hash("api"));
326 assert_eq!(a.member_hash("worker"), b.member_hash("worker"));
328 }
329
330 #[test]
333 fn from_sexpr_rejects_wrong_top_level() {
334 let err =
335 Fleet::from_sexpr(&SExpr::parse("(resource (:name \"x\"))").unwrap()).unwrap_err();
336 assert!(matches!(err, SExprError::Shape(_)));
337 }
338
339 #[test]
340 fn from_sexpr_rejects_non_list_members() {
341 let s = SExpr::parse("(fleet (:name \"x\") (:members 42))").unwrap();
342 let err = Fleet::from_sexpr(&s).unwrap_err();
343 assert!(matches!(err, SExprError::Shape(_)));
344 }
345}