Skip to main content

qualia_client_core/api/
q42_volumes.rs

1//! List, inspect, verify, magnet, and compact unified Q42 volumes in the vault.
2
3use 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}