sara_core/diff/
service.rs

1//! Diff service implementation.
2
3use crate::graph::{GraphBuilder, GraphDiff};
4use crate::repository::{GitReader, GitRef, parse_directory};
5
6use super::DiffOptions;
7
8/// Errors that can occur during diff operations.
9#[derive(Debug, thiserror::Error)]
10pub enum DiffError {
11    /// Failed to parse repository.
12    #[error("Failed to parse repository {path}: {reason}")]
13    ParseError { path: String, reason: String },
14
15    /// Failed to build graph.
16    #[error("Failed to build graph: {0}")]
17    GraphBuildError(String),
18
19    /// Git reference not supported yet.
20    #[error(
21        "Git reference comparison not fully implemented. Only current state comparison is available."
22    )]
23    GitRefNotSupported,
24
25    /// IO error.
26    #[error("IO error: {0}")]
27    Io(#[from] std::io::Error),
28}
29
30/// Result of a diff operation.
31#[derive(Debug)]
32pub struct DiffResult {
33    /// The computed diff.
34    pub diff: GraphDiff,
35    /// The first reference used.
36    pub ref1: String,
37    /// The second reference used.
38    pub ref2: String,
39    /// Whether this was a full Git ref comparison or a workaround.
40    pub is_full_comparison: bool,
41}
42
43impl DiffResult {
44    /// Returns true if there are no changes.
45    pub fn is_empty(&self) -> bool {
46        self.diff.is_empty()
47    }
48}
49
50/// Service for computing diffs between knowledge graph states.
51#[derive(Debug, Default)]
52pub struct DiffService;
53
54impl DiffService {
55    /// Creates a new diff service.
56    pub fn new() -> Self {
57        Self
58    }
59
60    /// Computes the diff between two references.
61    ///
62    /// This method supports full Git reference comparison when the repository
63    /// paths are Git repositories. It will parse the knowledge graph at each
64    /// reference point and compute the differences.
65    pub fn diff(&self, opts: &DiffOptions) -> Result<DiffResult, DiffError> {
66        // Try Git-based comparison first
67        if let Some(result) = self.try_git_diff(opts)? {
68            return Ok(result);
69        }
70
71        // Fall back to current working directory comparison
72        self.diff_working_directory(opts)
73    }
74
75    /// Attempts Git-based diff comparison.
76    /// Returns None if Git comparison is not possible (e.g., not a Git repo).
77    fn try_git_diff(&self, opts: &DiffOptions) -> Result<Option<DiffResult>, DiffError> {
78        // We need at least one repository path
79        if opts.repositories.is_empty() {
80            return Ok(None);
81        }
82
83        // Try to open a Git reader for the first repository
84        let repo_path = &opts.repositories[0];
85        let git_reader = match GitReader::discover(repo_path) {
86            Ok(reader) => reader,
87            Err(_) => return Ok(None), // Not a Git repo, fall back
88        };
89
90        // Parse Git references
91        let git_ref1 = GitRef::parse(&opts.ref1);
92        let git_ref2 = GitRef::parse(&opts.ref2);
93
94        // Parse items at each reference
95        let items1 = git_reader
96            .parse_commit(&git_ref1)
97            .map_err(|e| DiffError::ParseError {
98                path: format!("{}@{}", repo_path.display(), opts.ref1),
99                reason: e.to_string(),
100            })?;
101
102        let items2 = git_reader
103            .parse_commit(&git_ref2)
104            .map_err(|e| DiffError::ParseError {
105                path: format!("{}@{}", repo_path.display(), opts.ref2),
106                reason: e.to_string(),
107            })?;
108
109        // Build graphs from each reference
110        let graph1 = GraphBuilder::new()
111            .add_items(items1)
112            .build()
113            .map_err(|e| DiffError::GraphBuildError(e.to_string()))?;
114
115        let graph2 = GraphBuilder::new()
116            .add_items(items2)
117            .build()
118            .map_err(|e| DiffError::GraphBuildError(e.to_string()))?;
119
120        // Compute diff
121        let diff = GraphDiff::compute(&graph1, &graph2);
122
123        Ok(Some(DiffResult {
124            diff,
125            ref1: opts.ref1.clone(),
126            ref2: opts.ref2.clone(),
127            is_full_comparison: true,
128        }))
129    }
130
131    /// Falls back to comparing current working directory state with itself.
132    fn diff_working_directory(&self, opts: &DiffOptions) -> Result<DiffResult, DiffError> {
133        let items = self.parse_repositories(&opts.repositories)?;
134
135        let graph1 = GraphBuilder::new()
136            .add_items(items.clone())
137            .build()
138            .map_err(|e| DiffError::GraphBuildError(e.to_string()))?;
139
140        let graph2 = GraphBuilder::new()
141            .add_items(items)
142            .build()
143            .map_err(|e| DiffError::GraphBuildError(e.to_string()))?;
144
145        let diff = GraphDiff::compute(&graph1, &graph2);
146
147        Ok(DiffResult {
148            diff,
149            ref1: opts.ref1.clone(),
150            ref2: opts.ref2.clone(),
151            is_full_comparison: false,
152        })
153    }
154
155    /// Computes the diff between two existing graphs.
156    ///
157    /// Use this method when you already have the graphs loaded.
158    pub fn diff_graphs(
159        &self,
160        old_graph: &crate::graph::KnowledgeGraph,
161        new_graph: &crate::graph::KnowledgeGraph,
162        ref1: impl Into<String>,
163        ref2: impl Into<String>,
164    ) -> DiffResult {
165        let diff = GraphDiff::compute(old_graph, new_graph);
166        DiffResult {
167            diff,
168            ref1: ref1.into(),
169            ref2: ref2.into(),
170            is_full_comparison: true,
171        }
172    }
173
174    /// Parses items from all repository paths.
175    fn parse_repositories(
176        &self,
177        repositories: &[std::path::PathBuf],
178    ) -> Result<Vec<crate::model::Item>, DiffError> {
179        let mut all_items = Vec::new();
180
181        for repo_path in repositories {
182            let items = parse_directory(repo_path).map_err(|e| DiffError::ParseError {
183                path: repo_path.display().to_string(),
184                reason: e.to_string(),
185            })?;
186            all_items.extend(items);
187        }
188
189        Ok(all_items)
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::fs;
197    use std::path::Path;
198    use tempfile::TempDir;
199
200    fn create_test_file(dir: &Path, name: &str, content: &str) {
201        fs::write(dir.join(name), content).unwrap();
202    }
203
204    #[test]
205    fn test_diff_empty_repositories_non_git() {
206        let temp_dir = TempDir::new().unwrap();
207
208        let opts = DiffOptions::new("HEAD~1", "HEAD")
209            .with_repositories(vec![temp_dir.path().to_path_buf()]);
210
211        let service = DiffService::new();
212        let result = service.diff(&opts).unwrap();
213
214        // Non-git directory falls back to working directory comparison
215        assert!(result.is_empty());
216        assert!(!result.is_full_comparison);
217    }
218
219    #[test]
220    fn test_diff_with_items_non_git() {
221        let temp_dir = TempDir::new().unwrap();
222
223        create_test_file(
224            temp_dir.path(),
225            "solution.md",
226            r#"---
227id: "SOL-001"
228type: solution
229name: "Test Solution"
230---
231# Solution
232"#,
233        );
234
235        let opts = DiffOptions::new("main", "feature")
236            .with_repositories(vec![temp_dir.path().to_path_buf()]);
237
238        let service = DiffService::new();
239        let result = service.diff(&opts).unwrap();
240
241        // Non-git: falls back to comparing current state with itself
242        assert!(result.is_empty());
243        assert!(!result.is_full_comparison);
244        assert_eq!(result.ref1, "main");
245        assert_eq!(result.ref2, "feature");
246    }
247
248    #[test]
249    fn test_diff_options_builder() {
250        let opts = DiffOptions::new("HEAD~1", "HEAD")
251            .add_repository("/path/to/repo1".into())
252            .add_repository("/path/to/repo2".into());
253
254        assert_eq!(opts.ref1, "HEAD~1");
255        assert_eq!(opts.ref2, "HEAD");
256        assert_eq!(opts.repositories.len(), 2);
257    }
258
259    #[test]
260    fn test_diff_in_git_repo() {
261        // Use the current repository for testing Git comparison
262        let current_dir = std::env::current_dir().unwrap();
263
264        // Only run this test if we're in a git repo
265        if !crate::repository::is_git_repo(&current_dir) {
266            return;
267        }
268
269        let opts = DiffOptions::new("HEAD", "HEAD").with_repositories(vec![current_dir]);
270
271        let service = DiffService::new();
272        let result = service.diff(&opts).unwrap();
273
274        // Comparing HEAD to HEAD should produce no changes
275        assert!(result.is_empty());
276        // Should be a full Git comparison
277        assert!(result.is_full_comparison);
278    }
279}