Skip to main content

zoi_cli/pkg/
merge.rs

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