1use serde::de::DeserializeOwned;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11use crate::Error;
12
13#[derive(Debug, Clone)]
18pub struct ModuleEvaluation {
19 pub root: PathBuf,
21
22 pub instances: HashMap<PathBuf, Instance>,
24}
25
26impl ModuleEvaluation {
27 pub fn from_raw(
34 root: PathBuf,
35 raw_instances: HashMap<String, serde_json::Value>,
36 project_paths: Vec<String>,
37 ) -> Self {
38 let project_set: std::collections::HashSet<&str> =
40 project_paths.iter().map(String::as_str).collect();
41
42 let instances = raw_instances
43 .into_iter()
44 .map(|(path, value)| {
45 let path_buf = PathBuf::from(&path);
46 let kind = if project_set.contains(path.as_str()) {
48 InstanceKind::Project
49 } else {
50 InstanceKind::Base
51 };
52 let instance = Instance {
53 path: path_buf.clone(),
54 kind,
55 value,
56 };
57 (path_buf, instance)
58 })
59 .collect();
60
61 Self { root, instances }
62 }
63
64 pub fn bases(&self) -> impl Iterator<Item = &Instance> {
66 self.instances
67 .values()
68 .filter(|i| matches!(i.kind, InstanceKind::Base))
69 }
70
71 pub fn projects(&self) -> impl Iterator<Item = &Instance> {
73 self.instances
74 .values()
75 .filter(|i| matches!(i.kind, InstanceKind::Project))
76 }
77
78 pub fn root_instance(&self) -> Option<&Instance> {
80 self.instances.get(Path::new("."))
81 }
82
83 pub fn get(&self, path: &Path) -> Option<&Instance> {
85 self.instances.get(path)
86 }
87
88 pub fn base_count(&self) -> usize {
90 self.bases().count()
91 }
92
93 pub fn project_count(&self) -> usize {
95 self.projects().count()
96 }
97
98 pub fn ancestors(&self, path: &Path) -> Vec<PathBuf> {
103 if path == Path::new(".") {
105 return Vec::new();
106 }
107
108 let mut ancestors = Vec::new();
109 let mut current = path.to_path_buf();
110
111 while let Some(parent) = current.parent() {
112 if parent.as_os_str().is_empty() {
113 ancestors.push(PathBuf::from("."));
115 break;
116 }
117 ancestors.push(parent.to_path_buf());
118 current = parent.to_path_buf();
119 }
120
121 ancestors
122 }
123
124 pub fn is_inherited(&self, child_path: &Path, field: &str) -> bool {
129 let Some(child) = self.instances.get(child_path) else {
130 return false;
131 };
132
133 let Some(child_value) = child.value.get(field) else {
134 return false;
135 };
136
137 for ancestor_path in self.ancestors(child_path) {
139 if let Some(ancestor) = self.instances.get(&ancestor_path)
140 && let Some(ancestor_value) = ancestor.value.get(field)
141 && child_value == ancestor_value
142 {
143 return true;
144 }
145 }
146
147 false
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Instance {
154 pub path: PathBuf,
156
157 pub kind: InstanceKind,
159
160 pub value: serde_json::Value,
162}
163
164impl Instance {
165 pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
177 serde_json::from_value(self.value.clone()).map_err(|e| {
178 Error::configuration(format!(
179 "Failed to deserialize {} as {}: {}",
180 self.path.display(),
181 std::any::type_name::<T>(),
182 e
183 ))
184 })
185 }
186
187 pub fn project_name(&self) -> Option<&str> {
189 if matches!(self.kind, InstanceKind::Project) {
190 self.value.get("name").and_then(|v| v.as_str())
191 } else {
192 None
193 }
194 }
195
196 pub fn get_field(&self, field: &str) -> Option<&serde_json::Value> {
198 self.value.get(field)
199 }
200
201 pub fn has_field(&self, field: &str) -> bool {
203 self.value.get(field).is_some()
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209pub enum InstanceKind {
210 Base,
212 Project,
214}
215
216impl std::fmt::Display for InstanceKind {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 match self {
219 Self::Base => write!(f, "Base"),
220 Self::Project => write!(f, "Project"),
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use serde_json::json;
229
230 fn create_test_module() -> ModuleEvaluation {
231 let mut raw = HashMap::new();
232
233 raw.insert(
235 ".".to_string(),
236 json!({
237 "env": { "SHARED": "value" },
238 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
239 }),
240 );
241
242 raw.insert(
244 "projects/api".to_string(),
245 json!({
246 "name": "api",
247 "env": { "SHARED": "value" },
248 "owners": { "rules": { "default": { "pattern": "**", "owners": ["@owner"] } } }
249 }),
250 );
251
252 raw.insert(
254 "projects/web".to_string(),
255 json!({
256 "name": "web",
257 "env": { "SHARED": "value" },
258 "owners": { "rules": { "local": { "pattern": "**", "owners": ["@web-team"] } } }
259 }),
260 );
261
262 let project_paths = vec!["projects/api".to_string(), "projects/web".to_string()];
264
265 ModuleEvaluation::from_raw(PathBuf::from("/test/repo"), raw, project_paths)
266 }
267
268 #[test]
269 fn test_instance_kind_detection() {
270 let module = create_test_module();
271
272 assert_eq!(module.base_count(), 1);
273 assert_eq!(module.project_count(), 2);
274
275 let root = module.root_instance().unwrap();
276 assert!(matches!(root.kind, InstanceKind::Base));
277
278 let api = module.get(Path::new("projects/api")).unwrap();
279 assert!(matches!(api.kind, InstanceKind::Project));
280 assert_eq!(api.project_name(), Some("api"));
281 }
282
283 #[test]
284 fn test_ancestors() {
285 let module = create_test_module();
286
287 let ancestors = module.ancestors(Path::new("projects/api"));
288 assert_eq!(ancestors.len(), 2);
289 assert_eq!(ancestors[0], PathBuf::from("projects"));
290 assert_eq!(ancestors[1], PathBuf::from("."));
291
292 let root_ancestors = module.ancestors(Path::new("."));
293 assert!(root_ancestors.is_empty());
294 }
295
296 #[test]
297 fn test_is_inherited() {
298 let module = create_test_module();
299
300 assert!(module.is_inherited(Path::new("projects/api"), "owners"));
302
303 assert!(!module.is_inherited(Path::new("projects/web"), "owners"));
305
306 assert!(module.is_inherited(Path::new("projects/api"), "env"));
308 }
309
310 #[test]
311 fn test_instance_kind_display() {
312 assert_eq!(InstanceKind::Base.to_string(), "Base");
313 assert_eq!(InstanceKind::Project.to_string(), "Project");
314 }
315
316 #[test]
317 fn test_instance_deserialize() {
318 #[derive(Debug, Deserialize, PartialEq)]
319 struct TestConfig {
320 name: String,
321 env: std::collections::HashMap<String, String>,
322 }
323
324 let instance = Instance {
325 path: PathBuf::from("test/path"),
326 kind: InstanceKind::Project,
327 value: json!({
328 "name": "my-project",
329 "env": { "FOO": "bar" }
330 }),
331 };
332
333 let config: TestConfig = instance.deserialize().unwrap();
334 assert_eq!(config.name, "my-project");
335 assert_eq!(config.env.get("FOO"), Some(&"bar".to_string()));
336 }
337
338 #[test]
339 fn test_instance_deserialize_error() {
340 #[derive(Debug, Deserialize)]
341 #[allow(dead_code)]
342 struct RequiredFields {
343 required_field: String,
344 }
345
346 let instance = Instance {
347 path: PathBuf::from("test/path"),
348 kind: InstanceKind::Base,
349 value: json!({}), };
351
352 let result: crate::Result<RequiredFields> = instance.deserialize();
353 assert!(result.is_err());
354
355 let err = result.unwrap_err();
356 let msg = err.to_string();
357 assert!(
358 msg.contains("test/path"),
359 "Error should mention path: {}",
360 msg
361 );
362 assert!(
363 msg.contains("RequiredFields"),
364 "Error should mention target type: {}",
365 msg
366 );
367 }
368}