Skip to main content

gn_core/
validate.rs

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    /// Check if a commit SHA exists in the Git DAG
50    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    /// Run full integrity audit across all refs/notes/*
64    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            // 1. Check if ref exists and get commit sha
85            let rev_parse = self.git_cmd().args(["rev-parse", "--verify", &ref_path]).output()?;
86            if !rev_parse.status.success() {
87                // Ref does not exist yet (clean state or not initialized)
88                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            // 2. Read git tree blobs
97            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(); // (blob_sha, path)
107            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            // 3. Inspect each blob: content-addressed integrity & JSON schema
118            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                        // Check commit anchor existence
130                        if !self.commit_exists(&note.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}