1use crate::error::Result;
2use crate::namespace::Namespace;
3use crate::note::Note;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8#[derive(Debug, Serialize, Deserialize, Clone)]
9pub struct RefValidation {
10 pub namespace: String,
11 pub ref_path: String,
12 pub exists: bool,
13 pub commit_sha: Option<String>,
14 pub note_count: usize,
15 pub corrupt_blobs: usize,
16 pub orphan_notes: usize,
17 pub unparseable_notes: usize,
18 pub issues: Vec<String>,
19}
20
21#[derive(Debug, Serialize, Deserialize, Clone)]
22pub struct ValidationReport {
23 pub repo_root: String,
24 pub total_refs: usize,
25 pub total_notes: usize,
26 pub total_corrupt: usize,
27 pub total_orphans: usize,
28 pub healthy: bool,
29 pub ref_reports: Vec<RefValidation>,
30}
31
32pub struct DataValidator {
33 pub repo_path: PathBuf,
34}
35
36impl DataValidator {
37 pub fn new<P: AsRef<Path>>(repo_path: P) -> Self {
38 Self {
39 repo_path: repo_path.as_ref().to_path_buf(),
40 }
41 }
42
43 fn git_cmd(&self) -> Command {
44 let mut cmd = Command::new("git");
45 cmd.current_dir(&self.repo_path);
46 cmd
47 }
48
49 pub fn commit_exists(&self, sha: &str) -> bool {
51 if sha.trim().is_empty() {
52 return false;
53 }
54 let status = self.git_cmd()
55 .args(["cat-file", "-e", &format!("{}^{{commit}}", sha.trim())])
56 .output();
57 match status {
58 Ok(out) => out.status.success(),
59 Err(_) => false,
60 }
61 }
62
63 pub fn validate_all(&self, namespaces: &[Namespace]) -> Result<ValidationReport> {
65 let mut ref_reports = Vec::new();
66 let mut total_notes = 0;
67 let mut total_corrupt = 0;
68 let mut total_orphans = 0;
69
70 for ns in namespaces {
71 let ref_path = ns.ref_path();
72 let mut report = RefValidation {
73 namespace: ns.to_string(),
74 ref_path: ref_path.clone(),
75 exists: false,
76 commit_sha: None,
77 note_count: 0,
78 corrupt_blobs: 0,
79 orphan_notes: 0,
80 unparseable_notes: 0,
81 issues: Vec::new(),
82 };
83
84 let rev_parse = self.git_cmd().args(["rev-parse", "--verify", &ref_path]).output()?;
86 if !rev_parse.status.success() {
87 ref_reports.push(report);
89 continue;
90 }
91
92 report.exists = true;
93 let ref_commit = String::from_utf8_lossy(&rev_parse.stdout).trim().to_string();
94 report.commit_sha = Some(ref_commit);
95
96 let ls_tree = self.git_cmd().args(["ls-tree", "-r", &ref_path]).output()?;
98 if !ls_tree.status.success() {
99 report.issues.push(format!("Corrupt git tree for ref: {}", ref_path));
100 report.corrupt_blobs += 1;
101 ref_reports.push(report);
102 continue;
103 }
104
105 let tree_out = String::from_utf8_lossy(&ls_tree.stdout);
106 let mut blob_entries = Vec::new(); for line in tree_out.lines() {
108 let parts: Vec<&str> = line.split_whitespace().collect();
109 if parts.len() >= 4 {
110 blob_entries.push((parts[2].to_string(), parts[3].to_string()));
111 }
112 }
113
114 report.note_count = blob_entries.len();
115 total_notes += blob_entries.len();
116
117 for (blob_sha, entry_path) in blob_entries {
119 let cat_file = self.git_cmd().args(["cat-file", "-p", &blob_sha]).output()?;
120 if !cat_file.status.success() {
121 report.corrupt_blobs += 1;
122 report.issues.push(format!("Blob {} at '{}' is unreadable / corrupt", blob_sha, entry_path));
123 continue;
124 }
125
126 let content = cat_file.stdout;
127 match serde_json::from_slice::<Note>(&content) {
128 Ok(note) => {
129 if !self.commit_exists(¬e.commit) {
131 report.orphan_notes += 1;
132 report.issues.push(format!(
133 "Note {} anchors to unreachable/missing commit {}",
134 note.id,
135 note.commit
136 ));
137 }
138 }
139 Err(err) => {
140 report.unparseable_notes += 1;
141 report.issues.push(format!(
142 "Invalid note JSON schema at blob {} ('{}'): {}",
143 blob_sha, entry_path, err
144 ));
145 }
146 }
147 }
148
149 total_corrupt += report.corrupt_blobs + report.unparseable_notes;
150 total_orphans += report.orphan_notes;
151 ref_reports.push(report);
152 }
153
154 let repo_root_out = self.git_cmd().args(["rev-parse", "--show-toplevel"]).output()?;
155 let repo_root = String::from_utf8_lossy(&repo_root_out.stdout).trim().to_string();
156
157 let healthy = total_corrupt == 0;
158
159 Ok(ValidationReport {
160 repo_root,
161 total_refs: namespaces.len(),
162 total_notes,
163 total_corrupt,
164 total_orphans,
165 healthy,
166 ref_reports,
167 })
168 }
169}