// FFI functions are called from generated UniFFI bindings (C-ABI), // so Rust's dead_code analysis doesn't see the calls. #![allow(dead_code)] uniffi::setup_scaffolding!(); pub use zx_document_core::material_type::{MaterialType, PreviewMode}; pub use zx_document_core::image_meta::ImageMeta; pub use zx_document_core::text::TextStats; pub use zx_document_core::search::SearchResult; pub use zx_document_core::anchors::NoteAnchor; pub use zx_document_core::progress::ReadingPosition; pub use zx_document_core::events::ReadingEvent; pub use zx_document_core::reading_material::ReadingMaterialRef; pub use zx_document_core::events_v2::{ReadingEventV2, ReadingEventTypeV2}; pub use zx_document_core::epub::{EpubMetadata, EpubChapter}; pub use zx_document_core::office::{OfficePreviewConfig, OfficePreviewStrategy}; pub use zx_document_core::pdf::{PdfMetadata, PdfPageText}; pub use zx_document_core::session_v2::{ReadingSessionV2, ReadingSessionStatus}; use zx_document_core::blocks as core_blocks; // ── V2 Reading Session FFI ── #[uniffi::export] fn start_reading_session_v2(material: ReadingMaterialRef, timestamp_ms: i64) -> Result { zx_document_core::session_v2::start_reading_session_v2(material, timestamp_ms) .map_err(|e| e.to_string()) } #[uniffi::export] fn pause_reading_session_v2(session_id: String) -> Result<(), String> { zx_document_core::session_v2::pause_reading_session_v2(&session_id).map_err(|e| e.to_string()) } #[uniffi::export] fn resume_reading_session_v2(session_id: String) -> Result<(), String> { zx_document_core::session_v2::resume_reading_session_v2(&session_id).map_err(|e| e.to_string()) } #[uniffi::export] fn close_reading_session_v2(session_id: String) -> Result<(), String> { zx_document_core::session_v2::close_reading_session_v2(&session_id).map_err(|e| e.to_string()) } // ── V2 Reading Event FFI ── #[uniffi::export] fn push_material_opened_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result { zx_document_core::events_v2::push_material_opened_v2(&session_id, &material_id, timestamp_ms) .map_err(|e| e.to_string()) } #[uniffi::export] fn push_material_closed_v2(session_id: String, material_id: String, active_seconds_delta: u32, timestamp_ms: i64) -> Result { zx_document_core::events_v2::push_material_closed_v2(&session_id, &material_id, active_seconds_delta, timestamp_ms) .map_err(|e| e.to_string()) } #[uniffi::export] fn push_position_changed_v2(session_id: String, material_id: String, position: ReadingPosition, timestamp_ms: i64) -> Result { zx_document_core::events_v2::push_position_changed_v2(&session_id, &material_id, position, timestamp_ms) .map_err(|e| e.to_string()) } #[uniffi::export] fn push_heartbeat_v2(session_id: String, material_id: String, active_seconds_delta: u32, position: Option, timestamp_ms: i64) -> Result { zx_document_core::events_v2::push_heartbeat_v2(&session_id, &material_id, active_seconds_delta, position, timestamp_ms) .map_err(|e| e.to_string()) } #[uniffi::export] fn push_marked_as_read_v2(session_id: String, material_id: String, timestamp_ms: i64) -> Result { zx_document_core::events_v2::push_marked_as_read_v2(&session_id, &material_id, timestamp_ms) .map_err(|e| e.to_string()) } // ── V2 Buffer Management FFI ── #[uniffi::export] fn export_pending_events_v2(limit: u32, timestamp_ms: i64) -> Vec { zx_document_core::events_v2::export_pending_events_v2(limit, timestamp_ms) } #[uniffi::export] fn reload_stale_events_v2() -> u32 { zx_document_core::events_v2::reload_stale_events_v2() } #[uniffi::export] fn ack_events_v2(event_ids: Vec) -> u32 { zx_document_core::events_v2::ack_events_v2(&event_ids) } #[uniffi::export] fn mark_events_failed_v2(event_ids: Vec) -> u32 { zx_document_core::events_v2::mark_events_failed_v2(&event_ids) } // FFI-compatible DocumentBlock (tuple variants, UniFFI proc-macro) #[derive(Debug, uniffi::Enum)] pub enum DocumentBlock { Heading(String, u8, String), Paragraph(String, String), List(String, bool, Vec), CodeBlock(String, Option, String), Quote(String, String), Table(String, Vec, Vec>), ImageBlock(String, String, Option), HorizontalRule(String), } impl From for DocumentBlock { fn from(b: core_blocks::DocumentBlock) -> Self { match b { core_blocks::DocumentBlock::Heading { id, level, text } => { DocumentBlock::Heading(id, level, text) } core_blocks::DocumentBlock::Paragraph { id, text } => { DocumentBlock::Paragraph(id, text) } core_blocks::DocumentBlock::List { id, ordered, items } => { DocumentBlock::List(id, ordered, items) } core_blocks::DocumentBlock::CodeBlock { id, language, code } => { DocumentBlock::CodeBlock(id, language, code) } core_blocks::DocumentBlock::Quote { id, text } => { DocumentBlock::Quote(id, text) } core_blocks::DocumentBlock::Table { id, headers, rows } => { DocumentBlock::Table(id, headers, rows) } core_blocks::DocumentBlock::Image { id, src, alt } => { DocumentBlock::ImageBlock(id, src, alt) } core_blocks::DocumentBlock::HorizontalRule { id } => { DocumentBlock::HorizontalRule(id) } } } } #[derive(Debug, uniffi::Error)] pub enum DocumentError { FileNotFound, UnsupportedFormat, ParseError, InvalidEncoding, IoError, } impl std::fmt::Display for DocumentError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::FileNotFound => write!(f, "File not found"), Self::UnsupportedFormat => write!(f, "Unsupported format"), Self::ParseError => write!(f, "Parse error"), Self::InvalidEncoding => write!(f, "Invalid encoding"), Self::IoError => write!(f, "IO error"), } } } impl std::error::Error for DocumentError {} impl From for DocumentError { fn from(e: zx_document_core::error::DocumentError) -> Self { match e { zx_document_core::error::DocumentError::FileNotFound(_) => Self::FileNotFound, zx_document_core::error::DocumentError::UnsupportedFormat(_) => Self::UnsupportedFormat, zx_document_core::error::DocumentError::ParseError(_) => Self::ParseError, zx_document_core::error::DocumentError::InvalidEncoding => Self::InvalidEncoding, zx_document_core::error::DocumentError::IoError(_) => Self::IoError, } } } #[uniffi::export] fn detect_material_type(file_path: String) -> Result { zx_document_core::material_type::detect_material_type(&file_path).map_err(Into::into) } #[uniffi::export] fn read_image_meta(file_path: String) -> Result { zx_document_core::image_meta::read_image_meta(&file_path).map_err(Into::into) } #[uniffi::export] fn read_text_stats(file_path: String) -> Result { let content = std::fs::read_to_string(&file_path).map_err(|_| DocumentError::FileNotFound)?; Ok(zx_document_core::text::text_stats(&content)) } #[uniffi::export] fn parse_text(content: String) -> Result, DocumentError> { let blocks = zx_document_core::text::parse_text_content(&content); let result: Vec = blocks.into_iter().map(Into::into).collect(); Ok(result) } #[uniffi::export] fn parse_markdown(content: String) -> Result, DocumentError> { let blocks = zx_document_core::markdown::parse_markdown(&content).map_err(|e| match e { zx_document_core::error::DocumentError::ParseError(_) => DocumentError::ParseError, _ => DocumentError::ParseError, })?; let result: Vec = blocks.into_iter().map(Into::into).collect(); Ok(result) } /// Out-pointer free: avoids struct-passing ABI issues #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_rustbuffer_free_separate( capacity: u64, len: u64, data: *mut u8, ) { if data.is_null() { return; } unsafe { let _v = Vec::from_raw_parts(data, len as usize, capacity as usize); // _v drops here, freeing the memory } } /// Workaround: receive raw bytes via separate len/data args, return via out-pointers /// Avoids all struct-passing ABI issues on ARM64 iOS. #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_rustbuffer_from_bytes_separate( len: i32, data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, ) { let mut call_status = uniffi::RustCallStatus::default(); let buf = unsafe { uniffi::ffi::uniffi_rustbuffer_from_bytes( uniffi::ForeignBytes::from_raw_parts(data, len), &mut call_status, ) }; // Check if allocation succeeded if call_status.code != uniffi::RustCallStatusCode::Success { unsafe { *out_capacity = 0; *out_len = 0; *out_data = std::ptr::null_mut(); } return; } unsafe { *out_capacity = buf.capacity() as u64; *out_len = buf.len() as u64; *out_data = buf.data_pointer() as *mut u8; } // Transfer ownership to caller — don't drop the buffer std::mem::forget(buf); } /// Full parse_markdown via raw bytes, result via out-pointers — avoids all struct-passing ABI issues #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_parse_markdown_separate( len: i32, data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let slice = unsafe { std::slice::from_raw_parts(data, len as usize) }; let content = match std::str::from_utf8(slice) { Ok(s) => s.to_string(), Err(_) => { unsafe { *out_error_code = -1; } return; } }; let result = crate::parse_markdown(content); // Serialize result using UniFFI use uniffi::LowerReturn; let lowered = , DocumentError> as LowerReturn>::lower_return(result); match lowered { Ok(buf) => { unsafe { *out_capacity = buf.capacity() as u64; *out_len = buf.len() as u64; *out_data = buf.data_pointer() as *mut u8; *out_error_code = 0; } std::mem::forget(buf); } Err(e) => { unsafe { *out_error_code = -1; } } } } #[uniffi::export] fn search_markdown_blocks(blocks: Vec, query: String) -> Vec { let core_blocks: Vec = blocks.into_iter().map(core_block_from_ffi).collect(); zx_document_core::search::search_blocks(&core_blocks, &query) } #[uniffi::export] fn search_text_content(content: String, query: String) -> Vec { zx_document_core::search::search_text(&content, &query) } #[uniffi::export] fn search_pdf_pages(page_numbers: Vec, page_texts: Vec, query: String) -> Vec { let pages: Vec<_> = page_numbers.iter().copied() .zip(page_texts.iter().map(|s| s.as_str())) .collect(); zx_document_core::search::search_pdf_text(&pages, &query) } #[uniffi::export] fn search_epub_chapters_ffi(chapter_ids: Vec, chapter_texts: Vec, query: String) -> Vec { let chapters: Vec<_> = chapter_ids.iter().map(|s| s.clone()) .zip(chapter_texts.iter().map(|s| s.as_str())) .collect(); zx_document_core::search::search_epub_chapters(&chapters, &query) } #[uniffi::export] fn create_note_anchor(material_id: String, position: Option) -> NoteAnchor { zx_document_core::anchors::NoteAnchor::from_position(&material_id, position.as_ref()) } #[uniffi::export] fn create_note_anchor_from_search(material_id: String, result: SearchResult) -> NoteAnchor { zx_document_core::anchors::NoteAnchor::from_search_result(&material_id, &result) } #[uniffi::export] fn restore_position_from_anchor(anchor: NoteAnchor) -> Option { anchor.to_position() } #[uniffi::export] fn read_pdf_metadata_ffi(file_path: String) -> Result { zx_document_core::pdf::read_pdf_metadata(std::path::Path::new(&file_path)).map_err(Into::into) } #[uniffi::export] fn extract_pdf_text_ffi(file_path: String) -> Result, DocumentError> { zx_document_core::pdf::extract_pdf_text(std::path::Path::new(&file_path)).map_err(Into::into) } #[uniffi::export] fn read_epub_metadata_ffi(file_path: String) -> Result { zx_document_core::epub::read_epub_metadata(std::path::Path::new(&file_path)).map_err(Into::into) } #[uniffi::export] fn read_epub_chapters_ffi(file_path: String) -> Result, DocumentError> { zx_document_core::epub::read_epub_chapters(std::path::Path::new(&file_path)).map_err(Into::into) } #[uniffi::export] fn get_office_preview_config_ffi(material_type: MaterialType, file_size: u64) -> Result { zx_document_core::office::get_office_preview_config(&material_type, file_size).map_err(Into::into) } #[uniffi::export] fn is_office_type_ffi(material_type: MaterialType) -> bool { zx_document_core::office::is_office_type(&material_type) } #[uniffi::export] fn cleanup_stale_sessions_ffi(now_ms: i64, max_age_ms: i64) -> u32 { zx_document_core::session_v2::cleanup_stale_sessions_v2(now_ms, max_age_ms) } #[uniffi::export] fn push_reading_event(event: ReadingEvent) { zx_document_core::events::push_reading_event(event) } #[uniffi::export] fn update_reading_position(material_id: String, position: ReadingPosition) { zx_document_core::events::update_reading_position(&material_id, position) } #[uniffi::export] fn export_pending_events() -> Vec { zx_document_core::events::export_pending_events() } #[uniffi::export] fn clear_exported_events(count: u32) { zx_document_core::events::clear_exported_events(count as usize) } /// Helper: serialize a Result into out-pointers. /// Generic over T; the RustBuffer methods are accessed via the concrete type /// after the `lower_return` call. macro_rules! write_result_to_out { ($result:expr, $out_capacity:ident, $out_len:ident, $out_data:ident, $out_error_code:ident) => {{ use uniffi::LowerReturn; match as LowerReturn>::lower_return($result) { Ok(buf) => { unsafe { *$out_capacity = buf.capacity() as u64; *$out_len = buf.len() as u64; *$out_data = buf.data_pointer() as *mut u8; *$out_error_code = 0; } std::mem::forget(buf); } Err(_) => { unsafe { *$out_error_code = -1; } } } }}; } /// Helper: read a UTF-8 string from raw bytes, or set error and return false. unsafe fn read_str_input(len: i32, data: *const u8, out_error_code: *mut i8) -> Option { let slice = std::slice::from_raw_parts(data, len as usize); match std::str::from_utf8(slice) { Ok(s) => Some(s.to_string()), Err(_) => { *out_error_code = -1; None }, } } // ─── Batch 1 out-pointer functions: String input → Result output ─── #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_detect_material_type_separate( len: i32, data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let file_path = match unsafe { read_str_input(len, data, out_error_code) } { Some(s) => s, None => return, }; let result = crate::detect_material_type(file_path); write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_read_image_meta_separate( len: i32, data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let file_path = match unsafe { read_str_input(len, data, out_error_code) } { Some(s) => s, None => return, }; let result = crate::read_image_meta(file_path); write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_read_text_stats_separate( len: i32, data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let file_path = match unsafe { read_str_input(len, data, out_error_code) } { Some(s) => s, None => return, }; let result = crate::read_text_stats(file_path); write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_parse_text_separate( len: i32, data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let content = match unsafe { read_str_input(len, data, out_error_code) } { Some(s) => s, None => return, }; let result = crate::parse_text(content); write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code); } // ─── Helper: lift from raw buffer fields ─── macro_rules! lift_from_raw { ($T:ty, $capacity:expr, $len:expr, $data:expr) => {{ let v = unsafe { Vec::from_raw_parts($data as *mut u8, $len as usize, $capacity as usize) }; let buf = uniffi::RustBuffer::from_vec(v); <$T as uniffi::Lift>::try_lift(buf).expect(concat!("failed to lift ", stringify!($T))) }}; } // ─── Batch 2: complex type input/output ─── #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_push_reading_event_separate( event_cap: u64, event_len: u64, event_data: *const u8, ) { let event: ReadingEvent = lift_from_raw!(ReadingEvent, event_cap, event_len, event_data); crate::push_reading_event(event); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_update_reading_position_separate( mid_len: i32, mid_data: *const u8, pos_cap: u64, pos_len: u64, pos_data: *const u8, out_error_code: *mut i8, ) { let material_id = match unsafe { read_str_input(mid_len, mid_data, out_error_code) } { Some(s) => s, None => return, }; unsafe { *out_error_code = 0; } let position: ReadingPosition = lift_from_raw!(ReadingPosition, pos_cap, pos_len, pos_data); crate::update_reading_position(material_id, position); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_export_pending_events_separate( out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let result: Vec = crate::export_pending_events(); write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_create_note_anchor_separate( mid_len: i32, mid_data: *const u8, pos_cap: u64, pos_len: u64, pos_data: *const u8, pos_has_value: i8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let material_id = match unsafe { read_str_input(mid_len, mid_data, out_error_code) } { Some(s) => s, None => return, }; let position: Option = if pos_has_value != 0 { Some(lift_from_raw!(ReadingPosition, pos_cap, pos_len, pos_data)) } else { None }; let result: Result = Ok(crate::create_note_anchor(material_id, position)); write_result_to_out!(result, out_capacity, out_len, out_data, out_error_code); } // ─── Batch 3: search functions ─── #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_search_markdown_blocks_separate( blocks_cap: u64, blocks_len: u64, blocks_data: *const u8, query_len: i32, query_data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let query = match unsafe { read_str_input(query_len, query_data, out_error_code) } { Some(s) => s, None => return, }; let blocks: Vec = lift_from_raw!(Vec, blocks_cap, blocks_len, blocks_data); let result: Vec = crate::search_markdown_blocks(blocks, query); write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_search_text_content_separate( content_len: i32, content_data: *const u8, query_len: i32, query_data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let content = match unsafe { read_str_input(content_len, content_data, out_error_code) } { Some(s) => s, None => return, }; let query = match unsafe { read_str_input(query_len, query_data, out_error_code) } { Some(s) => s, None => return, }; let result: Vec = crate::search_text_content(content, query); write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_search_pdf_pages_separate( page_numbers_cap: u64, page_numbers_len: u64, page_numbers_data: *const u8, page_texts_cap: u64, page_texts_len: u64, page_texts_data: *const u8, query_len: i32, query_data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let query = match unsafe { read_str_input(query_len, query_data, out_error_code) } { Some(s) => s, None => return, }; let page_numbers: Vec = lift_from_raw!(Vec, page_numbers_cap, page_numbers_len, page_numbers_data); let page_texts: Vec = lift_from_raw!(Vec, page_texts_cap, page_texts_len, page_texts_data); let result: Vec = crate::search_pdf_pages(page_numbers, page_texts, query); write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_search_epub_chapters_ffi_separate( chapter_ids_cap: u64, chapter_ids_len: u64, chapter_ids_data: *const u8, chapter_texts_cap: u64, chapter_texts_len: u64, chapter_texts_data: *const u8, query_len: i32, query_data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let query = match unsafe { read_str_input(query_len, query_data, out_error_code) } { Some(s) => s, None => return, }; let chapter_ids: Vec = lift_from_raw!(Vec, chapter_ids_cap, chapter_ids_len, chapter_ids_data); let chapter_texts: Vec = lift_from_raw!(Vec, chapter_texts_cap, chapter_texts_len, chapter_texts_data); let result: Vec = crate::search_epub_chapters_ffi(chapter_ids, chapter_texts, query); write_result_to_out!(Ok::<_, DocumentError>(result), out_capacity, out_len, out_data, out_error_code); } #[no_mangle] pub extern "C" fn ffi_zx_document_ffi_create_note_anchor_from_search_separate( mid_len: i32, mid_data: *const u8, result_cap: u64, result_len: u64, result_data: *const u8, out_capacity: *mut u64, out_len: *mut u64, out_data: *mut *mut u8, out_error_code: *mut i8, ) { let material_id = match unsafe { read_str_input(mid_len, mid_data, out_error_code) } { Some(s) => s, None => return, }; let search_result: SearchResult = lift_from_raw!(SearchResult, result_cap, result_len, result_data); let anchor: NoteAnchor = crate::create_note_anchor_from_search(material_id, search_result); write_result_to_out!(Ok::<_, DocumentError>(anchor), out_capacity, out_len, out_data, out_error_code); } // Reverse conversion: FFI DocumentBlock → core DocumentBlock, used by search. fn core_block_from_ffi(block: DocumentBlock) -> core_blocks::DocumentBlock { match block { DocumentBlock::Heading(id, level, text) => { core_blocks::DocumentBlock::Heading { id, level, text } } DocumentBlock::Paragraph(id, text) => { core_blocks::DocumentBlock::Paragraph { id, text } } DocumentBlock::List(id, ordered, items) => { core_blocks::DocumentBlock::List { id, ordered, items } } DocumentBlock::CodeBlock(id, language, code) => { core_blocks::DocumentBlock::CodeBlock { id, language, code } } DocumentBlock::Quote(id, text) => { core_blocks::DocumentBlock::Quote { id, text } } DocumentBlock::Table(id, headers, rows) => { core_blocks::DocumentBlock::Table { id, headers, rows } } DocumentBlock::ImageBlock(id, src, alt) => { core_blocks::DocumentBlock::Image { id, src, alt } } DocumentBlock::HorizontalRule(id) => { core_blocks::DocumentBlock::HorizontalRule { id } } } } #[cfg(test)] mod ffi_tests { use super::*; use zx_document_core::events_v2; fn drain_buffer() { loop { let batch = export_pending_events_v2(1000, 0); if batch.is_empty() { break; } let ids: Vec = batch.iter().map(|e| e.event_id.clone()).collect(); ack_events_v2(ids); } } // ── V2 Event Pipeline ── #[test] fn test_v2_full_event_pipeline() { drain_buffer(); events_v2::clear_all_events_v2(); let mat = ReadingMaterialRef::new("mat_ffi_test".to_string()); let sid = start_reading_session_v2(mat, 1000).unwrap(); assert!(!sid.is_empty()); let e1 = push_material_opened_v2(sid.clone(), "mat_ffi_test".to_string(), 1000).unwrap(); assert_eq!(e1.event_type, ReadingEventTypeV2::MaterialOpened); assert_eq!(e1.sequence, 1); let pos = ReadingPosition::Markdown { block_id: "intro".to_string(), scroll_progress: 0.25 }; let e2 = push_position_changed_v2(sid.clone(), "mat_ffi_test".to_string(), pos, 2000).unwrap(); assert_eq!(e2.event_type, ReadingEventTypeV2::PositionChanged); assert_eq!(e2.sequence, 2); let e3 = push_heartbeat_v2(sid.clone(), "mat_ffi_test".to_string(), 15, None, 5000).unwrap(); assert_eq!(e3.event_type, ReadingEventTypeV2::Heartbeat); assert_eq!(e3.active_seconds_delta, 15); let e4 = push_marked_as_read_v2(sid.clone(), "mat_ffi_test".to_string(), 10000).unwrap(); assert_eq!(e4.event_type, ReadingEventTypeV2::MarkedAsRead); push_material_closed_v2(sid.clone(), "mat_ffi_test".to_string(), 0, 12000).unwrap(); close_reading_session_v2(sid.clone()).unwrap(); let exported = export_pending_events_v2(100, 13000); assert!(exported.len() >= 4, "expected >=4, got {}", exported.len()); let types: Vec = exported.iter().map(|e| e.event_type.clone()).collect(); assert!(types.contains(&ReadingEventTypeV2::MaterialOpened)); assert!(types.contains(&ReadingEventTypeV2::PositionChanged)); assert!(types.contains(&ReadingEventTypeV2::Heartbeat)); assert!(types.contains(&ReadingEventTypeV2::MarkedAsRead)); let ids: Vec = exported.iter().map(|e| e.event_id.clone()).collect(); let acked = ack_events_v2(ids); assert!(acked >= 4); let _ = zx_document_core::session_v2::remove_session_v2(&sid); } // ── Session Lifecycle ── #[test] fn test_session_lifecycle() { let mat = ReadingMaterialRef::new("mat_ffi_life".to_string()); let sid = start_reading_session_v2(mat, 0).unwrap(); pause_reading_session_v2(sid.clone()).unwrap(); resume_reading_session_v2(sid.clone()).unwrap(); close_reading_session_v2(sid.clone()).unwrap(); let _ = zx_document_core::session_v2::remove_session_v2(&sid); } // ── Buffer Recovery ── #[test] fn test_mark_failed_and_recover() { drain_buffer(); events_v2::clear_all_events_v2(); let mat = ReadingMaterialRef::new("mat_ffi_recover".to_string()); let sid = start_reading_session_v2(mat, 0).unwrap(); let e = push_material_opened_v2(sid.clone(), "mat_ffi_recover".to_string(), 1000).unwrap(); let batch = export_pending_events_v2(100, 2000); assert!(batch.iter().any(|ev| ev.event_id == e.event_id)); let marked = mark_events_failed_v2(vec![e.event_id.clone()]); assert_eq!(marked, 1); let retry = export_pending_events_v2(100, 3000); assert!(retry.iter().any(|ev| ev.event_id == e.event_id)); ack_events_v2(vec![e.event_id.clone()]); close_reading_session_v2(sid.clone()).unwrap(); let _ = zx_document_core::session_v2::remove_session_v2(&sid); } // ── Parse → Search → Anchor ── #[test] fn test_parse_search_anchor() { let md = "# Hello\n\nParagraph with searchable text.\n\n## Section 2\n\nMore."; let blocks = parse_markdown(md.to_string()).unwrap(); assert!(!blocks.is_empty()); let results = search_markdown_blocks(blocks, "searchable".to_string()); assert_eq!(results.len(), 1); assert!(results[0].snippet.to_lowercase().contains("searchable")); // Position → Anchor let pos = ReadingPosition::Markdown { block_id: "h1".to_string(), scroll_progress: 0.5 }; let anchor = create_note_anchor("mat_ffi".to_string(), Some(pos)); match &anchor { NoteAnchor::MarkdownBlock { material_id, block_id, .. } => { assert_eq!(material_id, "mat_ffi"); assert_eq!(block_id, "h1"); } _ => panic!("expected MarkdownBlock"), } // Anchor → Position (roundtrip) let restored = restore_position_from_anchor(anchor); assert!(restored.is_some()); // SearchResult → Anchor let sr_anchor = create_note_anchor_from_search("mat_ffi".to_string(), results[0].clone()); match sr_anchor { NoteAnchor::SearchResultAnchor { material_id, .. } => { assert_eq!(material_id, "mat_ffi"); } _ => panic!("expected SearchResultAnchor"), } } // ── Text Search ── #[test] fn test_text_search() { let results = search_text_content( "Line one\nLine two with keyword\nLine three".to_string(), "keyword".to_string(), ); assert_eq!(results.len(), 1); assert_eq!(results[0].line_number, Some(2)); } // ── PDF Search ── #[test] fn test_pdf_search() { let results = search_pdf_pages( vec![1, 2, 3], vec!["Page 1.".to_string(), "Page 2 with target.".to_string(), "Page 3.".to_string()], "target".to_string(), ); assert_eq!(results.len(), 1); assert_eq!(results[0].page_number, Some(2)); } // ── EPUB Search ── #[test] fn test_epub_search() { let results = search_epub_chapters_ffi( vec!["intro".to_string(), "ch1".to_string()], vec!["Welcome.".to_string(), "Chapter one keyword here.".to_string()], "keyword".to_string(), ); assert_eq!(results.len(), 1); assert_eq!(results[0].chapter_id, Some("ch1".to_string())); } // ── V1 Backward Compatibility ── #[test] fn test_v1_backward_compat() { let event = ReadingEvent::MaterialOpened { material_id: "mat_v1_ffi".to_string(), timestamp_ms: 1000, }; push_reading_event(event); let exported = export_pending_events(); assert!(!exported.is_empty()); let found = exported.iter().any(|e| { matches!(e, ReadingEvent::MaterialOpened { material_id, .. } if material_id == "mat_v1_ffi") }); assert!(found, "V1 event should be exported"); clear_exported_events(exported.len() as u32); } // ── Error Handling ── #[test] fn test_session_not_found() { assert!(close_reading_session_v2("nonexistent".to_string()).is_err()); } }