1use 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#[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
35pub type VcsResult<T> = Result<T, VcsError>;
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Commit {
41 pub id: String,
43 pub author: String,
45 pub message: String,
47 pub timestamp: chrono::DateTime<chrono::Utc>,
49 pub parents: Vec<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct StatuteRepository {
56 pub path: PathBuf,
58 pub current_branch: String,
60 pub commits: HashMap<String, Commit>,
62 pub statutes: HashMap<(String, String), Statute>,
64}
65
66impl StatuteRepository {
67 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 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 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 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 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 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 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 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 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 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
162pub mod git {
164 use super::*;
165
166 #[derive(Debug, Clone, Serialize, Deserialize)]
168 pub struct GitHookConfig {
169 pub pre_commit: bool,
171 pub post_commit: bool,
173 pub report_format: ReportFormat,
175 }
176
177 #[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 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 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 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 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 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}