use crate::graph::{GraphDiff, KnowledgeGraphBuilder};
use crate::repository::{GitReader, GitRef, parse_directory};
use super::DiffOptions;
#[derive(Debug, thiserror::Error)]
pub enum DiffError {
#[error("Failed to parse repository {path}: {reason}")]
ParseError { path: String, reason: String },
#[error("Failed to build graph: {0}")]
GraphBuildError(String),
#[error(
"Git reference comparison not fully implemented. Only current state comparison is available."
)]
GitRefNotSupported,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug)]
pub struct DiffResult {
pub diff: GraphDiff,
pub ref1: String,
pub ref2: String,
pub is_full_comparison: bool,
}
impl DiffResult {
pub fn is_empty(&self) -> bool {
self.diff.is_empty()
}
}
#[derive(Debug, Default)]
pub struct DiffService;
impl DiffService {
pub fn new() -> Self {
Self
}
pub fn diff(&self, opts: &DiffOptions) -> Result<DiffResult, DiffError> {
if let Some(result) = self.try_git_diff(opts)? {
return Ok(result);
}
self.diff_working_directory(opts)
}
fn try_git_diff(&self, opts: &DiffOptions) -> Result<Option<DiffResult>, DiffError> {
if opts.repositories.is_empty() {
return Ok(None);
}
let repo_path = &opts.repositories[0];
let git_reader = match GitReader::discover(repo_path) {
Ok(reader) => reader,
Err(_) => return Ok(None), };
let git_ref1 = GitRef::parse(&opts.ref1);
let git_ref2 = GitRef::parse(&opts.ref2);
let items1 = git_reader
.parse_commit(&git_ref1)
.map_err(|e| DiffError::ParseError {
path: format!("{}@{}", repo_path.display(), opts.ref1),
reason: e.to_string(),
})?;
let items2 = git_reader
.parse_commit(&git_ref2)
.map_err(|e| DiffError::ParseError {
path: format!("{}@{}", repo_path.display(), opts.ref2),
reason: e.to_string(),
})?;
let graph1 = KnowledgeGraphBuilder::new()
.add_items(items1)
.build()
.map_err(|e| DiffError::GraphBuildError(e.to_string()))?;
let graph2 = KnowledgeGraphBuilder::new()
.add_items(items2)
.build()
.map_err(|e| DiffError::GraphBuildError(e.to_string()))?;
let diff = GraphDiff::compute(&graph1, &graph2);
Ok(Some(DiffResult {
diff,
ref1: opts.ref1.clone(),
ref2: opts.ref2.clone(),
is_full_comparison: true,
}))
}
fn diff_working_directory(&self, opts: &DiffOptions) -> Result<DiffResult, DiffError> {
let items = self.parse_repositories(&opts.repositories)?;
let graph1 = KnowledgeGraphBuilder::new()
.add_items(items.clone())
.build()
.map_err(|e| DiffError::GraphBuildError(e.to_string()))?;
let graph2 = KnowledgeGraphBuilder::new()
.add_items(items)
.build()
.map_err(|e| DiffError::GraphBuildError(e.to_string()))?;
let diff = GraphDiff::compute(&graph1, &graph2);
Ok(DiffResult {
diff,
ref1: opts.ref1.clone(),
ref2: opts.ref2.clone(),
is_full_comparison: false,
})
}
pub fn diff_graphs(
&self,
old_graph: &crate::graph::KnowledgeGraph,
new_graph: &crate::graph::KnowledgeGraph,
ref1: impl Into<String>,
ref2: impl Into<String>,
) -> DiffResult {
let diff = GraphDiff::compute(old_graph, new_graph);
DiffResult {
diff,
ref1: ref1.into(),
ref2: ref2.into(),
is_full_comparison: true,
}
}
fn parse_repositories(
&self,
repositories: &[std::path::PathBuf],
) -> Result<Vec<crate::model::Item>, DiffError> {
let mut all_items = Vec::new();
for repo_path in repositories {
let items = parse_directory(repo_path).map_err(|e| DiffError::ParseError {
path: repo_path.display().to_string(),
reason: e.to_string(),
})?;
all_items.extend(items);
}
Ok(all_items)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn create_test_file(dir: &Path, name: &str, content: &str) {
fs::write(dir.join(name), content).unwrap();
}
#[test]
fn test_diff_empty_repositories_non_git() {
let temp_dir = TempDir::new().unwrap();
let opts = DiffOptions::new("HEAD~1", "HEAD")
.with_repositories(vec![temp_dir.path().to_path_buf()]);
let service = DiffService::new();
let result = service.diff(&opts).unwrap();
assert!(result.is_empty());
assert!(!result.is_full_comparison);
}
#[test]
fn test_diff_with_items_non_git() {
let temp_dir = TempDir::new().unwrap();
create_test_file(
temp_dir.path(),
"solution.md",
r#"---
id: "SOL-001"
type: solution
name: "Test Solution"
---
# Solution
"#,
);
let opts = DiffOptions::new("main", "feature")
.with_repositories(vec![temp_dir.path().to_path_buf()]);
let service = DiffService::new();
let result = service.diff(&opts).unwrap();
assert!(result.is_empty());
assert!(!result.is_full_comparison);
assert_eq!(result.ref1, "main");
assert_eq!(result.ref2, "feature");
}
#[test]
fn test_diff_options_builder() {
let opts = DiffOptions::new("HEAD~1", "HEAD")
.add_repository("/path/to/repo1".into())
.add_repository("/path/to/repo2".into());
assert_eq!(opts.ref1, "HEAD~1");
assert_eq!(opts.ref2, "HEAD");
assert_eq!(opts.repositories.len(), 2);
}
#[test]
fn test_diff_in_git_repo() {
let current_dir = std::env::current_dir().unwrap();
if !crate::repository::is_git_repo(¤t_dir) {
return;
}
let opts = DiffOptions::new("HEAD", "HEAD").with_repositories(vec![current_dir]);
let service = DiffService::new();
let result = service.diff(&opts).unwrap();
assert!(result.is_empty());
assert!(result.is_full_comparison);
}
}