sara_core/diff/
service.rs1use crate::graph::{GraphBuilder, GraphDiff};
4use crate::repository::{GitReader, GitRef, parse_directory};
5
6use super::DiffOptions;
7
8#[derive(Debug, thiserror::Error)]
10pub enum DiffError {
11 #[error("Failed to parse repository {path}: {reason}")]
13 ParseError { path: String, reason: String },
14
15 #[error("Failed to build graph: {0}")]
17 GraphBuildError(String),
18
19 #[error(
21 "Git reference comparison not fully implemented. Only current state comparison is available."
22 )]
23 GitRefNotSupported,
24
25 #[error("IO error: {0}")]
27 Io(#[from] std::io::Error),
28}
29
30#[derive(Debug)]
32pub struct DiffResult {
33 pub diff: GraphDiff,
35 pub ref1: String,
37 pub ref2: String,
39 pub is_full_comparison: bool,
41}
42
43impl DiffResult {
44 pub fn is_empty(&self) -> bool {
46 self.diff.is_empty()
47 }
48}
49
50#[derive(Debug, Default)]
52pub struct DiffService;
53
54impl DiffService {
55 pub fn new() -> Self {
57 Self
58 }
59
60 pub fn diff(&self, opts: &DiffOptions) -> Result<DiffResult, DiffError> {
66 if let Some(result) = self.try_git_diff(opts)? {
68 return Ok(result);
69 }
70
71 self.diff_working_directory(opts)
73 }
74
75 fn try_git_diff(&self, opts: &DiffOptions) -> Result<Option<DiffResult>, DiffError> {
78 if opts.repositories.is_empty() {
80 return Ok(None);
81 }
82
83 let repo_path = &opts.repositories[0];
85 let git_reader = match GitReader::discover(repo_path) {
86 Ok(reader) => reader,
87 Err(_) => return Ok(None), };
89
90 let git_ref1 = GitRef::parse(&opts.ref1);
92 let git_ref2 = GitRef::parse(&opts.ref2);
93
94 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 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 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 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 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 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 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 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 let current_dir = std::env::current_dir().unwrap();
263
264 if !crate::repository::is_git_repo(¤t_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 assert!(result.is_empty());
276 assert!(result.is_full_comparison);
278 }
279}