Skip to main content

legalis_diff/
vcs.rs

1//! Version control system integration for statute diffs.
2//!
3//! This module provides hooks and integration with version control systems
4//! like Git to track statute changes over time.
5
6use crate::{StatuteDiff, VersionInfo, diff};
7use legalis_core::Statute;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12
13/// Errors that can occur during VCS operations.
14#[derive(Debug, Error)]
15pub enum VcsError {
16    #[error("Repository not found at path: {0}")]
17    RepositoryNotFound(PathBuf),
18
19    #[error("Invalid commit reference: {0}")]
20    InvalidCommit(String),
21
22    #[error("Statute not found in repository: {0}")]
23    StatuteNotFound(String),
24
25    #[error("I/O error: {0}")]
26    Io(#[from] std::io::Error),
27
28    #[error("Serialization error: {0}")]
29    Serialization(#[from] serde_json::Error),
30
31    #[error("Diff error: {0}")]
32    Diff(#[from] crate::DiffError),
33}
34
35/// Result type for VCS operations.
36pub type VcsResult<T> = Result<T, VcsError>;
37
38/// Represents a commit in the version control system.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Commit {
41    /// Commit hash or identifier.
42    pub id: String,
43    /// Author of the commit.
44    pub author: String,
45    /// Commit message.
46    pub message: String,
47    /// Timestamp of the commit.
48    pub timestamp: chrono::DateTime<chrono::Utc>,
49    /// Parent commit IDs.
50    pub parents: Vec<String>,
51}
52
53/// A statute repository that tracks changes over time.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct StatuteRepository {
56    /// Path to the repository.
57    pub path: PathBuf,
58    /// Current branch name.
59    pub current_branch: String,
60    /// Commits indexed by ID.
61    pub commits: HashMap<String, Commit>,
62    /// Statutes indexed by (commit_id, statute_id).
63    pub statutes: HashMap<(String, String), Statute>,
64}
65
66impl StatuteRepository {
67    /// Creates a new statute repository.
68    pub fn new<P: AsRef<Path>>(path: P) -> Self {
69        Self {
70            path: path.as_ref().to_path_buf(),
71            current_branch: "main".to_string(),
72            commits: HashMap::new(),
73            statutes: HashMap::new(),
74        }
75    }
76
77    /// Initializes a repository from a directory.
78    pub fn init<P: AsRef<Path>>(path: P) -> VcsResult<Self> {
79        let repo_path = path.as_ref().to_path_buf();
80
81        if !repo_path.exists() {
82            return Err(VcsError::RepositoryNotFound(repo_path));
83        }
84
85        Ok(Self::new(repo_path))
86    }
87
88    /// Adds a commit to the repository.
89    pub fn add_commit(&mut self, commit: Commit, statutes: Vec<Statute>) {
90        let commit_id = commit.id.clone();
91
92        for statute in statutes {
93            self.statutes
94                .insert((commit_id.clone(), statute.id.clone()), statute);
95        }
96
97        self.commits.insert(commit_id, commit);
98    }
99
100    /// Gets a statute at a specific commit.
101    pub fn get_statute(&self, commit_id: &str, statute_id: &str) -> VcsResult<&Statute> {
102        self.statutes
103            .get(&(commit_id.to_string(), statute_id.to_string()))
104            .ok_or_else(|| VcsError::StatuteNotFound(statute_id.to_string()))
105    }
106
107    /// Compares a statute between two commits.
108    pub fn diff_commits(
109        &self,
110        old_commit: &str,
111        new_commit: &str,
112        statute_id: &str,
113    ) -> VcsResult<StatuteDiff> {
114        let old_statute = self.get_statute(old_commit, statute_id)?;
115        let new_statute = self.get_statute(new_commit, statute_id)?;
116
117        let mut diff_result = diff(old_statute, new_statute)?;
118
119        // Add version info based on commits
120        diff_result.version_info = Some(VersionInfo {
121            old_version: Some(self.get_commit_number(old_commit)),
122            new_version: Some(self.get_commit_number(new_commit)),
123        });
124
125        Ok(diff_result)
126    }
127
128    /// Gets all commits affecting a statute.
129    pub fn get_statute_history(&self, statute_id: &str) -> Vec<String> {
130        self.statutes
131            .keys()
132            .filter(|(_, sid)| sid == statute_id)
133            .map(|(cid, _)| cid.clone())
134            .collect()
135    }
136
137    /// Gets a commit by ID.
138    pub fn get_commit(&self, commit_id: &str) -> VcsResult<&Commit> {
139        self.commits
140            .get(commit_id)
141            .ok_or_else(|| VcsError::InvalidCommit(commit_id.to_string()))
142    }
143
144    /// Lists all commits in chronological order.
145    pub fn list_commits(&self) -> Vec<&Commit> {
146        let mut commits: Vec<&Commit> = self.commits.values().collect();
147        commits.sort_by_key(|c| c.timestamp);
148        commits
149    }
150
151    /// Gets the commit number (index in chronological order).
152    fn get_commit_number(&self, commit_id: &str) -> u32 {
153        let commits = self.list_commits();
154        commits
155            .iter()
156            .position(|c| c.id == commit_id)
157            .map(|pos| pos as u32 + 1)
158            .unwrap_or(0)
159    }
160}
161
162/// Git-specific integration helpers.
163pub mod git {
164    use super::*;
165
166    /// Configuration for Git hooks.
167    #[derive(Debug, Clone, Serialize, Deserialize)]
168    pub struct GitHookConfig {
169        /// Whether to run diff on pre-commit.
170        pub pre_commit: bool,
171        /// Whether to generate reports on post-commit.
172        pub post_commit: bool,
173        /// Output format for hook reports.
174        pub report_format: ReportFormat,
175    }
176
177    /// Report format for Git hooks.
178    #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
179    pub enum ReportFormat {
180        Json,
181        Markdown,
182        Html,
183        Unified,
184    }
185
186    impl Default for GitHookConfig {
187        fn default() -> Self {
188            Self {
189                pre_commit: true,
190                post_commit: true,
191                report_format: ReportFormat::Markdown,
192            }
193        }
194    }
195
196    /// Generates a Git hook script for pre-commit.
197    pub fn generate_pre_commit_hook(config: &GitHookConfig) -> String {
198        let mut script = String::from("#!/bin/sh\n\n");
199        script.push_str("# Legalis statute diff pre-commit hook\n");
200        script.push_str("# Auto-generated - DO NOT EDIT\n\n");
201
202        if config.pre_commit {
203            script.push_str("echo \"Running statute diff check...\"\n\n");
204            script.push_str("# Get list of changed statute files\n");
205            script.push_str("CHANGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\\.statute\\.json$')\n\n");
206            script.push_str("if [ -z \"$CHANGED_FILES\" ]; then\n");
207            script.push_str("    echo \"No statute files changed.\"\n");
208            script.push_str("    exit 0\n");
209            script.push_str("fi\n\n");
210            script.push_str("# Run diff for each changed file\n");
211            script.push_str("for file in $CHANGED_FILES; do\n");
212            script.push_str("    echo \"Checking $file...\"\n");
213            script.push_str("    # Add your diff logic here\n");
214            script.push_str("    # legalis-diff \"$file\" || exit 1\n");
215            script.push_str("done\n\n");
216            script.push_str("echo \"Statute diff check completed.\"\n");
217        }
218
219        script.push_str("exit 0\n");
220        script
221    }
222
223    /// Generates a Git hook script for post-commit.
224    pub fn generate_post_commit_hook(config: &GitHookConfig) -> String {
225        let mut script = String::from("#!/bin/sh\n\n");
226        script.push_str("# Legalis statute diff post-commit hook\n");
227        script.push_str("# Auto-generated - DO NOT EDIT\n\n");
228
229        if config.post_commit {
230            script.push_str("echo \"Generating statute diff reports...\"\n\n");
231
232            let format_flag = match config.report_format {
233                ReportFormat::Json => "--format=json",
234                ReportFormat::Markdown => "--format=markdown",
235                ReportFormat::Html => "--format=html",
236                ReportFormat::Unified => "--format=unified",
237            };
238
239            script.push_str(&format!("# Format: {}\n", format_flag));
240            script.push_str("# Add your report generation logic here\n");
241            script.push_str(&format!(
242                "# legalis-diff-report {} HEAD~1 HEAD\n",
243                format_flag
244            ));
245            script.push_str("\necho \"Statute diff reports generated.\"\n");
246        }
247
248        script.push_str("exit 0\n");
249        script
250    }
251
252    /// Installs Git hooks into a repository.
253    pub fn install_hooks<P: AsRef<Path>>(repo_path: P, config: &GitHookConfig) -> VcsResult<()> {
254        let hooks_dir = repo_path.as_ref().join(".git").join("hooks");
255
256        if !hooks_dir.exists() {
257            std::fs::create_dir_all(&hooks_dir)?;
258        }
259
260        // Install pre-commit hook
261        let pre_commit_path = hooks_dir.join("pre-commit");
262        std::fs::write(&pre_commit_path, generate_pre_commit_hook(config))?;
263        #[cfg(unix)]
264        {
265            use std::os::unix::fs::PermissionsExt;
266            let mut perms = std::fs::metadata(&pre_commit_path)?.permissions();
267            perms.set_mode(0o755);
268            std::fs::set_permissions(&pre_commit_path, perms)?;
269        }
270
271        // Install post-commit hook
272        let post_commit_path = hooks_dir.join("post-commit");
273        std::fs::write(&post_commit_path, generate_post_commit_hook(config))?;
274        #[cfg(unix)]
275        {
276            use std::os::unix::fs::PermissionsExt;
277            let mut perms = std::fs::metadata(&post_commit_path)?.permissions();
278            perms.set_mode(0o755);
279            std::fs::set_permissions(&post_commit_path, perms)?;
280        }
281
282        Ok(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use legalis_core::{ComparisonOp, Condition, Effect, EffectType};
290
291    fn test_statute() -> Statute {
292        Statute::new(
293            "test-statute",
294            "Test Statute",
295            Effect::new(EffectType::Grant, "Test benefit"),
296        )
297        .with_precondition(Condition::Age {
298            operator: ComparisonOp::GreaterOrEqual,
299            value: 18,
300        })
301    }
302
303    fn test_commit(id: &str, message: &str) -> Commit {
304        Commit {
305            id: id.to_string(),
306            author: "Test Author".to_string(),
307            message: message.to_string(),
308            timestamp: chrono::Utc::now(),
309            parents: Vec::new(),
310        }
311    }
312
313    #[test]
314    fn test_repository_creation() {
315        let repo = StatuteRepository::new("/tmp/test-repo");
316        assert_eq!(repo.current_branch, "main");
317        assert!(repo.commits.is_empty());
318        assert!(repo.statutes.is_empty());
319    }
320
321    #[test]
322    fn test_add_commit() {
323        let mut repo = StatuteRepository::new("/tmp/test-repo");
324        let commit = test_commit("commit1", "Initial commit");
325        let statute = test_statute();
326
327        repo.add_commit(commit.clone(), vec![statute.clone()]);
328
329        assert_eq!(repo.commits.len(), 1);
330        assert_eq!(repo.statutes.len(), 1);
331        assert!(repo.get_statute("commit1", "test-statute").is_ok());
332    }
333
334    #[test]
335    fn test_diff_commits() {
336        let mut repo = StatuteRepository::new("/tmp/test-repo");
337
338        let statute_v1 = test_statute();
339        let mut statute_v2 = statute_v1.clone();
340        statute_v2.title = "Updated Test Statute".to_string();
341
342        repo.add_commit(test_commit("commit1", "v1"), vec![statute_v1]);
343        repo.add_commit(test_commit("commit2", "v2"), vec![statute_v2]);
344
345        let diff_result = repo.diff_commits("commit1", "commit2", "test-statute");
346        assert!(diff_result.is_ok());
347
348        let diff = diff_result.unwrap();
349        assert!(!diff.changes.is_empty());
350        assert!(diff.version_info.is_some());
351    }
352
353    #[test]
354    fn test_statute_history() {
355        let mut repo = StatuteRepository::new("/tmp/test-repo");
356        let statute = test_statute();
357
358        repo.add_commit(test_commit("commit1", "v1"), vec![statute.clone()]);
359        repo.add_commit(test_commit("commit2", "v2"), vec![statute.clone()]);
360
361        let history = repo.get_statute_history("test-statute");
362        assert_eq!(history.len(), 2);
363    }
364
365    #[test]
366    fn test_git_hook_generation() {
367        let config = git::GitHookConfig::default();
368
369        let pre_commit = git::generate_pre_commit_hook(&config);
370        assert!(pre_commit.contains("#!/bin/sh"));
371        assert!(pre_commit.contains("pre-commit"));
372
373        let post_commit = git::generate_post_commit_hook(&config);
374        assert!(post_commit.contains("#!/bin/sh"));
375        assert!(post_commit.contains("post-commit"));
376    }
377}