1use std::io;
4use std::path::Path;
5
6use serde::Serialize;
7
8use super::super::{
9 Q42Volume, Q42VolumeHeader, FLAG_BLOCKS_LZ4, FLAG_FIELD_POSTINGS, FLAG_FIELD_RANGES,
10 FLAG_OBJECT_SORTED, FLAG_PERMISSIVE_COMMONS, FLAG_SANCTUARY, FLAG_VOLUME_ROOT, HEADER_SIZE,
11};
12use super::publication::{classify_q42_volume, PublicationIntent, Q42PublicationVerdict};
13#[derive(Clone, Debug, Serialize)]
15pub struct Q42SectionReport {
16 pub name: String,
17 pub offset: u64,
18 pub length: u64,
19}
20
21#[derive(Clone, Debug, Serialize)]
24pub struct Q42InspectReport {
25 pub path: String,
26 pub file_bytes: u64,
27 pub version: u16,
28 pub flags: u16,
29 pub flag_names: Vec<&'static str>,
30 pub block_count: u64,
31 pub block_size: u32,
32 pub quins_per_block: u32,
33 pub lexicon_bytes: u64,
34 pub lexicon_entries: Option<u64>,
35 pub lexicon_has_no_terms: bool,
38 pub has_bidx: bool,
39 pub has_field_ranges: bool,
40 pub has_field_postings: bool,
41 pub is_volume_root: bool,
42 pub publication_class: String,
43 pub publication_transport: String,
44 pub may_public_magnet: bool,
45 pub publication_reason: String,
46 pub sections: Vec<Q42SectionReport>,
47 pub honesty: Vec<String>,
48}
49
50impl Q42InspectReport {
51 pub fn from_path(path: &Path) -> io::Result<Self> {
52 let file_bytes = std::fs::metadata(path)?.len();
53 let volume = Q42Volume::open(path)?;
54 Ok(Self::from_volume(path, file_bytes, &volume))
55 }
56
57 pub fn from_volume(path: &Path, file_bytes: u64, volume: &Q42Volume) -> Self {
58 let header = volume.header();
59 let flags = header.flags;
60 let lex_length = header.lex_length;
61 let version = header.version;
62 let block_count = header.block_count;
63 let block_size = header.block_size;
64 let quins_per_block = header.quins_per_block;
65 let lexicon_entries = volume.lex_view().ok().map(|lex| lex.entry_count() as u64);
66 let lexicon_has_no_terms = lexicon_entries == Some(0) || lex_length == 0;
67 let mut honesty = Vec::new();
68 let shared_lex_shards = volume
69 .volume_manifest()
70 .ok()
71 .flatten()
72 .map(|manifest| manifest.lexicon_segments.len() as u64)
73 .unwrap_or(0);
74 if lexicon_has_no_terms && block_count > 0 && shared_lex_shards == 0 {
75 honesty.push(
76 "This file has a valid Q42LEX with 0 terms. If it is a volume-set child, terms live in the root's lexicon shards; otherwise this artifact was written hashed-only."
77 .into(),
78 );
79 }
80 if shared_lex_shards > 0 {
81 honesty.push(format!(
82 "Volume-set lexicon is sharded across {shared_lex_shards} child .q42 files named by this root. Local Q42LEX being empty here is expected."
83 ));
84 }
85 if flags & FLAG_FIELD_POSTINGS == 0 && block_count > 0 {
86 honesty.push(
87 "This file has no PIDX section. Newer writes include compact S/P/C postings; this artifact can be rewritten to add them."
88 .into(),
89 );
90 }
91 if flags & FLAG_VOLUME_ROOT != 0 {
92 honesty.push(
93 "Volume root: the catalog is here; graph SuperBlocks are in the child .q42 segments it names."
94 .into(),
95 );
96 }
97 if flags & FLAG_OBJECT_SORTED == 0 && block_count > 0 {
98 honesty.push(
99 "This file does not declare object sort order, so BIDX range pruning must not be trusted for it."
100 .into(),
101 );
102 }
103 let publication = classify_q42_volume(volume, PublicationIntent::Default);
104 push_publication_notes(&mut honesty, &publication);
105
106 Self {
107 path: path.display().to_string(),
108 file_bytes,
109 version,
110 flags,
111 flag_names: decode_flags(flags),
112 block_count,
113 block_size,
114 quins_per_block,
115 lexicon_bytes: lex_length,
116 lexicon_entries,
117 lexicon_has_no_terms,
118 has_bidx: header.bidx_length > 0,
119 has_field_ranges: flags & FLAG_FIELD_RANGES != 0,
120 has_field_postings: flags & FLAG_FIELD_POSTINGS != 0,
121 is_volume_root: flags & FLAG_VOLUME_ROOT != 0,
122 publication_class: publication.class.as_str().into(),
123 publication_transport: publication.transport.as_str().into(),
124 may_public_magnet: publication.may_emit_public_magnet,
125 publication_reason: publication.reason,
126 sections: collect_sections(header),
127 honesty,
128 }
129 }
130
131 pub fn to_text(&self) -> String {
132 let mut out = String::new();
133 out.push_str(&format!("Q42 {}\n", self.path));
134 out.push_str(&format!(
135 " file {} bytes\n",
136 self.file_bytes
137 ));
138 out.push_str(&format!(
139 " version {} flags 0x{:04x} ({})\n",
140 self.version,
141 self.flags,
142 self.flag_names.join(" | ")
143 ));
144 out.push_str(&format!(
145 " blocks {} × {} bytes ({} Quins/block capacity)\n",
146 self.block_count, self.block_size, self.quins_per_block
147 ));
148 out.push_str(&format!(
149 " lexicon {} bytes, {} entries{}\n",
150 self.lexicon_bytes,
151 self.lexicon_entries
152 .map(|n| n.to_string())
153 .unwrap_or_else(|| "?".into()),
154 if self.lexicon_has_no_terms {
155 " [0 terms in this file]"
156 } else {
157 ""
158 }
159 ));
160 out.push_str(&format!(
161 " indexes BIDX={} FIDX={} PIDX={} root={}\n",
162 yn(self.has_bidx),
163 yn(self.has_field_ranges),
164 yn(self.has_field_postings),
165 yn(self.is_volume_root)
166 ));
167 out.push_str(&format!(
168 " publication {}\n",
169 self.publication_class
170 ));
171 out.push_str(&format!(
172 " transport {}\n",
173 self.publication_transport
174 ));
175 out.push_str(&format!(
176 " public magnet {}\n",
177 yn(self.may_public_magnet)
178 ));
179 if !self.publication_reason.is_empty() {
180 out.push_str(&format!(" publish note {}\n", self.publication_reason));
181 }
182 out.push_str(" sections\n");
183 for section in &self.sections {
184 out.push_str(&format!(
185 " {:<18} {:>12} + {}\n",
186 section.name, section.offset, section.length
187 ));
188 }
189 if !self.honesty.is_empty() {
190 out.push_str(" honesty\n");
191 for note in &self.honesty {
192 out.push_str(&format!(" - {note}\n"));
193 }
194 }
195 out
196 }
197}
198
199fn yn(value: bool) -> &'static str {
200 if value {
201 "yes"
202 } else {
203 "no"
204 }
205}
206
207fn push_publication_notes(honesty: &mut Vec<String>, publication: &Q42PublicationVerdict) {
208 match publication.class {
209 super::publication::Q42PublicationClass::UnmarkedLocal => honesty.push(
210 "Unmarked volume: no public magnet unless a human marks it as a Permissive Commons catalog (--commons or FLAG_PERMISSIVE_COMMONS). Personal and medical records stay local / SocialWebNet."
211 .into(),
212 ),
213 super::publication::Q42PublicationClass::Sanctuary => honesty.push(
214 "Sanctuary volume: public magnet, HTTP web-seed, and IPFS are denied. Transport is local or SocialWebNet (pairwise DID)."
215 .into(),
216 ),
217 super::publication::Q42PublicationClass::MixedFailClosed => honesty.push(
218 "Mixed volume: Commons and Selfhood Quins share one file. Split before any public hash is emitted."
219 .into(),
220 ),
221 super::publication::Q42PublicationClass::PermissiveCommons
222 | super::publication::Q42PublicationClass::CommonsGated => honesty.push(
223 "Permissive Commons transport (hash-addressed). This is not open-data: Selfhood stays out, and consume-side TrustGroup / billing gates still apply."
224 .into(),
225 ),
226 }
227}
228
229fn decode_flags(flags: u16) -> Vec<&'static str> {
230 let mut names = Vec::new();
231 if flags & FLAG_BLOCKS_LZ4 != 0 {
232 names.push("lz4");
233 }
234 if flags & FLAG_OBJECT_SORTED != 0 {
235 names.push("object-sorted");
236 }
237 if flags & FLAG_VOLUME_ROOT != 0 {
238 names.push("volume-root");
239 }
240 if flags & FLAG_FIELD_RANGES != 0 {
241 names.push("field-ranges");
242 }
243 if flags & FLAG_FIELD_POSTINGS != 0 {
244 names.push("field-postings");
245 }
246 if flags & FLAG_PERMISSIVE_COMMONS != 0 {
247 names.push("permissive-commons");
248 }
249 if flags & FLAG_SANCTUARY != 0 {
250 names.push("sanctuary");
251 }
252 let known = FLAG_BLOCKS_LZ4
253 | FLAG_OBJECT_SORTED
254 | FLAG_VOLUME_ROOT
255 | FLAG_FIELD_RANGES
256 | FLAG_FIELD_POSTINGS
257 | FLAG_PERMISSIVE_COMMONS
258 | FLAG_SANCTUARY;
259 if flags & !known != 0 {
260 names.push("unknown");
261 }
262 if names.is_empty() {
263 names.push("none");
264 }
265 names
266}
267
268fn collect_sections(header: &Q42VolumeHeader) -> Vec<Q42SectionReport> {
269 let mut sections = vec![Q42SectionReport {
270 name: "header".into(),
271 offset: 0,
272 length: HEADER_SIZE as u64,
273 }];
274 let mut push = |name: &str, offset: u64, length: u64| {
275 if length > 0 {
276 sections.push(Q42SectionReport {
277 name: name.into(),
278 offset,
279 length,
280 });
281 }
282 };
283 push("lexicon", header.lex_offset, header.lex_length);
284 if let Some((offset, length)) = header.volume_manifest_range() {
285 push("volume-manifest", offset, length);
286 }
287 push("bidx", header.bidx_offset, header.bidx_length);
288 if let Some((offset, length)) = header.field_range_index_range() {
289 push("field-ranges", offset, length);
290 }
291 if let Some((offset, length)) = header.field_postings_range() {
292 push("field-postings", offset, length);
293 }
294 push(
295 "block-directory",
296 header.block_dir_offset,
297 header.block_dir_length,
298 );
299 push("block-data", header.data_offset, header.data_length);
300 push(
301 "temporal-index",
302 header.temporal_index_offset,
303 header.temporal_index_length,
304 );
305 push("merkle-dag", header.dag_root_offset, header.dag_root_length);
306 sections
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::q42_volume::{write_unified_volume, StreamingQ42VolumeWriter};
313 use crate::NQuin;
314 use std::collections::HashMap;
315
316 #[test]
317 fn inspect_names_empty_lex_and_streaming_postings() {
318 let file = tempfile::NamedTempFile::new().unwrap();
319 write_unified_volume(
320 file.path(),
321 &HashMap::new(),
322 &[(3, 3)],
323 &[vec![NQuin {
324 subject: 1,
325 predicate: 2,
326 object: 3,
327 context: 0,
328 metadata: 0,
329 parity: 0,
330 }]],
331 )
332 .unwrap();
333 let report = Q42InspectReport::from_path(file.path()).unwrap();
334 assert!(report.lexicon_has_no_terms);
335 assert!(report.honesty.iter().any(|n| n.contains("0 terms")));
336 assert_eq!(report.publication_class, "unmarked-local");
337 assert!(!report.may_public_magnet);
338
339 let streamed = tempfile::NamedTempFile::new().unwrap();
340 let mut lex = HashMap::new();
341 lex.insert(1, "s".into());
342 lex.insert(2, "p".into());
343 lex.insert(3, "o".into());
344 let mut writer = StreamingQ42VolumeWriter::new(&lex).unwrap();
345 writer
346 .push_block(
347 0,
348 &[NQuin {
349 subject: 1,
350 predicate: 2,
351 object: 3,
352 context: 0,
353 metadata: 0,
354 parity: 0,
355 }],
356 )
357 .unwrap();
358 writer.finish(streamed.path()).unwrap();
359 let good = Q42InspectReport::from_path(streamed.path()).unwrap();
360 assert!(good.has_field_postings);
361 assert!(!good.lexicon_has_no_terms);
362 }
363}