Skip to main content

archidoc_engine/
merge.rs

1use std::fmt;
2use std::collections::HashMap;
3use archidoc_types::ModuleDoc;
4
5/// Error returned when merge encounters conflicting module definitions.
6#[derive(Debug)]
7pub struct MergeError {
8    pub module_path: String,
9    pub message: String,
10}
11
12impl fmt::Display for MergeError {
13    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14        write!(f, "merge conflict at '{}': {}", self.module_path, self.message)
15    }
16}
17
18/// Merge multiple IR sets into a single unified ModuleDoc list.
19///
20/// Rules:
21/// - Modules with unique paths are included as-is
22/// - Duplicate module_paths with the SAME c4_level: last writer wins (later source overrides earlier)
23/// - Duplicate module_paths with DIFFERENT c4_levels: returns MergeError
24/// - Output is sorted by module_path
25pub fn merge_ir(sources: Vec<Vec<ModuleDoc>>) -> Result<Vec<ModuleDoc>, MergeError> {
26    let mut merged: HashMap<String, ModuleDoc> = HashMap::new();
27
28    for source_set in sources {
29        for doc in source_set {
30            let module_path = doc.module_path.clone();
31
32            if let Some(existing) = merged.get(&module_path) {
33                if existing.c4_level != doc.c4_level {
34                    return Err(MergeError {
35                        module_path: module_path.clone(),
36                        message: format!(
37                            "conflicting C4 levels: existing '{}' vs new '{}'",
38                            existing.c4_level,
39                            doc.c4_level
40                        ),
41                    });
42                }
43
44                eprintln!(
45                    "warning: duplicate module '{}' at C4 level '{}', overwriting with later source",
46                    module_path,
47                    doc.c4_level
48                );
49            }
50
51            merged.insert(module_path, doc);
52        }
53    }
54
55    let mut result: Vec<ModuleDoc> = merged.into_values().collect();
56    result.sort_by(|a, b| a.module_path.cmp(&b.module_path));
57
58    Ok(result)
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use archidoc_types::{C4Level, PatternStatus, Relationship};
65
66    fn make_doc(path: &str, level: C4Level) -> ModuleDoc {
67        ModuleDoc {
68            module_path: path.to_string(),
69            content: String::new(),
70            source_file: format!("src/{}/mod.rs", path),
71            c4_level: level,
72            pattern: "--".to_string(),
73            pattern_status: PatternStatus::Planned,
74            description: format!("Module {}", path),
75            parent_container: None,
76            relationships: vec![],
77            files: vec![],
78        }
79    }
80
81    #[test]
82    fn merge_combines_disjoint_sets() {
83        let set1 = vec![
84            make_doc("api", C4Level::Container),
85            make_doc("core", C4Level::Container),
86        ];
87        let set2 = vec![
88            make_doc("database", C4Level::Component),
89            make_doc("ui", C4Level::Component),
90        ];
91
92        let result = merge_ir(vec![set1, set2]).unwrap();
93
94        assert_eq!(result.len(), 4);
95        assert_eq!(result[0].module_path, "api");
96        assert_eq!(result[1].module_path, "core");
97        assert_eq!(result[2].module_path, "database");
98        assert_eq!(result[3].module_path, "ui");
99    }
100
101    #[test]
102    fn merge_deduplicates_same_level() {
103        let set1 = vec![
104            make_doc("api", C4Level::Container),
105        ];
106        let mut set2 = vec![
107            make_doc("api", C4Level::Container),
108        ];
109        set2[0].description = "Updated API module".to_string();
110
111        let result = merge_ir(vec![set1, set2]).unwrap();
112
113        assert_eq!(result.len(), 1);
114        assert_eq!(result[0].module_path, "api");
115        assert_eq!(result[0].description, "Updated API module");
116    }
117
118    #[test]
119    fn merge_rejects_conflicting_c4_levels() {
120        let set1 = vec![
121            make_doc("api", C4Level::Container),
122        ];
123        let set2 = vec![
124            make_doc("api", C4Level::Component),
125        ];
126
127        let result = merge_ir(vec![set1, set2]);
128
129        assert!(result.is_err());
130        let err = result.unwrap_err();
131        assert_eq!(err.module_path, "api");
132        assert!(err.message.contains("conflicting C4 levels"));
133        assert!(err.message.contains("container"));
134        assert!(err.message.contains("component"));
135    }
136
137    #[test]
138    fn merge_sorts_by_module_path() {
139        let set1 = vec![
140            make_doc("zebra", C4Level::Container),
141            make_doc("alpha", C4Level::Container),
142        ];
143        let set2 = vec![
144            make_doc("middle", C4Level::Component),
145        ];
146
147        let result = merge_ir(vec![set1, set2]).unwrap();
148
149        assert_eq!(result.len(), 3);
150        assert_eq!(result[0].module_path, "alpha");
151        assert_eq!(result[1].module_path, "middle");
152        assert_eq!(result[2].module_path, "zebra");
153    }
154
155    #[test]
156    fn merge_empty_inputs_returns_empty() {
157        let result1 = merge_ir(vec![]).unwrap();
158        assert_eq!(result1.len(), 0);
159
160        let result2 = merge_ir(vec![vec![], vec![]]).unwrap();
161        assert_eq!(result2.len(), 0);
162    }
163
164    #[test]
165    fn merge_preserves_relationships() {
166        let mut doc1 = make_doc("api", C4Level::Container);
167        doc1.relationships = vec![
168            Relationship {
169                target: "database".to_string(),
170                label: "Persists data".to_string(),
171                protocol: "sqlx".to_string(),
172            },
173        ];
174
175        let mut doc2 = make_doc("database", C4Level::Component);
176        doc2.relationships = vec![
177            Relationship {
178                target: "storage".to_string(),
179                label: "Writes files".to_string(),
180                protocol: "fs".to_string(),
181            },
182        ];
183
184        let result = merge_ir(vec![vec![doc1], vec![doc2]]).unwrap();
185
186        assert_eq!(result.len(), 2);
187
188        let api_doc = result.iter().find(|d| d.module_path == "api").unwrap();
189        assert_eq!(api_doc.relationships.len(), 1);
190        assert_eq!(api_doc.relationships[0].target, "database");
191
192        let db_doc = result.iter().find(|d| d.module_path == "database").unwrap();
193        assert_eq!(db_doc.relationships.len(), 1);
194        assert_eq!(db_doc.relationships[0].target, "storage");
195    }
196}