1use std::fs;
4use std::path::{Path, PathBuf};
5
6use qualia_core_db::q42_volume::{
7 compact_volume_set, is_unified_volume, verify_volume_set_from_root, Q42InspectReport,
8 Q42Magnet, Q42VerifySetReport, Q42VolumeSetMagnets, VerifyLevel,
9};
10use serde::{Deserialize, Serialize};
11
12const SCAN_ROOTS: &[&str] = &["Index", "Chats", "wellfair", "runtime"];
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Q42VolumeListItem {
16 pub path: String,
17 pub relative: String,
18 pub display_name: String,
19 pub file_bytes: u64,
20 pub version: u16,
21 pub flags: u16,
22 pub flag_names: Vec<String>,
23 pub block_count: u64,
24 pub lexicon_entries: Option<u64>,
25 pub has_bidx: bool,
26 pub has_field_ranges: bool,
27 pub has_field_postings: bool,
28 pub is_volume_root: bool,
29 pub publication_class: String,
30 pub publication_transport: String,
31 pub may_public_magnet: bool,
32 pub open_error: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Q42VolumeWorkspace {
37 pub storage_path: String,
38 pub volumes: Vec<Q42VolumeListItem>,
39 pub total_bytes: u64,
40 pub volume_count: usize,
41 pub unreadable: usize,
42}
43
44#[derive(Debug, Clone, Serialize)]
45pub struct Q42MagnetResult {
46 pub root: Q42Magnet,
47 pub children: Vec<Q42Magnet>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Q42CompactResult {
52 pub source: String,
53 pub output: String,
54}
55
56fn storage_root() -> Result<PathBuf, String> {
57 let state = crate::state::APP_STATE
58 .get()
59 .ok_or_else(|| "App state is not initialised".to_string())?;
60 let path = state
61 .config
62 .lock()
63 .map_err(|e| format!("config lock poisoned: {e}"))?
64 .storage_path
65 .clone();
66 Ok(PathBuf::from(path))
67}
68
69pub fn list_q42_volumes() -> Result<Q42VolumeWorkspace, String> {
70 list_q42_volumes_under(&storage_root()?)
71}
72
73pub fn list_q42_volumes_under(storage: &Path) -> Result<Q42VolumeWorkspace, String> {
74 let mut volumes = Vec::new();
75 if storage.is_dir() {
76 scan_dir(storage, storage, 0, &mut volumes)?;
77 for name in SCAN_ROOTS {
78 let child = storage.join(name);
79 if child.is_dir() {
80 scan_dir(&child, storage, 0, &mut volumes)?;
81 }
82 }
83 }
84 volumes.sort_by(|a, b| a.relative.cmp(&b.relative));
85 volumes.dedup_by(|a, b| a.path == b.path);
86 let total_bytes = volumes.iter().map(|v| v.file_bytes).sum();
87 let unreadable = volumes.iter().filter(|v| v.open_error.is_some()).count();
88 Ok(Q42VolumeWorkspace {
89 storage_path: storage.display().to_string(),
90 volume_count: volumes.len(),
91 unreadable,
92 total_bytes,
93 volumes,
94 })
95}
96
97fn scan_dir(
98 dir: &Path,
99 storage: &Path,
100 depth: usize,
101 out: &mut Vec<Q42VolumeListItem>,
102) -> Result<(), String> {
103 if depth > 6 {
104 return Ok(());
105 }
106 let entries = match fs::read_dir(dir) {
107 Ok(entries) => entries,
108 Err(_) => return Ok(()),
109 };
110 for entry in entries.filter_map(Result::ok) {
111 let path = entry.path();
112 if path.is_dir() {
113 if depth == 0 && dir == storage {
114 continue;
115 }
116 scan_dir(&path, storage, depth + 1, out)?;
117 continue;
118 }
119 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
120 continue;
121 };
122 if !name.ends_with(".q42") || name.ends_with(".c.q42") {
123 continue;
124 }
125 out.push(item_from_path(&path, storage));
126 }
127 Ok(())
128}
129
130fn item_from_path(path: &Path, storage: &Path) -> Q42VolumeListItem {
131 let display_name = path
132 .file_name()
133 .and_then(|n| n.to_str())
134 .unwrap_or("volume.q42")
135 .to_string();
136 let relative = path
137 .strip_prefix(storage)
138 .map(|p| p.to_string_lossy().replace('\\', "/"))
139 .unwrap_or_else(|_| path.display().to_string());
140 let file_bytes = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
141 match Q42InspectReport::from_path(path) {
142 Ok(report) => Q42VolumeListItem {
143 path: path.display().to_string(),
144 relative,
145 display_name,
146 file_bytes: report.file_bytes,
147 version: report.version,
148 flags: report.flags,
149 flag_names: report.flag_names.iter().map(|s| (*s).to_string()).collect(),
150 block_count: report.block_count,
151 lexicon_entries: report.lexicon_entries,
152 has_bidx: report.has_bidx,
153 has_field_ranges: report.has_field_ranges,
154 has_field_postings: report.has_field_postings,
155 is_volume_root: report.is_volume_root,
156 publication_class: report.publication_class,
157 publication_transport: report.publication_transport,
158 may_public_magnet: report.may_public_magnet,
159 open_error: None,
160 },
161 Err(err) => Q42VolumeListItem {
162 path: path.display().to_string(),
163 relative,
164 display_name,
165 file_bytes,
166 version: 0,
167 flags: 0,
168 flag_names: Vec::new(),
169 block_count: 0,
170 lexicon_entries: None,
171 has_bidx: false,
172 has_field_ranges: false,
173 has_field_postings: false,
174 is_volume_root: false,
175 publication_class: "unreadable".into(),
176 publication_transport: String::new(),
177 may_public_magnet: false,
178 open_error: Some(err.to_string()),
179 },
180 }
181}
182
183pub fn inspect_q42_volume(path: String) -> Result<Q42InspectReport, String> {
184 let path = require_q42_file(&path)?;
185 Q42InspectReport::from_path(&path).map_err(|e| e.to_string())
186}
187
188pub fn verify_q42_volume(path: String, level: Option<String>) -> Result<Q42VerifySetReport, String> {
189 let path = require_q42_file(&path)?;
190 let level = match level.as_deref() {
191 None | Some("") => VerifyLevel::Full,
192 Some(raw) => VerifyLevel::parse(raw).map_err(|e| e.to_string())?,
193 };
194 verify_volume_set_from_root(&path, level).map_err(|e| e.to_string())
195}
196
197pub fn magnet_q42_volume(path: String) -> Result<Q42MagnetResult, String> {
198 let path = require_q42_file(&path)?;
199 let webseed = Some("http://127.0.0.1:4242/torrent/webseed/{hash}");
200 if is_unified_volume(&path).ok() == Some(true) {
201 if let Ok(set) = Q42VolumeSetMagnets::for_root(&path, webseed) {
202 return Ok(Q42MagnetResult {
203 root: set.root,
204 children: set.children,
205 });
206 }
207 }
208 let root = Q42Magnet::for_path(&path, webseed).map_err(|e| e.to_string())?;
209 Ok(Q42MagnetResult {
210 root,
211 children: Vec::new(),
212 })
213}
214
215pub fn compact_q42_volume(path: String) -> Result<Q42CompactResult, String> {
216 let path = require_q42_file(&path)?;
217 let stem = path
218 .file_stem()
219 .and_then(|s| s.to_str())
220 .unwrap_or("volume");
221 let out_dir = path
222 .parent()
223 .unwrap_or_else(|| Path::new("."))
224 .join(format!("{stem}-compacted"));
225 let output = compact_volume_set(&path, &out_dir).map_err(|e| e.to_string())?;
226 Ok(Q42CompactResult {
227 source: path.display().to_string(),
228 output: output.display().to_string(),
229 })
230}
231
232fn require_q42_file(raw: &str) -> Result<PathBuf, String> {
233 let path = PathBuf::from(raw.trim());
234 if !path.is_file() {
235 return Err(format!("Q42 file not found: {}", path.display()));
236 }
237 let name = path
238 .file_name()
239 .and_then(|n| n.to_str())
240 .unwrap_or_default();
241 if !name.ends_with(".q42") {
242 return Err("path must be a .q42 volume".into());
243 }
244 Ok(path)
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use qualia_core_db::q42_volume::write_sorted_quins_volume;
251 use qualia_core_db::NQuin;
252
253 fn quin(object: u64) -> NQuin {
254 NQuin {
255 subject: 1,
256 predicate: 2,
257 object,
258 context: 3,
259 metadata: 0,
260 parity: NQuin::calculate_parity(1, 2, object, 3, 0),
261 }
262 }
263
264 #[test]
265 fn list_inspect_verify_and_compact_a_vault_volume() {
266 let dir = tempfile::tempdir().unwrap();
267 let index = dir.path().join("Index");
268 fs::create_dir_all(&index).unwrap();
269 let path = index.join("session.q42");
270 write_sorted_quins_volume(&path, &[quin(9), quin(1)]).unwrap();
271
272 let workspace = list_q42_volumes_under(dir.path()).unwrap();
273 assert_eq!(workspace.volume_count, 1);
274 assert_eq!(workspace.volumes[0].display_name, "session.q42");
275 assert_eq!(workspace.volumes[0].block_count, 1);
276 assert!(workspace.volumes[0].has_field_postings);
277 assert!(workspace.volumes[0].open_error.is_none());
278
279 let inspect = inspect_q42_volume(path.display().to_string()).unwrap();
280 assert_eq!(inspect.version, 3);
281 assert_eq!(inspect.block_count, 1);
282
283 let verify = verify_q42_volume(path.display().to_string(), Some("full".into())).unwrap();
284 assert_eq!(verify.members.len(), 1);
285 assert_ne!(
286 verify.overall,
287 qualia_core_db::q42_volume::CheckStatus::Fail,
288 "{}",
289 verify.to_text()
290 );
291 assert!(verify.members[0].checks.iter().any(|check| {
292 check.name == "blocks.decode"
293 && check.status == qualia_core_db::q42_volume::CheckStatus::Pass
294 }));
295
296 let compacted = compact_q42_volume(path.display().to_string()).unwrap();
297 assert!(PathBuf::from(&compacted.output).is_file());
298 let again = verify_q42_volume(compacted.output, None).unwrap();
299 assert_ne!(
300 again.overall,
301 qualia_core_db::q42_volume::CheckStatus::Fail,
302 "{}",
303 again.to_text()
304 );
305 }
306
307 #[test]
308 fn unmarked_personal_volume_cannot_mint_a_public_magnet() {
309 let dir = tempfile::tempdir().unwrap();
310 let path = dir.path().join("chat.q42");
311 write_sorted_quins_volume(&path, &[quin(4)]).unwrap();
312 let err = magnet_q42_volume(path.display().to_string()).unwrap_err();
313 assert!(
314 err.to_ascii_lowercase().contains("permissive commons")
315 || err.to_ascii_lowercase().contains("denied")
316 || err.to_ascii_lowercase().contains("unmarked"),
317 "deny text was: {err}"
318 );
319 }
320}