Skip to main content

zoi_cli/pkg/
merge.rs

1use anyhow::Result;
2use colored::*;
3use diffy::merge;
4use std::fs;
5use std::path::Path;
6
7/// Performs a 3-way merge on configuration files defined in the `backup` manifest field.
8///
9/// It compares:
10/// - Base: The original package default from the previous version (`.zoiorig`).
11/// - Yours: The user's modified config in the previous version's directory.
12/// - Theirs: The new package default in the incoming version's directory.
13///
14/// Merging logic:
15/// - If Yours == Base: User didn't change it. Use Theirs (do nothing, already in place).
16/// - If Theirs == Base: Upstream didn't change it. Use Yours (copy Yours over Theirs).
17/// - If both changed: Perform 3-way merge.
18///   - Clean merge: Write result to Theirs.
19///   - Conflict: Write result with markers to Theirs, save Theirs as `.zoinew`.
20pub fn handle_backup_files(
21    old_version_dir: &Path,
22    new_version_dir: &Path,
23    backup_files: &[String],
24    scope: crate::pkg::types::Scope,
25) -> Result<()> {
26    for backup_file_rel in backup_files {
27        let old_expanded =
28            crate::pkg::utils::expand_placeholders(backup_file_rel, old_version_dir, scope)?;
29        let new_expanded =
30            crate::pkg::utils::expand_placeholders(backup_file_rel, new_version_dir, scope)?;
31
32        let old_path = std::path::PathBuf::from(old_expanded);
33        let new_path = std::path::PathBuf::from(new_expanded);
34
35        // Zoi creates .zoiorig in pkg_install.rs
36        let mut old_orig_path = old_path.clone();
37        let ext = old_orig_path
38            .extension()
39            .and_then(|s| s.to_str())
40            .map(|s| format!("{}.zoiorig", s))
41            .unwrap_or_else(|| "zoiorig".to_string());
42        old_orig_path.set_extension(ext);
43
44        if old_path.exists() {
45            // Try 3-way merge if we have the original base
46            if old_orig_path.exists()
47                && new_path.exists()
48                && let (Ok(base), Ok(yours), Ok(theirs)) = (
49                    fs::read_to_string(&old_orig_path),
50                    fs::read_to_string(&old_path),
51                    fs::read_to_string(&new_path),
52                )
53            {
54                if yours == base {
55                    // Case A: Unmodified by user. Use new upstream default.
56                    continue;
57                }
58
59                if theirs == base {
60                    // Case B: Upstream unchanged. Keep user's changes.
61                    if let Err(e) = fs::copy(&old_path, &new_path) {
62                        eprintln!("Warning: failed to restore user config: {}", e);
63                    }
64                    continue;
65                }
66
67                // Case C: 3-Way Merge
68                println!(
69                    "{} Merging changes for '{}'...",
70                    "::".bold().blue(),
71                    backup_file_rel.cyan()
72                );
73                match merge(&base, &yours, &theirs) {
74                    Ok(merged) => {
75                        println!("   {} Automatically merged.", "Success:".green());
76                        if let Err(e) = fs::write(&new_path, merged) {
77                            eprintln!("Warning: failed to write merged config: {}", e);
78                        }
79                    }
80                    Err(conflicted) => {
81                        eprintln!(
82                            "   {} Conflict in {}. Standard markers inserted.",
83                            "Warning:".yellow().bold(),
84                            backup_file_rel.bold()
85                        );
86                        // Save new default as .zoinew
87                        let zoinew_path = new_path.with_extension(format!(
88                            "{}.zoinew",
89                            new_path
90                                .extension()
91                                .and_then(|s| s.to_str())
92                                .unwrap_or_default()
93                        ));
94                        let _ = fs::copy(&new_path, &zoinew_path);
95
96                        if let Err(e) = fs::write(&new_path, conflicted) {
97                            eprintln!("Warning: failed to write conflicted config: {}", e);
98                        }
99                    }
100                }
101                continue;
102            }
103
104            // Legacy Fallback / Binary File handling
105            if new_path.exists() {
106                let zoinew_path = new_path.with_extension(format!(
107                    "{}.zoinew",
108                    new_path
109                        .extension()
110                        .and_then(|s| s.to_str())
111                        .unwrap_or_default()
112                ));
113                println!(
114                    "Configuration file '{}' exists in new version. Saving as .zoinew",
115                    new_path.display()
116                );
117                if let Err(e) = fs::rename(&new_path, &zoinew_path) {
118                    eprintln!("Warning: failed to rename to .zoinew: {}", e);
119                    continue;
120                }
121            }
122            if let Some(p) = new_path.parent() {
123                fs::create_dir_all(p)?;
124            }
125            if let Err(e) = fs::rename(&old_path, &new_path) {
126                eprintln!("Warning: failed to restore backup file: {}", e);
127            }
128        }
129    }
130    Ok(())
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use tempfile::tempdir;
137
138    #[test]
139    fn test_merge_case_a_unmodified() {
140        let dir = tempdir().unwrap();
141        let old_dir = dir.path().join("1.0.0");
142        let new_dir = dir.path().join("1.1.0");
143        fs::create_dir_all(&old_dir).unwrap();
144        fs::create_dir_all(&new_dir).unwrap();
145
146        let _config_rel = "config.txt";
147        let base = "line1\nline2\n";
148
149        fs::write(old_dir.join("config.txt.zoiorig"), base).unwrap();
150        fs::write(old_dir.join("config.txt"), base).unwrap();
151        fs::write(new_dir.join("config.txt"), "line1\nline2\nline3\n").unwrap();
152
153        handle_backup_files(
154            &old_dir,
155            &new_dir,
156            &["config.txt".to_string()],
157            crate::pkg::types::Scope::User,
158        )
159        .unwrap();
160
161        // Should keep new version
162        assert_eq!(
163            fs::read_to_string(new_dir.join("config.txt")).unwrap(),
164            "line1\nline2\nline3\n"
165        );
166    }
167
168    #[test]
169    fn test_merge_case_b_upstream_unchanged() {
170        let dir = tempdir().unwrap();
171        let old_dir = dir.path().join("1.0.0");
172        let new_dir = dir.path().join("1.1.0");
173        fs::create_dir_all(&old_dir).unwrap();
174        fs::create_dir_all(&new_dir).unwrap();
175
176        let base = "line1\nline2\n";
177        fs::write(old_dir.join("config.txt.zoiorig"), base).unwrap();
178        fs::write(old_dir.join("config.txt"), "line1\nline2\nuser_mod\n").unwrap();
179        fs::write(new_dir.join("config.txt"), base).unwrap();
180
181        handle_backup_files(
182            &old_dir,
183            &new_dir,
184            &["config.txt".to_string()],
185            crate::pkg::types::Scope::User,
186        )
187        .unwrap();
188
189        // Should keep user version
190        assert_eq!(
191            fs::read_to_string(new_dir.join("config.txt")).unwrap(),
192            "line1\nline2\nuser_mod\n"
193        );
194    }
195
196    #[test]
197    fn test_merge_case_c_clean_merge() {
198        let dir = tempdir().unwrap();
199        let old_dir = dir.path().join("1.0.0");
200        let new_dir = dir.path().join("1.1.0");
201        fs::create_dir_all(&old_dir).unwrap();
202        fs::create_dir_all(&new_dir).unwrap();
203
204        let base = "common\n";
205        fs::write(old_dir.join("config.txt.zoiorig"), base).unwrap();
206        fs::write(old_dir.join("config.txt"), "user_pref\ncommon\n").unwrap();
207        fs::write(new_dir.join("config.txt"), "common\nupstream_add\n").unwrap();
208
209        handle_backup_files(
210            &old_dir,
211            &new_dir,
212            &["config.txt".to_string()],
213            crate::pkg::types::Scope::User,
214        )
215        .unwrap();
216
217        let result = fs::read_to_string(new_dir.join("config.txt")).unwrap();
218        assert!(result.contains("user_pref"));
219        assert!(result.contains("upstream_add"));
220        assert!(result.contains("common"));
221    }
222
223    #[test]
224    fn test_merge_case_c_conflict() {
225        let dir = tempdir().unwrap();
226        let old_dir = dir.path().join("1.0.0");
227        let new_dir = dir.path().join("1.1.0");
228        fs::create_dir_all(&old_dir).unwrap();
229        fs::create_dir_all(&new_dir).unwrap();
230
231        let base = "line\n";
232        fs::write(old_dir.join("config.txt.zoiorig"), base).unwrap();
233        fs::write(old_dir.join("config.txt"), "user\n").unwrap();
234        fs::write(new_dir.join("config.txt"), "upstream\n").unwrap();
235
236        handle_backup_files(
237            &old_dir,
238            &new_dir,
239            &["config.txt".to_string()],
240            crate::pkg::types::Scope::User,
241        )
242        .unwrap();
243
244        let result = fs::read_to_string(new_dir.join("config.txt")).unwrap();
245        assert!(result.contains("<<<<<<<"));
246        assert!(result.contains("user"));
247        assert!(result.contains("upstream"));
248
249        assert!(new_dir.join("config.txt.zoinew").exists());
250    }
251}