1use anyhow::Result;
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::cache::CodeCacheManifest;
7use crate::hash::compute_file_hash;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum FileStatus {
12 Unchanged,
14 SafeToUpdate,
16 UserModified,
18 NewFile,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MergeStrategy {
25 Skip,
27 Force,
29 Interactive,
31}
32
33impl std::str::FromStr for MergeStrategy {
34 type Err = anyhow::Error;
35
36 fn from_str(s: &str) -> Result<Self> {
37 match s.to_lowercase().as_str() {
38 "skip" => Ok(MergeStrategy::Skip),
39 "force" => Ok(MergeStrategy::Force),
40 "interactive" => Ok(MergeStrategy::Interactive),
41 _ => anyhow::bail!("Invalid merge strategy: {}. Valid options: skip, force, interactive", s),
42 }
43 }
44}
45
46impl std::fmt::Display for MergeStrategy {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 MergeStrategy::Skip => write!(f, "skip"),
50 MergeStrategy::Force => write!(f, "force"),
51 MergeStrategy::Interactive => write!(f, "interactive"),
52 }
53 }
54}
55
56#[derive(Debug)]
58pub struct MergeResult {
59 pub updated: Vec<PathBuf>,
61 pub skipped: Vec<PathBuf>,
63 pub unchanged: Vec<PathBuf>,
65 pub new: Vec<PathBuf>,
67}
68
69fn determine_file_status(
71 cached_hash: Option<&str>,
72 current_hash: Option<&str>,
73 new_hash: &str,
74) -> FileStatus {
75 match (cached_hash, current_hash) {
76 (None, None) => FileStatus::NewFile,
78 (None, Some(current)) => {
79 if current == new_hash {
81 FileStatus::Unchanged
82 } else {
83 FileStatus::NewFile
84 }
85 }
86 (Some(_cached), None) => FileStatus::SafeToUpdate, (Some(cached), Some(current)) => {
90 if cached == current {
91 if current == new_hash {
93 FileStatus::Unchanged
94 } else {
95 FileStatus::SafeToUpdate
96 }
97 } else {
98 FileStatus::UserModified
100 }
101 }
102 }
103}
104
105pub fn merge_generated_code(
107 staging_files: &HashMap<String, String>,
108 output_dir: &Path,
109 cache_manifest: Option<&CodeCacheManifest>,
110 strategy: MergeStrategy,
111) -> Result<MergeResult> {
112 let mut updated = Vec::new();
113 let mut skipped = Vec::new();
114 let mut unchanged = Vec::new();
115 let mut new = Vec::new();
116
117 for (rel_path, new_content) in staging_files {
118 let output_path = output_dir.join(rel_path);
119 let new_hash = compute_file_hash(new_content);
120
121 let current_hash = if output_path.exists() {
123 let current_content = fs::read_to_string(&output_path)?;
124 Some(compute_file_hash(¤t_content))
125 } else {
126 None
127 };
128
129 let cached_hash = cache_manifest
131 .and_then(|m| m.plugins.values().find_map(|p| p.file_hashes.get(rel_path)))
132 .map(|s| s.as_str());
133
134 let status = determine_file_status(cached_hash, current_hash.as_deref(), &new_hash);
136
137 match status {
139 FileStatus::Unchanged => {
140 unchanged.push(PathBuf::from(rel_path));
141 }
142 FileStatus::SafeToUpdate => {
143 if let Some(parent) = output_path.parent() {
145 fs::create_dir_all(parent)?;
146 }
147 fs::write(&output_path, new_content)?;
148 updated.push(PathBuf::from(rel_path));
149 }
150 FileStatus::NewFile => {
151 if let Some(parent) = output_path.parent() {
153 fs::create_dir_all(parent)?;
154 }
155 fs::write(&output_path, new_content)?;
156 new.push(PathBuf::from(rel_path));
157 }
158 FileStatus::UserModified => {
159 match strategy {
161 MergeStrategy::Skip => {
162 skipped.push(PathBuf::from(rel_path));
164 }
165 MergeStrategy::Force => {
166 if let Some(parent) = output_path.parent() {
168 fs::create_dir_all(parent)?;
169 }
170 fs::write(&output_path, new_content)?;
171 updated.push(PathBuf::from(rel_path));
172 }
173 MergeStrategy::Interactive => {
174 anyhow::bail!("Interactive merge strategy not yet implemented");
175 }
176 }
177 }
178 }
179 }
180
181 Ok(MergeResult {
182 updated,
183 skipped,
184 unchanged,
185 new,
186 })
187}
188
189pub fn print_merge_summary(result: &MergeResult) {
191 let total = result.updated.len() + result.skipped.len() + result.unchanged.len() + result.new.len();
192
193 println!("\nMerge Summary:");
194 println!(" Updated: {} files", result.updated.len());
195 println!(" New: {} files", result.new.len());
196 println!(" Unchanged: {} files", result.unchanged.len());
197
198 if !result.skipped.is_empty() {
199 eprintln!("\n WARNING: The following files have been modified and were NOT updated:");
200 for file in &result.skipped {
201 eprintln!(" {}", file.display());
202 }
203 eprintln!("\n These files were skipped to preserve your changes.");
204 eprintln!(" To overwrite them, use: --merge-strategy force");
205 }
206
207 println!("\nTotal: {} files", total);
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_determine_file_status_unchanged() {
216 let hash = "abc123";
217 let status = determine_file_status(Some(hash), Some(hash), hash);
218 assert_eq!(status, FileStatus::Unchanged);
219 }
220
221 #[test]
222 fn test_determine_file_status_safe_to_update() {
223 let cached = "abc123";
224 let current = "abc123";
225 let new = "def456";
226 let status = determine_file_status(Some(cached), Some(current), new);
227 assert_eq!(status, FileStatus::SafeToUpdate);
228 }
229
230 #[test]
231 fn test_determine_file_status_user_modified() {
232 let cached = "abc123";
233 let current = "modified";
234 let new = "def456";
235 let status = determine_file_status(Some(cached), Some(current), new);
236 assert_eq!(status, FileStatus::UserModified);
237 }
238
239 #[test]
240 fn test_determine_file_status_new_file() {
241 let new = "abc123";
242 let status = determine_file_status(None, None, new);
243 assert_eq!(status, FileStatus::NewFile);
244 }
245
246 #[test]
247 fn test_merge_strategy_from_str() {
248 assert_eq!("skip".parse::<MergeStrategy>().unwrap(), MergeStrategy::Skip);
249 assert_eq!("force".parse::<MergeStrategy>().unwrap(), MergeStrategy::Force);
250 assert_eq!("interactive".parse::<MergeStrategy>().unwrap(), MergeStrategy::Interactive);
251 assert!("invalid".parse::<MergeStrategy>().is_err());
252 }
253}