1pub mod redact;
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::event::parser;
9use crate::shard::{ShardError, ShardManager, ShardManifest};
10
11#[derive(Debug, thiserror::Error)]
13pub enum VerifyError {
14 #[error("shard error: {0}")]
16 Shard(#[from] ShardError),
17
18 #[error("I/O error: {0}")]
20 Io(#[from] std::io::Error),
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ShardCheck {
26 pub shard_name: String,
28 pub status: ShardCheckStatus,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ShardCheckStatus {
35 Verified,
37 Regenerated,
39 Failed(String),
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct VerifyReport {
46 pub shards: Vec<ShardCheck>,
48 pub active_shard_parse_ok: bool,
50}
51
52impl VerifyReport {
53 #[must_use]
55 pub fn is_ok(&self) -> bool {
56 self.active_shard_parse_ok
57 && self
58 .shards
59 .iter()
60 .all(|s| !matches!(s.status, ShardCheckStatus::Failed(_)))
61 }
62}
63
64pub fn verify_repository(
79 bones_dir: &Path,
80 regenerate_missing: bool,
81) -> Result<VerifyReport, VerifyError> {
82 let mgr = ShardManager::new(bones_dir);
83 let shards = mgr.list_shards()?;
84
85 if shards.is_empty() {
86 return Ok(VerifyReport {
87 shards: Vec::new(),
88 active_shard_parse_ok: true,
89 });
90 }
91
92 let active = shards.last().copied();
93 let mut checks = Vec::new();
94
95 for (year, month) in shards.iter().copied() {
96 if Some((year, month)) == active {
97 continue;
98 }
99
100 let shard_name = ShardManager::shard_filename(year, month);
101 let computed = compute_manifest(&mgr, year, month)?;
102
103 match mgr.read_manifest(year, month)? {
104 Some(existing) => {
105 if existing == computed {
106 checks.push(ShardCheck {
107 shard_name,
108 status: ShardCheckStatus::Verified,
109 });
110 } else {
111 checks.push(ShardCheck {
112 shard_name,
113 status: ShardCheckStatus::Failed("manifest mismatch".to_string()),
114 });
115 }
116 }
117 None if regenerate_missing => {
118 let _ = mgr.write_manifest(year, month)?;
119 checks.push(ShardCheck {
120 shard_name,
121 status: ShardCheckStatus::Regenerated,
122 });
123 }
124 None => {
125 checks.push(ShardCheck {
126 shard_name,
127 status: ShardCheckStatus::Failed("missing manifest".to_string()),
128 });
129 }
130 }
131 }
132
133 let active_shard_parse_ok = if let Some((year, month)) = active {
134 let content = mgr.read_shard(year, month)?;
135 parser::parse_lines(&content).is_ok()
136 } else {
137 true
138 };
139
140 Ok(VerifyReport {
141 shards: checks,
142 active_shard_parse_ok,
143 })
144}
145
146fn compute_manifest(
147 mgr: &ShardManager,
148 year: i32,
149 month: u32,
150) -> Result<ShardManifest, VerifyError> {
151 let path: PathBuf = mgr.shard_path(year, month);
152 let content = fs::read(&path)?;
153 let content_str = String::from_utf8_lossy(&content);
154 let event_count = content_str
155 .lines()
156 .filter(|line| !line.trim().is_empty() && !line.starts_with('#'))
157 .count() as u64;
158
159 Ok(ShardManifest {
160 shard_name: ShardManager::shard_filename(year, month),
161 event_count,
162 byte_len: content.len() as u64,
163 file_hash: format!("blake3:{}", blake3::hash(&content).to_hex()),
164 })
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use tempfile::TempDir;
171
172 #[test]
173 fn verify_regenerates_missing_manifest_for_sealed_shard() {
174 let tmp = TempDir::new().expect("tmp");
175 let bones = tmp.path().join(".bones");
176 let mgr = ShardManager::new(&bones);
177 mgr.ensure_dirs().expect("dirs");
178
179 mgr.create_shard(2025, 1).expect("old shard");
180 mgr.append_raw(2025, 1, "e1\n").expect("append");
181
182 mgr.create_shard(2030, 1).expect("new shard");
184
185 let report = verify_repository(&bones, true).expect("verify");
186
187 assert!(report.active_shard_parse_ok);
188 assert!(
189 report
190 .shards
191 .iter()
192 .any(|s| matches!(s.status, ShardCheckStatus::Regenerated))
193 );
194 assert!(mgr.manifest_path(2025, 1).exists());
195 }
196}