Skip to main content

diffx_core/diff/
core.rs

1// Core diff functions: diff_paths, diff, diff_files, diff_directories
2
3use crate::{detect_format_from_path, parse_content_by_format, DiffOptions, DiffResult};
4use anyhow::{anyhow, Result};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9
10use super::diff_recursive;
11use crate::io::get_all_files_recursive;
12
13/// Unified diff function for diffx (path-based entry point)
14///
15/// This is the main entry point that handles both files and directories.
16/// - File vs File: Regular file comparison
17/// - Directory vs Directory: Requires --recursive flag, otherwise error
18/// - File vs Directory: Returns error
19pub fn diff_paths(
20    old_path: &str,
21    new_path: &str,
22    options: Option<&DiffOptions>,
23) -> Result<Vec<DiffResult>> {
24    let path1 = Path::new(old_path);
25    let path2 = Path::new(new_path);
26
27    let recursive = options.and_then(|o| o.recursive).unwrap_or(false);
28
29    match (path1.is_dir(), path2.is_dir()) {
30        (true, true) => {
31            if recursive {
32                diff_directories(path1, path2, options)
33            } else {
34                Err(anyhow!(
35                    "Both paths are directories. Use --recursive (-r) to compare directories."
36                ))
37            }
38        }
39        (false, false) => diff_files(path1, path2, options),
40        (true, false) => Err(anyhow!(
41            "Cannot compare directory '{old_path}' with file '{new_path}'"
42        )),
43        (false, true) => Err(anyhow!(
44            "Cannot compare file '{old_path}' with directory '{new_path}'"
45        )),
46    }
47}
48
49/// Unified diff function for diffx (Value-based)
50///
51/// This function operates on pre-parsed JSON values.
52/// For file/directory operations, use diff_paths() instead.
53pub fn diff(old: &Value, new: &Value, options: Option<&DiffOptions>) -> Result<Vec<DiffResult>> {
54    let default_options = DiffOptions::default();
55    let opts = options.unwrap_or(&default_options);
56
57    let mut results = Vec::new();
58    diff_recursive(old, new, "", &mut results, opts);
59    Ok(results)
60}
61
62fn diff_files(
63    path1: &Path,
64    path2: &Path,
65    options: Option<&DiffOptions>,
66) -> Result<Vec<DiffResult>> {
67    // Read file contents
68    let content1 = fs::read_to_string(path1)?;
69    let content2 = fs::read_to_string(path2)?;
70
71    // Detect formats based on file extensions
72    let format1 = detect_format_from_path(path1);
73    let format2 = detect_format_from_path(path2);
74
75    // Parse content based on detected formats
76    let value1 = parse_content_by_format(&content1, format1)?;
77    let value2 = parse_content_by_format(&content2, format2)?;
78
79    // Use existing diff implementation
80    diff(&value1, &value2, options)
81}
82
83fn diff_directories(
84    dir1: &Path,
85    dir2: &Path,
86    options: Option<&DiffOptions>,
87) -> Result<Vec<DiffResult>> {
88    let mut results = Vec::new();
89
90    // Get all files in both directories recursively
91    let files1 = get_all_files_recursive(dir1)?;
92    let files2 = get_all_files_recursive(dir2)?;
93
94    // Create maps for easier lookup (relative path -> absolute path)
95    let files1_map: HashMap<String, &Path> = files1
96        .iter()
97        .filter_map(|path| {
98            path.strip_prefix(dir1)
99                .ok()
100                .map(|rel| (rel.to_string_lossy().to_string(), path.as_path()))
101        })
102        .collect();
103
104    let files2_map: HashMap<String, &Path> = files2
105        .iter()
106        .filter_map(|path| {
107            path.strip_prefix(dir2)
108                .ok()
109                .map(|rel| (rel.to_string_lossy().to_string(), path.as_path()))
110        })
111        .collect();
112
113    // Find files that exist in dir1 but not in dir2 (removed)
114    for (rel_path, abs_path1) in &files1_map {
115        if !files2_map.contains_key(rel_path) {
116            let content = fs::read_to_string(abs_path1).unwrap_or_default();
117            if let Ok(value) = parse_content_by_format(&content, detect_format_from_path(abs_path1))
118            {
119                results.push(DiffResult::Removed(rel_path.clone(), value));
120            }
121        }
122    }
123
124    // Find files that exist in dir2 but not in dir1 (added)
125    for (rel_path, abs_path2) in &files2_map {
126        if !files1_map.contains_key(rel_path) {
127            let content = fs::read_to_string(abs_path2).unwrap_or_default();
128            if let Ok(value) = parse_content_by_format(&content, detect_format_from_path(abs_path2))
129            {
130                results.push(DiffResult::Added(rel_path.clone(), value));
131            }
132        }
133    }
134
135    // Find files that exist in both directories (compare contents)
136    for (rel_path, abs_path1) in &files1_map {
137        if let Some(abs_path2) = files2_map.get(rel_path) {
138            match diff_files(abs_path1, abs_path2, options) {
139                Ok(mut file_results) => {
140                    // Prefix all paths with the relative path
141                    for result in &mut file_results {
142                        match result {
143                            DiffResult::Added(path, _) => *path = format!("{rel_path}/{path}"),
144                            DiffResult::Removed(path, _) => *path = format!("{rel_path}/{path}"),
145                            DiffResult::Modified(path, _, _) => {
146                                *path = format!("{rel_path}/{path}")
147                            }
148                            DiffResult::TypeChanged(path, _, _) => {
149                                *path = format!("{rel_path}/{path}")
150                            }
151                        }
152                    }
153                    results.extend(file_results);
154                }
155                Err(_) => {
156                    // If file comparison fails, skip this file
157                    continue;
158                }
159            }
160        }
161    }
162
163    Ok(results)
164}
165
166// ============================================================================
167// TESTS
168// ============================================================================
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::fs;
174
175    #[test]
176    fn test_diff_paths_files() {
177        // Test file vs file comparison
178        let temp_dir = std::env::temp_dir();
179        let file1_path = temp_dir.join("diffx_test1.json");
180        let file2_path = temp_dir.join("diffx_test2.json");
181
182        fs::write(&file1_path, r#"{"name": "test", "value": 1}"#).unwrap();
183        fs::write(&file2_path, r#"{"name": "test", "value": 2}"#).unwrap();
184
185        let results = diff_paths(
186            &file1_path.to_string_lossy(),
187            &file2_path.to_string_lossy(),
188            None,
189        )
190        .unwrap();
191
192        assert_eq!(results.len(), 1);
193
194        // Cleanup
195        let _ = fs::remove_file(file1_path);
196        let _ = fs::remove_file(file2_path);
197    }
198
199    #[test]
200    fn test_diff_paths_file_vs_directory_error() {
201        let temp_dir = std::env::temp_dir();
202        let file_path = temp_dir.join("diffx_test_file.json");
203        let dir_path = temp_dir.join("diffx_test_dir");
204
205        fs::write(&file_path, r#"{"test": true}"#).unwrap();
206        fs::create_dir_all(&dir_path).unwrap();
207
208        let result = diff_paths(
209            &file_path.to_string_lossy(),
210            &dir_path.to_string_lossy(),
211            None,
212        );
213
214        assert!(result.is_err());
215        assert!(result
216            .unwrap_err()
217            .to_string()
218            .contains("Cannot compare file"));
219
220        // Cleanup
221        let _ = fs::remove_file(file_path);
222        let _ = fs::remove_dir_all(dir_path);
223    }
224}