Skip to main content

qualia_client_core/
chat_files.rs

1//! Session-scoped chat file attachments — PDF/text extraction, image sharing, vision ingest.
2
3use std::fs::{self, File, OpenOptions};
4use std::io::{BufRead, BufReader, Read, Write};
5use std::path::{Path, PathBuf};
6
7use qualia_core_db::{q_hash, wal::WriteAheadLog, NQuin};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::chat_session::{self, SessionKind};
12
13const OBJECT_HASH_MASK: u64 = 0x0FFF_FFFF_FFFF_FFFF;
14const MAX_EXTRACTED_CHARS: usize = 512_000;
15const PREVIEW_CHARS: usize = 400;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case")]
19pub enum FileVisibility {
20    OwnerOnly,
21    SessionParticipants,
22    SpecificDids,
23    PublicInSession,
24}
25
26impl FileVisibility {
27    pub fn as_str(&self) -> &'static str {
28        match self {
29            FileVisibility::OwnerOnly => "owner_only",
30            FileVisibility::SessionParticipants => "session_participants",
31            FileVisibility::SpecificDids => "specific_dids",
32            FileVisibility::PublicInSession => "public_in_session",
33        }
34    }
35
36    pub fn from_str(s: &str) -> Result<Self, String> {
37        match s {
38            "owner_only" => Ok(FileVisibility::OwnerOnly),
39            "session_participants" => Ok(FileVisibility::SessionParticipants),
40            "specific_dids" => Ok(FileVisibility::SpecificDids),
41            "public_in_session" => Ok(FileVisibility::PublicInSession),
42            _ => Err(format!("unknown file visibility: {s}")),
43        }
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub struct ChatFileSharing {
49    pub visibility: FileVisibility,
50    pub allow_download: bool,
51    pub allow_llm_context: bool,
52    pub allow_relay_sync: bool,
53    #[serde(default = "default_chat_file_sensitivity")]
54    pub sensitivity_level: u8,
55    #[serde(default)]
56    pub allowed_dids: Vec<String>,
57    #[serde(default)]
58    pub expires_at: Option<u64>,
59}
60
61impl Default for ChatFileSharing {
62    fn default() -> Self {
63        Self {
64            visibility: FileVisibility::SessionParticipants,
65            allow_download: true,
66            allow_llm_context: true,
67            allow_relay_sync: false,
68            sensitivity_level: NQuin::SENSITIVITY_RESTRICTED,
69            allowed_dids: vec![],
70            expires_at: None,
71        }
72    }
73}
74
75pub fn default_sharing_for_session(kind: SessionKind) -> ChatFileSharing {
76    match kind {
77        SessionKind::Solo => ChatFileSharing {
78            visibility: FileVisibility::OwnerOnly,
79            allow_download: true,
80            allow_llm_context: true,
81            allow_relay_sync: false,
82            sensitivity_level: NQuin::SENSITIVITY_CLASSIFIED,
83            allowed_dids: vec![],
84            expires_at: None,
85        },
86        SessionKind::Group => ChatFileSharing::default(),
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct PdfPageExtract {
92    pub page_index: u32,
93    pub text: String,
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
97#[serde(rename_all = "snake_case")]
98pub enum MediaKind {
99    #[default]
100    Document,
101    Image,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct ParsedDocument {
106    pub media_kind: MediaKind,
107    pub mime_type: String,
108    pub extension: String,
109    pub page_count: Option<u32>,
110    pub image_width: Option<u32>,
111    pub image_height: Option<u32>,
112    pub full_text: String,
113    pub pages: Vec<PdfPageExtract>,
114    pub parse_status: String,
115    pub parse_error: Option<String>,
116    #[serde(default)]
117    pub thumbnail_rel_path: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ChatFileRecord {
122    pub file_id: String,
123    pub original_name: String,
124    pub mime_type: String,
125    pub extension: String,
126    pub sha256: String,
127    pub byte_size: u64,
128    pub page_count: Option<u32>,
129    pub text_preview: String,
130    pub storage_rel_path: String,
131    pub text_rel_path: String,
132    pub author_did: String,
133    pub author_name: Option<String>,
134    pub message_lamport: Option<u64>,
135    pub attached_at: u64,
136    pub sharing: ChatFileSharing,
137    pub parse_status: String,
138    pub parse_error: Option<String>,
139    #[serde(default = "default_chat_file_sensitivity")]
140    pub sensitivity_level: u8,
141    #[serde(default)]
142    pub media_kind: MediaKind,
143    #[serde(default)]
144    pub image_width: Option<u32>,
145    #[serde(default)]
146    pub image_height: Option<u32>,
147    #[serde(default)]
148    pub thumbnail_rel_path: Option<String>,
149    #[serde(default)]
150    pub vision_lexicon_id: Option<String>,
151    #[serde(default)]
152    pub vision_facet: Option<String>,
153    #[serde(default)]
154    pub vision_status: Option<String>,
155}
156
157fn default_chat_file_sensitivity() -> u8 {
158    NQuin::SENSITIVITY_PUBLIC
159}
160
161fn infer_sensitivity_from_sharing(sharing: &ChatFileSharing) -> u8 {
162    match sharing.visibility {
163        FileVisibility::PublicInSession => NQuin::SENSITIVITY_PUBLIC,
164        FileVisibility::SessionParticipants | FileVisibility::SpecificDids => {
165            NQuin::SENSITIVITY_RESTRICTED
166        }
167        FileVisibility::OwnerOnly => NQuin::SENSITIVITY_CLASSIFIED,
168    }
169}
170
171fn clamp_sensitivity_level(level: u8) -> u8 {
172    level.min(NQuin::SENSITIVITY_CLASSIFIED)
173}
174
175fn effective_sensitivity_from_sharing(sharing: &ChatFileSharing) -> u8 {
176    let inferred = infer_sensitivity_from_sharing(sharing);
177    clamp_sensitivity_level(sharing.sensitivity_level).max(inferred)
178}
179
180fn normalize_chat_file_sensitivity(record: &mut ChatFileRecord) {
181    let effective = effective_sensitivity_from_sharing(&record.sharing);
182    record.sharing.sensitivity_level = effective;
183    if record.sensitivity_level > NQuin::SENSITIVITY_CLASSIFIED {
184        record.sensitivity_level = effective;
185        return;
186    }
187    record.sensitivity_level = clamp_sensitivity_level(record.sensitivity_level).max(effective);
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct AttachChatFileResult {
192    pub file: ChatFileRecord,
193    pub message_lamport: u64,
194}
195
196fn files_index_path(storage_root: &Path, session_id: &str) -> PathBuf {
197    chat_session::chats_dir(storage_root)
198        .join(session_id)
199        .join("files.jsonl")
200}
201
202fn files_dir(storage_root: &Path, session_id: &str) -> PathBuf {
203    chat_session::chats_dir(storage_root)
204        .join(session_id)
205        .join("files")
206}
207
208fn unix_now() -> u64 {
209    std::time::SystemTime::now()
210        .duration_since(std::time::UNIX_EPOCH)
211        .unwrap_or_default()
212        .as_secs()
213}
214
215fn sha256_hex(bytes: &[u8]) -> String {
216    let digest = Sha256::digest(bytes);
217    digest.iter().map(|b| format!("{b:02x}")).collect()
218}
219
220fn extension_of(name: &str) -> String {
221    Path::new(name)
222        .extension()
223        .and_then(|s| s.to_str())
224        .unwrap_or("")
225        .to_lowercase()
226}
227
228fn mime_for_extension(ext: &str) -> &'static str {
229    match ext {
230        "pdf" => "application/pdf",
231        "txt" => "text/plain",
232        "md" | "markdown" => "text/markdown",
233        "png" => "image/png",
234        "jpg" | "jpeg" => "image/jpeg",
235        "webp" => "image/webp",
236        "gif" => "image/gif",
237        _ => "application/octet-stream",
238    }
239}
240
241pub fn is_image_extension(ext: &str) -> bool {
242    matches!(ext, "png" | "jpg" | "jpeg" | "webp" | "gif")
243}
244
245pub fn is_image_record(record: &ChatFileRecord) -> bool {
246    record.media_kind == MediaKind::Image || is_image_extension(&record.extension)
247}
248
249pub fn parse_document_bytes(name: &str, bytes: &[u8]) -> ParsedDocument {
250    parse_document_bytes_with_dir(name, bytes, None)
251}
252
253pub fn parse_document_bytes_with_dir(
254    name: &str,
255    bytes: &[u8],
256    thumb_dir: Option<&Path>,
257) -> ParsedDocument {
258    let ext = extension_of(name);
259    let mime_type = mime_for_extension(&ext).to_string();
260
261    if is_image_extension(&ext) {
262        return parse_image_bytes(bytes, mime_type, ext, thumb_dir);
263    }
264
265    if ext == "pdf" {
266        return parse_pdf_bytes(bytes, mime_type, ext);
267    }
268
269    if ext == "txt" || ext == "md" || ext == "markdown" {
270        let text = String::from_utf8_lossy(bytes).into_owned();
271        let truncated = truncate_text(&text);
272        return ParsedDocument {
273            media_kind: MediaKind::Document,
274            mime_type,
275            extension: ext,
276            page_count: None,
277            image_width: None,
278            image_height: None,
279            full_text: truncated.clone(),
280            pages: vec![],
281            parse_status: "ok".to_string(),
282            parse_error: None,
283            thumbnail_rel_path: None,
284        };
285    }
286
287    let ext_copy = ext.clone();
288    ParsedDocument {
289        media_kind: MediaKind::Document,
290        mime_type,
291        extension: ext,
292        page_count: None,
293        image_width: None,
294        image_height: None,
295        full_text: String::new(),
296        pages: vec![],
297        parse_status: "unsupported".to_string(),
298        parse_error: Some(format!("Unsupported extension for chat file: {ext_copy}")),
299        thumbnail_rel_path: None,
300    }
301}
302
303fn parse_image_bytes(
304    bytes: &[u8],
305    mime_type: String,
306    ext: String,
307    thumb_dir: Option<&Path>,
308) -> ParsedDocument {
309    match image::load_from_memory(bytes) {
310        Ok(img) => {
311            let (w, h) = (img.width(), img.height());
312            let thumb_rel = None;
313            let _ = thumb_dir;
314            let facet = format!(
315                "image attachment {w}x{h} {mime_type} sha256:{}",
316                &sha256_hex(bytes)[..16]
317            );
318            let full_text = format!(
319                "[Image attachment]\nfilename_extension: {ext}\nmime: {mime_type}\ndimensions: {w}x{h}\nsha256: {}\nvision_facet: {facet}",
320                sha256_hex(bytes)
321            );
322            ParsedDocument {
323                media_kind: MediaKind::Image,
324                mime_type,
325                extension: ext,
326                page_count: None,
327                image_width: Some(w),
328                image_height: Some(h),
329                full_text,
330                pages: vec![],
331                parse_status: "ok".to_string(),
332                parse_error: None,
333                thumbnail_rel_path: thumb_rel,
334            }
335        }
336        Err(e) => ParsedDocument {
337            media_kind: MediaKind::Image,
338            mime_type,
339            extension: ext,
340            page_count: None,
341            image_width: None,
342            image_height: None,
343            full_text: String::new(),
344            pages: vec![],
345            parse_status: "failed".to_string(),
346            parse_error: Some(format!("Image decode failed: {e}")),
347            thumbnail_rel_path: None,
348        },
349    }
350}
351
352fn write_thumbnail(dir: &Path, file_id: &str, img: &image::DynamicImage) -> Option<String> {
353    let name = format!("{file_id}_thumb.jpg");
354    let path = dir.join(&name);
355    let thumb = img.thumbnail(320, 320);
356    thumb
357        .save_with_format(&path, image::ImageFormat::Jpeg)
358        .ok()?;
359    Some(format!("files/{name}"))
360}
361
362fn parse_pdf_bytes(bytes: &[u8], mime_type: String, ext: String) -> ParsedDocument {
363    match pdf_extract::extract_text_from_mem_by_pages(bytes) {
364        Ok(pages) => {
365            let page_count = pages.len() as u32;
366            let mut full = String::new();
367            let mut page_extracts = Vec::with_capacity(pages.len());
368            for (i, page_text) in pages.into_iter().enumerate() {
369                if !full.is_empty() {
370                    full.push_str("\n\n");
371                }
372                full.push_str(&format!("--- Page {} ---\n{}", i + 1, page_text));
373                page_extracts.push(PdfPageExtract {
374                    page_index: i as u32,
375                    text: page_text,
376                });
377            }
378            let truncated = truncate_text(&full);
379            let status = if truncated.trim().is_empty() {
380                "partial"
381            } else {
382                "ok"
383            };
384            ParsedDocument {
385                media_kind: MediaKind::Document,
386                mime_type,
387                extension: ext,
388                page_count: Some(page_count),
389                image_width: None,
390                image_height: None,
391                full_text: truncated,
392                pages: page_extracts,
393                parse_status: status.to_string(),
394                parse_error: if status == "partial" {
395                    Some("PDF parsed but no extractable text (scanned image PDF)".to_string())
396                } else {
397                    None
398                },
399                thumbnail_rel_path: None,
400            }
401        }
402        Err(e) => ParsedDocument {
403            media_kind: MediaKind::Document,
404            mime_type,
405            extension: ext,
406            page_count: None,
407            image_width: None,
408            image_height: None,
409            full_text: String::new(),
410            pages: vec![],
411            parse_status: "failed".to_string(),
412            parse_error: Some(format!("{e}")),
413            thumbnail_rel_path: None,
414        },
415    }
416}
417
418fn try_vision_bind(
419    storage_root: &Path,
420    source_path: &Path,
421) -> (Option<String>, Option<String>, String) {
422    let active = crate::api::load_active_model_record_from_disk();
423    match crate::vision_ingest::ingest_image_with_active_record(
424        storage_root,
425        active,
426        source_path,
427        "ChatShare",
428    ) {
429        Ok(result) => (
430            Some(result.lexicon_id),
431            Some(result.facet),
432            "ok".to_string(),
433        ),
434        Err(e) => (None, None, format!("skipped:{e}")),
435    }
436}
437
438fn truncate_text(text: &str) -> String {
439    if text.len() <= MAX_EXTRACTED_CHARS {
440        return text.to_string();
441    }
442    let mut end = MAX_EXTRACTED_CHARS;
443    while end > 0 && !text.is_char_boundary(end) {
444        end -= 1;
445    }
446    format!("{}…", &text[..end])
447}
448
449fn preview_text(text: &str) -> String {
450    let flat = text.replace('\n', " ");
451    if flat.chars().count() <= PREVIEW_CHARS {
452        return flat;
453    }
454    flat.chars().take(PREVIEW_CHARS).collect::<String>() + "…"
455}
456
457fn load_all_records(storage_root: &Path, session_id: &str) -> Result<Vec<ChatFileRecord>, String> {
458    let path = files_index_path(storage_root, session_id);
459    if !path.is_file() {
460        return Ok(vec![]);
461    }
462    let file = File::open(path).map_err(|e| e.to_string())?;
463    let reader = BufReader::new(file);
464    let mut out = Vec::new();
465    for line in reader.lines() {
466        let line = line.map_err(|e| e.to_string())?;
467        if line.trim().is_empty() {
468            continue;
469        }
470        let mut record: ChatFileRecord = serde_json::from_str(&line).map_err(|e| e.to_string())?;
471        normalize_chat_file_sensitivity(&mut record);
472        out.push(record);
473    }
474    Ok(out)
475}
476
477fn write_all_records(
478    storage_root: &Path,
479    session_id: &str,
480    records: &[ChatFileRecord],
481) -> Result<(), String> {
482    let path = files_index_path(storage_root, session_id);
483    if let Some(parent) = path.parent() {
484        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
485    }
486    let mut file = OpenOptions::new()
487        .create(true)
488        .write(true)
489        .truncate(true)
490        .open(path)
491        .map_err(|e| e.to_string())?;
492    for r in records {
493        writeln!(
494            file,
495            "{}",
496            serde_json::to_string(r).map_err(|e| e.to_string())?
497        )
498        .map_err(|e| e.to_string())?;
499    }
500    Ok(())
501}
502
503fn session_participant_dids(storage_root: &Path, session_id: &str) -> Vec<String> {
504    let Ok(session) = chat_session::load_session(storage_root, session_id) else {
505        return vec![];
506    };
507    session
508        .environment
509        .participants
510        .iter()
511        .map(|p| p.did.clone())
512        .collect()
513}
514
515pub fn can_view_file(
516    record: &ChatFileRecord,
517    viewer_did: &str,
518    participant_dids: &[String],
519) -> bool {
520    if record
521        .sharing
522        .expires_at
523        .is_some_and(|exp| unix_now() > exp)
524    {
525        return false;
526    }
527    if record.author_did == viewer_did {
528        return true;
529    }
530    match record.sharing.visibility {
531        FileVisibility::OwnerOnly => false,
532        FileVisibility::SessionParticipants | FileVisibility::PublicInSession => {
533            participant_dids.iter().any(|d| d == viewer_did) || participant_dids.is_empty()
534        }
535        FileVisibility::SpecificDids => record.sharing.allowed_dids.iter().any(|d| d == viewer_did),
536    }
537}
538
539pub fn can_use_in_llm_context(
540    record: &ChatFileRecord,
541    viewer_did: &str,
542    participants: &[String],
543) -> bool {
544    record.sharing.allow_llm_context && can_view_file(record, viewer_did, participants)
545}
546
547pub fn attach_chat_file(
548    storage_root: &Path,
549    session_id: &str,
550    source_path: &Path,
551    sharing: ChatFileSharing,
552) -> Result<AttachChatFileResult, String> {
553    if !source_path.is_file() {
554        return Err(format!("File not found: {}", source_path.display()));
555    }
556
557    let ext = extension_of(
558        source_path
559            .file_name()
560            .and_then(|s| s.to_str())
561            .unwrap_or(""),
562    );
563    if ext != "pdf" && ext != "txt" && ext != "md" && ext != "markdown" && !is_image_extension(&ext)
564    {
565        return Err(format!(
566            "Unsupported file type '.{ext}' — attach PDF, TXT, Markdown, or image (PNG/JPEG/WebP/GIF)"
567        ));
568    }
569
570    let mut bytes = Vec::new();
571    File::open(source_path)
572        .and_then(|mut f| f.read_to_end(&mut bytes))
573        .map_err(|e| e.to_string())?;
574
575    let sha = sha256_hex(&bytes);
576    let file_id = format!("{:016x}", q_hash(&format!("chatfile:{session_id}:{sha}")));
577    let original_name = source_path
578        .file_name()
579        .and_then(|s| s.to_str())
580        .unwrap_or("attachment")
581        .to_string();
582
583    let dir = files_dir(storage_root, session_id);
584    fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
585
586    let mut parsed = parse_document_bytes(&original_name, &bytes);
587    if parsed.media_kind == MediaKind::Image {
588        if let Ok(img) = image::load_from_memory(&bytes) {
589            parsed.thumbnail_rel_path = write_thumbnail(&dir, &file_id, &img);
590        }
591    }
592
593    let (vision_lexicon_id, vision_facet, vision_status) = if parsed.media_kind == MediaKind::Image
594    {
595        let (lex, facet, status) = try_vision_bind(storage_root, source_path);
596        if let Some(ref f) = facet {
597            parsed.full_text.push_str(&format!("\nvision_ingest: {f}"));
598        }
599        (lex, facet, status)
600    } else {
601        (None, None, String::new())
602    };
603
604    let bin_name = format!("{file_id}.bin");
605    let txt_name = format!("{file_id}.txt");
606    fs::write(dir.join(&bin_name), &bytes).map_err(|e| e.to_string())?;
607    fs::write(dir.join(&txt_name), &parsed.full_text).map_err(|e| e.to_string())?;
608
609    let profile = crate::user_profile::load_profile();
610    let author_name = profile
611        .sharing
612        .share_display_name
613        .then(|| profile.display_name.clone());
614
615    let text_preview = if parsed.media_kind == MediaKind::Image {
616        match (parsed.image_width, parsed.image_height) {
617            (Some(w), Some(h)) => format!("{w}×{h} {}", parsed.mime_type),
618            _ => parsed.mime_type.clone(),
619        }
620    } else {
621        preview_text(&parsed.full_text)
622    };
623
624    let mut sharing = sharing;
625    let sensitivity_level = effective_sensitivity_from_sharing(&sharing);
626    sharing.sensitivity_level = sensitivity_level;
627    let record = ChatFileRecord {
628        file_id: file_id.clone(),
629        original_name: original_name.clone(),
630        mime_type: parsed.mime_type,
631        extension: parsed.extension,
632        sha256: sha,
633        byte_size: bytes.len() as u64,
634        page_count: parsed.page_count,
635        text_preview,
636        storage_rel_path: format!("files/{bin_name}"),
637        text_rel_path: format!("files/{txt_name}"),
638        author_did: profile.public_did.clone(),
639        author_name,
640        message_lamport: None,
641        attached_at: unix_now(),
642        sharing,
643        parse_status: parsed.parse_status,
644        parse_error: parsed.parse_error,
645        sensitivity_level,
646        media_kind: parsed.media_kind,
647        image_width: parsed.image_width,
648        image_height: parsed.image_height,
649        thumbnail_rel_path: parsed.thumbnail_rel_path.clone(),
650        vision_lexicon_id: vision_lexicon_id.clone(),
651        vision_facet: vision_facet.clone(),
652        vision_status: if vision_status.is_empty() {
653            None
654        } else {
655            Some(vision_status)
656        },
657    };
658
659    let size_note = if record.media_kind == MediaKind::Image {
660        match (record.image_width, record.image_height) {
661            (Some(w), Some(h)) => format!(" ({w}×{h})"),
662            _ => String::new(),
663        }
664    } else {
665        record
666            .page_count
667            .map(|n| format!(" ({n} pages)"))
668            .unwrap_or_default()
669    };
670    let icon = if record.media_kind == MediaKind::Image {
671        "🖼️"
672    } else {
673        "📎"
674    };
675    let msg_content = format!(
676        "{icon} Attached {}: {original_name}{size_note} [{}]",
677        if record.media_kind == MediaKind::Image {
678            "image"
679        } else {
680            "file"
681        },
682        record.sharing.visibility.as_str()
683    );
684    let lamport = chat_session::append_message_with_author(
685        storage_root,
686        session_id,
687        chat_session::Role::User,
688        &msg_content,
689        None,
690        Some("chat_file".to_string()),
691        Some(profile.public_did.clone()),
692        record.author_name.clone(),
693        None,
694    )
695    .map_err(|e| e.to_string())?;
696
697    let mut record = record;
698    record.message_lamport = Some(lamport);
699
700    let mut records = load_all_records(storage_root, session_id)?;
701    records.push(record.clone());
702    write_all_records(storage_root, session_id, &records)?;
703
704    append_file_wal_quin(storage_root, session_id, &record)?;
705
706    Ok(AttachChatFileResult {
707        file: record,
708        message_lamport: lamport,
709    })
710}
711
712fn append_file_wal_quin(
713    storage_root: &Path,
714    session_id: &str,
715    record: &ChatFileRecord,
716) -> Result<(), String> {
717    let wal_path = chat_session::chats_dir(storage_root)
718        .join(session_id)
719        .join("chat.wal");
720    if !wal_path.is_file() {
721        return Ok(());
722    }
723    let subject = q_hash(&format!("chat:session:{session_id}"));
724    let predicate = q_hash("chat:hasFile");
725    let object = u64::from_str_radix(&record.file_id, 16).unwrap_or(0) & OBJECT_HASH_MASK;
726    let context = q_hash(&record.author_did);
727    let metadata = (record.byte_size.min(0x1FFF_FFFF)) << 32;
728    let parity = subject ^ predicate ^ object ^ context ^ metadata;
729    let mut quin = NQuin {
730        subject,
731        predicate,
732        object,
733        context,
734        metadata,
735        parity,
736    };
737    quin.set_sensitivity_byte(record.sensitivity_level);
738    if let Ok(mut wal) = WriteAheadLog::open(&wal_path) {
739        wal.append_mutation(&quin).map_err(|e| e.to_string())?;
740    }
741    Ok(())
742}
743
744pub fn set_chat_file_sharing(
745    storage_root: &Path,
746    session_id: &str,
747    file_id: &str,
748    sharing: ChatFileSharing,
749) -> Result<ChatFileRecord, String> {
750    let profile = crate::user_profile::load_profile();
751    let mut records = load_all_records(storage_root, session_id)?;
752    let idx = records
753        .iter()
754        .position(|r| r.file_id == file_id)
755        .ok_or_else(|| format!("Chat file not found: {file_id}"))?;
756    if records[idx].author_did != profile.public_did {
757        return Err("Only the file owner can change sharing permissions".to_string());
758    }
759    let mut sharing = sharing;
760    let sensitivity_level = effective_sensitivity_from_sharing(&sharing);
761    sharing.sensitivity_level = sensitivity_level;
762    records[idx].sharing = sharing;
763    records[idx].sensitivity_level = sensitivity_level;
764    let updated = records[idx].clone();
765    write_all_records(storage_root, session_id, &records)?;
766    Ok(updated)
767}
768
769pub fn list_chat_files(
770    storage_root: &Path,
771    session_id: &str,
772    viewer_did: Option<&str>,
773) -> Result<Vec<ChatFileRecord>, String> {
774    let viewer = viewer_did
775        .map(|s| s.to_string())
776        .unwrap_or_else(|| crate::user_profile::load_profile().public_did);
777    let participants = session_participant_dids(storage_root, session_id);
778    let records = load_all_records(storage_root, session_id)?;
779    Ok(records
780        .into_iter()
781        .filter(|r| can_view_file(r, &viewer, &participants))
782        .collect())
783}
784
785pub fn read_file_text(
786    storage_root: &Path,
787    session_id: &str,
788    file_id: &str,
789    viewer_did: Option<&str>,
790) -> Result<String, String> {
791    let viewer = viewer_did
792        .map(|s| s.to_string())
793        .unwrap_or_else(|| crate::user_profile::load_profile().public_did);
794    let participants = session_participant_dids(storage_root, session_id);
795    let records = load_all_records(storage_root, session_id)?;
796    let record = records
797        .iter()
798        .find(|r| r.file_id == file_id)
799        .ok_or_else(|| format!("Chat file not found: {file_id}"))?;
800    if !can_view_file(record, &viewer, &participants) {
801        return Err("You do not have permission to view this file".to_string());
802    }
803    let text_path = chat_session::chats_dir(storage_root)
804        .join(session_id)
805        .join(&record.text_rel_path);
806    fs::read_to_string(text_path).map_err(|e| e.to_string())
807}
808
809pub fn build_chat_files_context_block(
810    storage_root: &Path,
811    session_id: &str,
812    max_chars: usize,
813) -> String {
814    let profile = crate::user_profile::load_profile();
815    let participants = session_participant_dids(storage_root, session_id);
816    let Ok(files) = list_chat_files(storage_root, session_id, Some(&profile.public_did)) else {
817        return String::new();
818    };
819
820    let mut lines = vec!["[Chat attached files]".to_string()];
821    let mut used = 0usize;
822
823    for f in &files {
824        if !can_use_in_llm_context(f, &profile.public_did, &participants) {
825            continue;
826        }
827        let dim = match (f.image_width, f.image_height) {
828            (Some(w), Some(h)) => format!(", {w}x{h}"),
829            _ => String::new(),
830        };
831        let header = format!(
832            "- {} ({}{}{} bytes, visibility={})",
833            f.original_name,
834            f.mime_type,
835            dim,
836            f.byte_size,
837            f.sharing.visibility.as_str()
838        );
839        used += header.len();
840        lines.push(header);
841
842        if let Some(ref facet) = f.vision_facet {
843            let line = format!("  vision_facet: {facet}");
844            used += line.len();
845            lines.push(line);
846        }
847
848        if used >= max_chars {
849            lines.push("  … (truncated)".to_string());
850            break;
851        }
852
853        if is_image_record(f) {
854            lines.push(
855                "  note: multimodal image — use active VLM mmproj when vision_status=ok"
856                    .to_string(),
857            );
858            continue;
859        }
860
861        if let Ok(text) = read_file_text(
862            storage_root,
863            session_id,
864            &f.file_id,
865            Some(&profile.public_did),
866        ) {
867            let budget = max_chars.saturating_sub(used);
868            let excerpt = if text.len() <= budget {
869                text
870            } else {
871                let mut end = budget;
872                while end > 0 && !text.is_char_boundary(end) {
873                    end -= 1;
874                }
875                format!("{}…", &text[..end])
876            };
877            used += excerpt.len();
878            lines.push(format!("  excerpt: {excerpt}"));
879        }
880    }
881
882    if lines.len() == 1 {
883        return String::new();
884    }
885    lines.join("\n")
886}
887
888pub fn resolve_chat_file_path(
889    storage_root: &Path,
890    session_id: &str,
891    file_id: &str,
892    variant: &str,
893    viewer_did: Option<&str>,
894) -> Result<PathBuf, String> {
895    let viewer = viewer_did
896        .map(|s| s.to_string())
897        .unwrap_or_else(|| crate::user_profile::load_profile().public_did);
898    let participants = session_participant_dids(storage_root, session_id);
899    let records = load_all_records(storage_root, session_id)?;
900    let record = records
901        .iter()
902        .find(|r| r.file_id == file_id)
903        .ok_or_else(|| format!("Chat file not found: {file_id}"))?;
904    if !can_view_file(record, &viewer, &participants) {
905        return Err("You do not have permission to view this file".to_string());
906    }
907    let rel = match variant {
908        "thumbnail" => record
909            .thumbnail_rel_path
910            .as_deref()
911            .unwrap_or(&record.storage_rel_path),
912        _ => &record.storage_rel_path,
913    };
914    Ok(chat_session::chats_dir(storage_root)
915        .join(session_id)
916        .join(rel))
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use std::env;
923
924    #[test]
925    fn default_sharing_differs_by_session_kind() {
926        let solo = default_sharing_for_session(SessionKind::Solo);
927        assert_eq!(solo.visibility, FileVisibility::OwnerOnly);
928        assert_eq!(solo.sensitivity_level, NQuin::SENSITIVITY_CLASSIFIED);
929        let group = default_sharing_for_session(SessionKind::Group);
930        assert_eq!(group.visibility, FileVisibility::SessionParticipants);
931        assert_eq!(group.sensitivity_level, NQuin::SENSITIVITY_RESTRICTED);
932    }
933
934    #[test]
935    fn parse_png_image() {
936        let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([10, 20, 30, 255]));
937        let dyn_img = image::DynamicImage::ImageRgba8(img);
938        let mut buf = Vec::new();
939        dyn_img
940            .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
941            .expect("encode png");
942        let parsed = parse_document_bytes("snap.png", &buf);
943        assert_eq!(parsed.media_kind, MediaKind::Image);
944        assert_eq!(parsed.image_width, Some(2));
945        assert_eq!(parsed.parse_status, "ok");
946    }
947
948    #[test]
949    fn parse_txt_document() {
950        let parsed = parse_document_bytes("notes.txt", b"hello chat files");
951        assert_eq!(parsed.parse_status, "ok");
952        assert!(parsed.full_text.contains("hello"));
953    }
954
955    #[test]
956    fn sharing_permission_gate() {
957        let record = ChatFileRecord {
958            file_id: "abc".to_string(),
959            original_name: "x.pdf".to_string(),
960            mime_type: "application/pdf".to_string(),
961            extension: "pdf".to_string(),
962            sha256: "00".to_string(),
963            byte_size: 10,
964            page_count: Some(1),
965            text_preview: "p".to_string(),
966            storage_rel_path: "files/a.bin".to_string(),
967            text_rel_path: "files/a.txt".to_string(),
968            author_did: "did:owner".to_string(),
969            author_name: None,
970            message_lamport: Some(1),
971            attached_at: 0,
972            sharing: ChatFileSharing {
973                visibility: FileVisibility::OwnerOnly,
974                allow_download: false,
975                allow_llm_context: true,
976                allow_relay_sync: false,
977                sensitivity_level: NQuin::SENSITIVITY_CLASSIFIED,
978                allowed_dids: vec![],
979                expires_at: None,
980            },
981            parse_status: "ok".to_string(),
982            parse_error: None,
983            sensitivity_level: NQuin::SENSITIVITY_CLASSIFIED,
984            media_kind: MediaKind::Document,
985            image_width: None,
986            image_height: None,
987            thumbnail_rel_path: None,
988            vision_lexicon_id: None,
989            vision_facet: None,
990            vision_status: None,
991        };
992        assert!(can_view_file(&record, "did:owner", &[]));
993        assert!(!can_view_file(&record, "did:other", &[]));
994    }
995
996    #[test]
997    fn attach_and_list_round_trip() {
998        let mut storage = env::temp_dir();
999        storage.push(format!("qualia-chat-files-{}", rand::random::<u32>()));
1000        let session_id =
1001            chat_session::create_session(&storage, Some("Files test".to_string()), None)
1002                .expect("create session");
1003
1004        let src = storage.join("sample.md");
1005        fs::write(&src, "# Title\n\nBody text for chat.").unwrap();
1006
1007        let sharing = default_sharing_for_session(SessionKind::Group);
1008        let attached = attach_chat_file(&storage, &session_id, &src, sharing).expect("attach");
1009        assert_eq!(attached.file.extension, "md");
1010        assert!(attached.message_lamport > 0);
1011
1012        let owner_did = attached.file.author_did.clone();
1013        let listed = list_chat_files(&storage, &session_id, Some(&owner_did)).unwrap();
1014        assert_eq!(listed.len(), 1);
1015        assert_eq!(listed[0].sensitivity_level, NQuin::SENSITIVITY_RESTRICTED);
1016
1017        let updated = set_chat_file_sharing(
1018            &storage,
1019            &session_id,
1020            &attached.file.file_id,
1021            ChatFileSharing {
1022                visibility: FileVisibility::SpecificDids,
1023                allow_download: true,
1024                allow_llm_context: false,
1025                allow_relay_sync: false,
1026                sensitivity_level: NQuin::SENSITIVITY_PUBLIC,
1027                allowed_dids: vec!["did:friend".to_string()],
1028                expires_at: None,
1029            },
1030        )
1031        .expect("set sharing");
1032        assert_eq!(updated.sharing.visibility, FileVisibility::SpecificDids);
1033        assert_eq!(updated.sensitivity_level, NQuin::SENSITIVITY_RESTRICTED);
1034        assert_eq!(
1035            updated.sharing.sensitivity_level,
1036            NQuin::SENSITIVITY_RESTRICTED
1037        );
1038    }
1039
1040    #[test]
1041    fn explicit_sensitivity_can_raise_but_not_lower_visibility_floor() {
1042        let restricted = ChatFileSharing {
1043            visibility: FileVisibility::SessionParticipants,
1044            allow_download: true,
1045            allow_llm_context: true,
1046            allow_relay_sync: false,
1047            sensitivity_level: NQuin::SENSITIVITY_CLASSIFIED,
1048            allowed_dids: vec![],
1049            expires_at: None,
1050        };
1051        assert_eq!(
1052            effective_sensitivity_from_sharing(&restricted),
1053            NQuin::SENSITIVITY_CLASSIFIED
1054        );
1055
1056        let owner_only = ChatFileSharing {
1057            visibility: FileVisibility::OwnerOnly,
1058            allow_download: true,
1059            allow_llm_context: true,
1060            allow_relay_sync: false,
1061            sensitivity_level: NQuin::SENSITIVITY_PUBLIC,
1062            allowed_dids: vec![],
1063            expires_at: None,
1064        };
1065        assert_eq!(
1066            effective_sensitivity_from_sharing(&owner_only),
1067            NQuin::SENSITIVITY_CLASSIFIED
1068        );
1069    }
1070}