Skip to main content

hub_codegen/
merge.rs

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/// Status of a file in three-way comparison (cache vs current vs new)
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum FileStatus {
12    /// File unchanged: cache == current == new
13    Unchanged,
14    /// Safe to update: cache == current, but new is different
15    SafeToUpdate,
16    /// User modified: cache != current (conflict!)
17    UserModified,
18    /// New file not in cache
19    NewFile,
20}
21
22/// Strategy for handling merge conflicts
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum MergeStrategy {
25    /// Skip modified files (safe default)
26    Skip,
27    /// Force overwrite everything
28    Force,
29    /// Interactive prompts (not yet implemented)
30    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/// Result of a merge operation
57#[derive(Debug)]
58pub struct MergeResult {
59    /// Files that were updated
60    pub updated: Vec<PathBuf>,
61    /// Files that were skipped due to user modifications
62    pub skipped: Vec<PathBuf>,
63    /// Files that were unchanged
64    pub unchanged: Vec<PathBuf>,
65    /// New files that were added
66    pub new: Vec<PathBuf>,
67}
68
69/// Determine file status from three-way hash comparison
70fn 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        // File not in cache
77        (None, None) => FileStatus::NewFile,
78        (None, Some(current)) => {
79            // Not in cache but exists on disk
80            if current == new_hash {
81                FileStatus::Unchanged
82            } else {
83                FileStatus::NewFile
84            }
85        }
86        // File in cache but deleted from disk
87        (Some(_cached), None) => FileStatus::SafeToUpdate, // Recreate it
88        // File in cache and on disk
89        (Some(cached), Some(current)) => {
90            if cached == current {
91                // User hasn't modified it
92                if current == new_hash {
93                    FileStatus::Unchanged
94                } else {
95                    FileStatus::SafeToUpdate
96                }
97            } else {
98                // User has modified it - conflict!
99                FileStatus::UserModified
100            }
101        }
102    }
103}
104
105/// Perform three-way merge: staging vs output vs cache
106pub 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        // Get current file hash if it exists
122        let current_hash = if output_path.exists() {
123            let current_content = fs::read_to_string(&output_path)?;
124            Some(compute_file_hash(&current_content))
125        } else {
126            None
127        };
128
129        // Get cached hash from manifest
130        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        // Determine file status
135        let status = determine_file_status(cached_hash, current_hash.as_deref(), &new_hash);
136
137        // Apply merge strategy
138        match status {
139            FileStatus::Unchanged => {
140                unchanged.push(PathBuf::from(rel_path));
141            }
142            FileStatus::SafeToUpdate => {
143                // Safe to update - write the file
144                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                // New file - write it
152                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                // Conflict! User has modified the file
160                match strategy {
161                    MergeStrategy::Skip => {
162                        // Skip this file
163                        skipped.push(PathBuf::from(rel_path));
164                    }
165                    MergeStrategy::Force => {
166                        // Overwrite anyway
167                        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
189/// Print merge result summary with warnings for conflicts
190pub 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}