diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0e9b5cd..aaa6f16 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2,6 +2,132 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +/// Unique identifier for a document within a location +/// Combines location_id + rel_path for stable identity +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct DocId { + pub location_id: LocationId, + pub rel_path: PathBuf, +} + +impl DocId { + /// Creates a new DocId after validating the relative path + pub fn new(location_id: LocationId, rel_path: PathBuf) -> Result { + let normalized = normalize_relative_path(&rel_path)?; + Ok(Self { location_id, rel_path: normalized }) + } + + /// Resolves this DocId against a location root path + pub fn resolve(&self, location_root: &Path) -> PathBuf { + location_root.join(&self.rel_path) + } + + /// Converts to a DocRef for operations + pub fn to_doc_ref(&self) -> DocRef { + DocRef { location_id: self.location_id, rel_path: self.rel_path.clone() } + } +} + +impl From for DocId { + fn from(doc_ref: DocRef) -> Self { + Self { location_id: doc_ref.location_id, rel_path: doc_ref.rel_path } + } +} + +/// Metadata for a document in the catalog +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DocMeta { + pub id: DocId, + pub filename: String, + pub size_bytes: u64, + pub mtime: DateTime, + pub created_at: Option>, + pub content_hash: Option, + pub encoding: Encoding, + pub line_ending: LineEnding, + pub is_conflict: bool, + pub title: Option, + pub word_count: Option, +} + +/// File encoding detection and preservation +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Default)] +pub enum Encoding { + #[default] + Utf8, + Utf8WithBom, + Utf16Le, + Utf16Be, +} + + +/// Line ending style preservation +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Default)] +pub enum LineEnding { + #[default] + Lf, + CrLf, + Auto, +} + + +/// Document content with metadata for opening +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DocContent { + pub text: String, + pub meta: DocMeta, +} + +/// Options for listing documents +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct DocListOptions { + pub recursive: bool, + pub extensions: Option>, + pub sort_by: Option, + pub sort_order: SortOrder, +} + +/// Sort fields for document listing +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub enum DocSortField { + Name, + Modified, + Created, + Size, +} + +/// Sort order +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[derive(Default)] +pub enum SortOrder { + Ascending, + #[default] + Descending, +} + + +/// Save policy options +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[derive(Default)] +pub enum SavePolicy { + /// Atomic save: write to temp, fsync, rename + #[default] + Atomic, + /// In-place overwrite (not recommended for production) + InPlace, +} + + +/// Result of a save operation +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SaveResult { + pub success: bool, + pub new_meta: Option, + pub conflict_detected: bool, +} + /// Unique identifier for a location #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct LocationId(pub i64); @@ -192,6 +318,26 @@ pub enum BackendEvent { }, /// Emitted during startup reconciliation ReconciliationComplete { checked: usize, missing: Vec }, + /// Emitted when a conflicted copy is detected + ConflictDetected { + location_id: LocationId, + rel_path: PathBuf, + conflict_filename: String, + }, + /// Emitted when a document is modified externally + DocModifiedExternally { doc_id: DocId, new_mtime: DateTime }, + /// Emitted when save status changes (for UI feedback) + SaveStatusChanged { doc_id: DocId, status: SaveStatus }, +} + +/// Save status for UI feedback loop +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum SaveStatus { + Idle, + Dirty, + Saving, + Saved, + Error, } /// Normalizes a relative path and rejects any path traversal attempts @@ -249,6 +395,27 @@ pub fn is_path_within_location(resolved_path: &PathBuf, location_root: &PathBuf) resolved_canonical.starts_with(&root_canonical) } +/// Pattern for detecting cloud provider conflicted copies +/// Examples: +/// - "My Document (conflict).md" +/// - "My Document (John's conflicted copy 2024-01-15).md" +/// - "My Document (Case conflicted copy).md" +pub static CONFLICT_PATTERNS: &[&str] = &[ + "(conflict)", + "'s conflicted copy", + ".conflicted copy", + " (conflicted copy", + " conflicted copy)", + "(conflicted copy", + "conflicted copy", +]; + +/// Detects if a filename indicates a conflicted copy from cloud providers +pub fn is_conflicted_filename(filename: &str) -> bool { + let lower = filename.to_lowercase(); + CONFLICT_PATTERNS.iter().any(|pattern| lower.contains(pattern)) +} + #[cfg(test)] mod tests { use super::*; @@ -330,4 +497,64 @@ mod tests { let result = DocRef::new(location_id, rel_path); assert!(matches!(result, Err(PathError::PathTraversalAttempt))); } + + #[test] + fn test_doc_id_creation() { + let location_id = LocationId(1); + let rel_path = PathBuf::from("docs/file.md"); + let doc_id = DocId::new(location_id, rel_path.clone()).unwrap(); + + assert_eq!(doc_id.location_id, location_id); + assert_eq!(doc_id.rel_path, rel_path); + } + + #[test] + fn test_doc_id_to_doc_ref() { + let location_id = LocationId(1); + let rel_path = PathBuf::from("docs/file.md"); + let doc_id = DocId::new(location_id, rel_path.clone()).unwrap(); + + let doc_ref = doc_id.to_doc_ref(); + assert_eq!(doc_ref.location_id, location_id); + assert_eq!(doc_ref.rel_path, rel_path); + } + + #[test] + fn test_is_conflicted_filename_basic() { + assert!(is_conflicted_filename("My Doc (conflict).md")); + assert!(is_conflicted_filename("My Doc conflicted copy.md")); + assert!(is_conflicted_filename("My Doc (John's conflicted copy).md")); + assert!(is_conflicted_filename("My Doc (Case conflicted copy 2024-01-15).md")); + } + + #[test] + fn test_is_conflicted_filename_false() { + assert!(!is_conflicted_filename("My Doc.md")); + assert!(!is_conflicted_filename("Conflict Resolution.md")); + assert!(!is_conflicted_filename("regular-file.txt")); + } + + #[test] + fn test_default_encoding() { + let enc: Encoding = Default::default(); + assert!(matches!(enc, Encoding::Utf8)); + } + + #[test] + fn test_default_line_ending() { + let le: LineEnding = Default::default(); + assert!(matches!(le, LineEnding::Lf)); + } + + #[test] + fn test_default_save_policy() { + let policy: SavePolicy = Default::default(); + assert!(matches!(policy, SavePolicy::Atomic)); + } + + #[test] + fn test_default_sort_order() { + let order: SortOrder = Default::default(); + assert!(matches!(order, SortOrder::Descending)); + } } diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index c2321b8..3f400a8 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -12,6 +12,7 @@ chrono = "0.4" rusqlite = { version = "0.38", features = ["bundled", "chrono", "serde_json"] } dirs = "6" tracing = "0.1" +tempfile = "3" [dev-dependencies] tempfile = "3" diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 1a76bc3..a093aea 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,8 +1,13 @@ -use chrono::Utc; +use chrono::{DateTime, Utc}; use rusqlite::{Connection, params}; -use std::path::PathBuf; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use writer_core::{AppError, ErrorCode, LocationDescriptor, LocationId}; +use writer_core::{ + AppError, DocContent, DocId, DocListOptions, DocMeta, DocSortField, Encoding, ErrorCode, LineEnding, + LocationDescriptor, LocationId, SavePolicy, SaveResult, SortOrder, is_conflicted_filename, +}; /// Manages the SQLite database for the application pub struct Store { @@ -60,6 +65,40 @@ impl Store { ) .map_err(|e| AppError::io(format!("Failed to create index: {}", e)))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS documents ( + location_id INTEGER NOT NULL, + rel_path TEXT NOT NULL, + filename TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + mtime TEXT NOT NULL, + created_at TEXT, + content_hash TEXT, + encoding INTEGER NOT NULL DEFAULT 0, + line_ending INTEGER NOT NULL DEFAULT 0, + is_conflict INTEGER NOT NULL DEFAULT 0, + title TEXT, + word_count INTEGER, + updated_at TEXT NOT NULL, + PRIMARY KEY (location_id, rel_path), + FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE + )", + [], + ) + .map_err(|e| AppError::io(format!("Failed to create documents table: {}", e)))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_documents_mtime ON documents(location_id, mtime DESC)", + [], + ) + .map_err(|e| AppError::io(format!("Failed to create documents index: {}", e)))?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_documents_conflict ON documents(is_conflict)", + [], + ) + .map_err(|e| AppError::io(format!("Failed to create conflict index: {}", e)))?; + tracing::debug!("Database schema initialized"); Ok(()) } @@ -212,6 +251,443 @@ impl Store { Ok(missing) } + + /// Lists documents in a location + pub fn doc_list(&self, location_id: LocationId, options: Option) -> Result, AppError> { + let location = self + .location_get(location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; + + let options = options.unwrap_or_default(); + let root_path = &location.root_path; + + let mut docs = Vec::new(); + + if options.recursive { + self.collect_docs_recursive(root_path, root_path, location_id, &options, &mut docs)?; + } else { + self.collect_docs_shallow(root_path, root_path, location_id, &options, &mut docs)?; + } + + match options.sort_by.unwrap_or(DocSortField::Modified) { + DocSortField::Name => { + docs.sort_by(|a, b| a.filename.cmp(&b.filename)); + } + DocSortField::Modified => { + docs.sort_by(|a, b| b.mtime.cmp(&a.mtime)); + } + DocSortField::Created => { + docs.sort_by(|a, b| match (&a.created_at, &b.created_at) { + (Some(a), Some(b)) => b.cmp(a), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + } + DocSortField::Size => { + docs.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)); + } + } + + if matches!(options.sort_order, SortOrder::Ascending) { + docs.reverse(); + } + + tracing::debug!("Listed {} documents in location {:?}", docs.len(), location_id); + Ok(docs) + } + + fn collect_docs_shallow( + &self, root: &Path, current: &Path, location_id: LocationId, options: &DocListOptions, docs: &mut Vec, + ) -> Result<(), AppError> { + let entries = + std::fs::read_dir(current).map_err(|e| AppError::io(format!("Failed to read directory: {}", e)))?; + + let extensions = options + .extensions + .as_ref() + .map(|exts| exts.iter().map(|e| e.to_lowercase()).collect::>()); + + for entry in entries { + let entry = entry.map_err(|e| AppError::io(format!("Failed to read entry: {}", e)))?; + let path = entry.path(); + + if path.is_file() { + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + if let Some(ref exts) = extensions { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + if !exts.contains(&ext) { + continue; + } + } + + let rel_path = path + .strip_prefix(root) + .map_err(|_| AppError::io("Path not within root"))? + .to_path_buf(); + + let meta = self.read_doc_metadata(&path, location_id, rel_path, &filename)?; + docs.push(meta); + } + } + + Ok(()) + } + + fn collect_docs_recursive( + &self, root: &Path, current: &Path, location_id: LocationId, options: &DocListOptions, docs: &mut Vec, + ) -> Result<(), AppError> { + let entries = + std::fs::read_dir(current).map_err(|e| AppError::io(format!("Failed to read directory: {}", e)))?; + + let extensions = options + .extensions + .as_ref() + .map(|exts| exts.iter().map(|e| e.to_lowercase()).collect::>()); + + for entry in entries { + let entry = entry.map_err(|e| AppError::io(format!("Failed to read entry: {}", e)))?; + let path = entry.path(); + + if path.is_file() { + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + if let Some(ref exts) = extensions { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + if !exts.contains(&ext) { + continue; + } + } + + let rel_path = path + .strip_prefix(root) + .map_err(|_| AppError::io("Path not within root"))? + .to_path_buf(); + + let meta = self.read_doc_metadata(&path, location_id, rel_path, &filename)?; + docs.push(meta); + } else if path.is_dir() { + self.collect_docs_recursive(root, &path, location_id, options, docs)?; + } + } + + Ok(()) + } + + fn read_doc_metadata( + &self, path: &Path, location_id: LocationId, rel_path: PathBuf, filename: &str, + ) -> Result { + let metadata = std::fs::metadata(path).map_err(|e| AppError::io(format!("Failed to read metadata: {}", e)))?; + + let size_bytes = metadata.len(); + let mtime = metadata + .modified() + .map_err(|e| AppError::io(format!("Failed to get mtime: {}", e)))?; + let mtime: DateTime = mtime.into(); + + let created_at = metadata.created().ok().map(DateTime::::from); + + let is_conflict = is_conflicted_filename(filename); + + let word_count = if filename.ends_with(".md") || filename.ends_with(".txt") { + std::fs::read_to_string(path).ok().map(|content| count_words(&content)) + } else { + None + }; + + Ok(DocMeta { + id: DocId { location_id, rel_path }, + filename: filename.to_string(), + size_bytes, + mtime, + created_at, + content_hash: None, + encoding: Encoding::default(), + line_ending: LineEnding::default(), + is_conflict, + title: None, + word_count, + }) + } + + /// Opens a document and returns its content with metadata + pub fn doc_open(&self, doc_id: &DocId) -> Result { + let location = self + .location_get(doc_id.location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", doc_id.location_id)))?; + + let full_path = doc_id.resolve(&location.root_path); + + if !full_path.exists() { + return Err(AppError::not_found(format!("Document not found: {:?}", full_path))); + } + + let mut file = File::open(&full_path).map_err(|e| AppError::io(format!("Failed to open file: {}", e)))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|e| AppError::io(format!("Failed to read file: {}", e)))?; + + let (text, encoding) = detect_and_decode(&bytes)?; + + let line_ending = detect_line_ending(&text); + + let word_count = count_words(&text); + + let title = extract_title(&text, &doc_id.rel_path); + + let metadata = + std::fs::metadata(&full_path).map_err(|e| AppError::io(format!("Failed to read metadata: {}", e)))?; + let mtime = metadata + .modified() + .map_err(|e| AppError::io(format!("Failed to get mtime: {}", e)))?; + let mtime: DateTime = mtime.into(); + + let created_at = metadata.created().ok().map(DateTime::::from); + + let is_conflict = is_conflicted_filename(&doc_id.rel_path.to_string_lossy()); + + let doc_meta = DocMeta { + id: doc_id.clone(), + filename: doc_id + .rel_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(), + size_bytes: metadata.len(), + mtime, + created_at, + content_hash: None, + encoding, + line_ending, + is_conflict, + title, + word_count: Some(word_count), + }; + + tracing::info!("Opened document: {:?}", doc_id.rel_path); + + Ok(DocContent { text, meta: doc_meta }) + } + + /// Saves a document with atomic write semantics + pub fn doc_save(&self, doc_id: &DocId, text: &str, policy: Option) -> Result { + let policy = policy.unwrap_or_default(); + let location = self + .location_get(doc_id.location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", doc_id.location_id)))?; + + let full_path = doc_id.resolve(&location.root_path); + + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(format!("Failed to create directory: {}", e)))?; + } + + let is_conflict = is_conflicted_filename(&doc_id.rel_path.to_string_lossy()); + + match policy { + SavePolicy::Atomic => { + self.save_atomic(&full_path, text)?; + } + SavePolicy::InPlace => { + let mut file = + File::create(&full_path).map_err(|e| AppError::io(format!("Failed to create file: {}", e)))?; + file.write_all(text.as_bytes()) + .map_err(|e| AppError::io(format!("Failed to write file: {}", e)))?; + } + } + + let metadata = + std::fs::metadata(&full_path).map_err(|e| AppError::io(format!("Failed to read metadata: {}", e)))?; + let mtime = metadata + .modified() + .map_err(|e| AppError::io(format!("Failed to get mtime: {}", e)))?; + let mtime: DateTime = mtime.into(); + + let created_at = metadata.created().ok().map(DateTime::::from); + + let line_ending = detect_line_ending(text); + let word_count = count_words(text); + let title = extract_title(text, &doc_id.rel_path); + + let new_meta = DocMeta { + id: doc_id.clone(), + filename: doc_id + .rel_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(), + size_bytes: metadata.len(), + mtime, + created_at, + content_hash: None, + encoding: Encoding::Utf8, + line_ending, + is_conflict, + title, + word_count: Some(word_count), + }; + + self.update_doc_in_catalog(doc_id, &new_meta)?; + + tracing::info!("Saved document: {:?}", doc_id.rel_path); + + Ok(SaveResult { success: true, new_meta: Some(new_meta), conflict_detected: is_conflict }) + } + + /// Atomic save implementation: write to temp file, fsync, rename + fn save_atomic(&self, target_path: &Path, text: &str) -> Result<(), AppError> { + let parent_dir = target_path + .parent() + .ok_or_else(|| AppError::invalid_path("Target path has no parent directory"))?; + + let temp_file = tempfile::NamedTempFile::new_in(parent_dir) + .map_err(|e| AppError::io(format!("Failed to create temp file: {}", e)))?; + + let temp_path = temp_file.path(); + + let mut file = temp_file.as_file(); + file.write_all(text.as_bytes()) + .map_err(|e| AppError::io(format!("Failed to write temp file: {}", e)))?; + + file.sync_all() + .map_err(|e| AppError::io(format!("Failed to fsync temp file: {}", e)))?; + + if target_path.exists() + && let Ok(orig_metadata) = std::fs::metadata(target_path) + { + let permissions = orig_metadata.permissions(); + let _ = std::fs::set_permissions(temp_path, permissions); + } + + temp_file + .persist(target_path) + .map_err(|e| AppError::io(format!("Failed to persist file: {}", e)))?; + + tracing::debug!("Atomic save completed: {:?}", target_path); + Ok(()) + } + + /// Updates document entry in catalog + fn update_doc_in_catalog(&self, doc_id: &DocId, meta: &DocMeta) -> Result<(), AppError> { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + + let rel_path_str = doc_id.rel_path.to_string_lossy().to_string(); + let mtime_str = meta.mtime.to_rfc3339(); + let updated_at_str = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO documents + (location_id, rel_path, filename, size_bytes, mtime, encoding, line_ending, is_conflict, title, word_count, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(location_id, rel_path) DO UPDATE SET + filename = excluded.filename, + size_bytes = excluded.size_bytes, + mtime = excluded.mtime, + encoding = excluded.encoding, + line_ending = excluded.line_ending, + is_conflict = excluded.is_conflict, + title = excluded.title, + word_count = excluded.word_count, + updated_at = excluded.updated_at", + params![ + doc_id.location_id.0, + rel_path_str, + meta.filename, + meta.size_bytes as i64, + mtime_str, + encoding_to_i32(meta.encoding), + line_ending_to_i32(meta.line_ending), + meta.is_conflict as i32, + meta.title, + meta.word_count.map(|n| n as i64), + updated_at_str, + ], + ).map_err(|e| AppError::io(format!("Failed to update document catalog: {}", e)))?; + + Ok(()) + } +} + +/// Detects encoding from byte BOM and decodes to string +fn detect_and_decode(bytes: &[u8]) -> Result<(String, Encoding), AppError> { + if bytes.starts_with(&[0xef, 0xbb, 0xbf]) { + let text = String::from_utf8_lossy(&bytes[3..]).into_owned(); + Ok((text, Encoding::Utf8WithBom)) + } else if bytes.starts_with(&[0xff, 0xfe]) { + let u16_vec: Vec = bytes[2..] + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + let text = String::from_utf16(&u16_vec).map_err(|e| AppError::io(format!("Invalid UTF-16 LE: {}", e)))?; + Ok((text, Encoding::Utf16Le)) + } else if bytes.starts_with(&[0xfe, 0xff]) { + let u16_vec: Vec = bytes[2..] + .chunks_exact(2) + .map(|c| u16::from_be_bytes([c[0], c[1]])) + .collect(); + let text = String::from_utf16(&u16_vec).map_err(|e| AppError::io(format!("Invalid UTF-16 BE: {}", e)))?; + Ok((text, Encoding::Utf16Be)) + } else { + let text = String::from_utf8_lossy(bytes).into_owned(); + Ok((text, Encoding::Utf8)) + } +} + +/// Detects line ending style from text content +fn detect_line_ending(text: &str) -> LineEnding { + let crlf_count = text.matches("\r\n").count(); + let lf_count = text.matches('\n').count() - crlf_count; + if crlf_count > lf_count { LineEnding::CrLf } else { LineEnding::Lf } +} + +/// Counts words in text (simple whitespace-based) +fn count_words(text: &str) -> usize { + text.split_whitespace().count() +} + +/// Extracts title from markdown (first H1) or filename +fn extract_title(text: &str, rel_path: &Path) -> Option { + for line in text.lines() { + let trimmed = line.trim(); + if let Some(title) = trimmed.strip_prefix("# ") { + return Some(title.trim().to_string()); + } + } + + rel_path.file_stem().and_then(|s| s.to_str()).map(|s| s.to_string()) +} + +/// Converts Encoding to i32 for database storage +fn encoding_to_i32(enc: Encoding) -> i32 { + match enc { + Encoding::Utf8 => 0, + Encoding::Utf8WithBom => 1, + Encoding::Utf16Le => 2, + Encoding::Utf16Be => 3, + } +} + +/// Converts LineEnding to i32 for database storage +fn line_ending_to_i32(le: LineEnding) -> i32 { + match le { + LineEnding::Lf => 0, + LineEnding::CrLf => 1, + LineEnding::Auto => 2, + } } #[cfg(test)] @@ -310,4 +786,200 @@ mod tests { assert_eq!(missing[0].0, non_existent.id); assert_eq!(missing[0].1, non_existent_path); } + + #[test] + fn test_doc_list_shallow() { + let (store, _temp) = create_test_store(); + let location_dir = TempDir::new().unwrap(); + let location_path = location_dir.path().to_path_buf(); + + let location = store + .location_add("Test Location".to_string(), location_path.clone()) + .unwrap(); + + std::fs::write(location_path.join("file1.md"), "# File 1").unwrap(); + std::fs::write(location_path.join("file2.txt"), "File 2 content").unwrap(); + + let docs = store.doc_list(location.id, None).unwrap(); + assert_eq!(docs.len(), 2); + } + + #[test] + fn test_doc_list_recursive() { + let (store, _temp) = create_test_store(); + let location_dir = TempDir::new().unwrap(); + let location_path = location_dir.path().to_path_buf(); + + let location = store + .location_add("Test Location".to_string(), location_path.clone()) + .unwrap(); + + std::fs::write(location_path.join("file1.md"), "# File 1").unwrap(); + std::fs::create_dir(location_path.join("subdir")).unwrap(); + std::fs::write(location_path.join("subdir/file2.md"), "# File 2").unwrap(); + + let options = DocListOptions { recursive: true, ..Default::default() }; + let docs = store.doc_list(location.id, Some(options)).unwrap(); + assert_eq!(docs.len(), 2); + } + + #[test] + fn test_doc_list_with_extension_filter() { + let (store, _temp) = create_test_store(); + let location_dir = TempDir::new().unwrap(); + let location_path = location_dir.path().to_path_buf(); + + let location = store + .location_add("Test Location".to_string(), location_path.clone()) + .unwrap(); + + std::fs::write(location_path.join("file1.md"), "# File 1").unwrap(); + std::fs::write(location_path.join("file2.txt"), "File 2").unwrap(); + std::fs::write(location_path.join("file3.rs"), "fn main() {}").unwrap(); + + let options = + DocListOptions { recursive: false, extensions: Some(vec!["md".to_string()]), ..Default::default() }; + let docs = store.doc_list(location.id, Some(options)).unwrap(); + assert_eq!(docs.len(), 1); + assert_eq!(docs[0].filename, "file1.md"); + } + + #[test] + fn test_doc_open() { + let (store, _temp) = create_test_store(); + let location_dir = TempDir::new().unwrap(); + let location_path = location_dir.path().to_path_buf(); + + let location = store + .location_add("Test Location".to_string(), location_path.clone()) + .unwrap(); + + let content = "# Test Document\n\nThis is a test."; + std::fs::write(location_path.join("test.md"), content).unwrap(); + + let doc_id = DocId::new(location.id, PathBuf::from("test.md")).unwrap(); + let doc_content = store.doc_open(&doc_id).unwrap(); + + assert_eq!(doc_content.text, content); + assert_eq!(doc_content.meta.filename, "test.md"); + assert_eq!(doc_content.meta.word_count, Some(7)); + assert_eq!(doc_content.meta.title, Some("Test Document".to_string())); + } + + #[test] + fn test_doc_save_atomic() { + let (store, _temp) = create_test_store(); + let location_dir = TempDir::new().unwrap(); + let location_path = location_dir.path().to_path_buf(); + + let location = store + .location_add("Test Location".to_string(), location_path.clone()) + .unwrap(); + + let doc_id = DocId::new(location.id, PathBuf::from("new_file.md")).unwrap(); + let content = "# New Document\n\nContent here."; + let result = store.doc_save(&doc_id, content, None).unwrap(); + assert!(result.success); + assert!(result.new_meta.is_some()); + assert!(!result.conflict_detected); + + let saved_path = location_path.join("new_file.md"); + assert!(saved_path.exists()); + let saved_content = std::fs::read_to_string(saved_path).unwrap(); + assert_eq!(saved_content, content); + } + + #[test] + fn test_doc_save_overwrite() { + let (store, _temp) = create_test_store(); + let location_dir = TempDir::new().unwrap(); + let location_path = location_dir.path().to_path_buf(); + + let location = store + .location_add("Test Location".to_string(), location_path.clone()) + .unwrap(); + + let doc_id = DocId::new(location.id, PathBuf::from("overwrite.md")).unwrap(); + store.doc_save(&doc_id, "Initial content", None).unwrap(); + + let new_content = "Updated content here"; + let result = store.doc_save(&doc_id, new_content, None).unwrap(); + + assert!(result.success); + + let saved_path = location_path.join("overwrite.md"); + let saved_content = std::fs::read_to_string(saved_path).unwrap(); + assert_eq!(saved_content, new_content); + } + + #[test] + fn test_doc_save_creates_directories() { + let (store, _temp) = create_test_store(); + let location_dir = TempDir::new().unwrap(); + let location_path = location_dir.path().to_path_buf(); + + let location = store + .location_add("Test Location".to_string(), location_path.clone()) + .unwrap(); + + let doc_id = DocId::new(location.id, PathBuf::from("level1/level2/file.md")).unwrap(); + let result = store.doc_save(&doc_id, "Nested content", None); + + assert!(result.is_ok()); + assert!(location_path.join("level1/level2/file.md").exists()); + } + + #[test] + fn test_detect_encoding_utf8() { + let bytes = b"Hello, World!"; + let (text, enc) = detect_and_decode(bytes).unwrap(); + assert_eq!(text, "Hello, World!"); + assert!(matches!(enc, Encoding::Utf8)); + } + + #[test] + fn test_detect_encoding_utf8_bom() { + let bytes = vec![0xef, 0xbb, 0xbf, b'H', b'i']; + let (text, enc) = detect_and_decode(&bytes).unwrap(); + assert_eq!(text, "Hi"); + assert!(matches!(enc, Encoding::Utf8WithBom)); + } + + #[test] + fn test_detect_line_ending_lf() { + let text = "line1\nline2\nline3"; + let le = detect_line_ending(text); + assert!(matches!(le, LineEnding::Lf)); + } + + #[test] + fn test_detect_line_ending_crlf() { + let text = "line1\r\nline2\r\nline3"; + let le = detect_line_ending(text); + assert!(matches!(le, LineEnding::CrLf)); + } + + #[test] + fn test_count_words() { + assert_eq!(count_words("Hello world"), 2); + assert_eq!(count_words("One two three four"), 4); + assert_eq!(count_words(""), 0); + assert_eq!(count_words(" multiple spaces "), 2); + } + + #[test] + fn test_extract_title_from_heading() { + let text = "# My Title\n\nSome content"; + let path = Path::new("file.md"); + let title = extract_title(text, path); + assert_eq!(title, Some("My Title".to_string())); + } + + #[test] + fn test_extract_title_from_filename() { + let text = "No heading here"; + let path = Path::new("my_document.md"); + let title = extract_title(text, path); + assert_eq!(title, Some("my_document".to_string())); + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 708b376..7f0d875 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,7 +62,7 @@ Open/edit/save files in locations safely and predictably. - Debounced save in Elm (`Msg::EditorChanged` → schedule save `Cmd`) - Save status machine: `Idle | Dirty | Saving | Saved | Error` -## Markdown engine (Rust): parse, render, and metadata extraction +## Markdown engine (Rust) A "thorough" Markdown pipeline: deterministic HTML, structured metadata, and source mapping support. @@ -83,19 +83,16 @@ A "thorough" Markdown pipeline: deterministic HTML, structured metadata, and sou - Enable `render.sourcepos` to include source position attributes in HTML output (useful for editor↔preview sync). ([Docs.rs][6]) 4. **Metadata extraction** - Extract: - - - title (first H1) - - headings outline (H1–H6) - - link refs - - task items count - - word count estimate + - title (first H1) + - headings outline (H1–H6) + - link refs + - task items count + - word count estimate 5. **Golden tests** - - For each fixture: - - - Markdown → HTML exact match - - Outline JSON match - - Sourcepos presence for block nodes + - Markdown → HTML exact match + - Outline JSON match + - Sourcepos presence for block nodes ## Editor MVP (React): CodeMirror 6 + Markdown language + Elm integration @@ -127,19 +124,15 @@ High-quality Markdown rendering with predictable safety and a stable sync model. ### Tasks 1. **Backend preview command** - - `markdown_render(doc_ref, text, profile) -> { html, outline, diagnostics }` - Cache rendered HTML by `(doc_id, content_hash, profile)` 2. **Frontend preview** - - Render HTML in a sandboxed container (no inline scripts) - Apply CSS theme consistent with editor 3. **Sync strategy** - - Use `data-sourcepos` (from Comrak sourcepos) to map: - - - editor cursor line → preview anchor - - preview scroll → nearest sourcepos line + - editor cursor line → preview anchor + - preview scroll → nearest sourcepos line - Implement coarse sync first (block-level), refine later. ([Docs.rs][6]) ## Indexing + search (SQLite FTS), driven by watcher + reconciliation @@ -149,55 +142,45 @@ Fast global search across locations with correct incremental updates. ### Tasks 1. **SQLite schema** - - `docs(location_id, rel_path, mtime, size, hash, title, updated_at, …)` - `docs_fts(content, tokenize=...)` 2. **Index update pipeline** - - On save: update FTS row - On watcher event: queue reindex for changed file - On startup: reconcile catalog vs filesystem (drift repair) 3. **Search API** - - `search(query, filters, limit) -> SearchHit[]` with snippets ## Markdown "thoroughness" upgrades (extensions, diagnostics, export) Make Markdown handling feel professional and predictable for writers. -### Tasks (pick the ones you want "first-class") +### Tasks 1. **Front matter** - Parse YAML/TOML front matter (Comrak supports front matter extension; decide format). ([GitHub][5]) 2. **Footnotes, definition lists, tables** - - Enable and test; ensure preview styles cover them. ([GitHub][5]) 3. **Diagnostics** - - Lint-like warnings: - - - duplicate heading IDs - - malformed links - - mixed line endings + - duplicate heading IDs + - malformed links + - mixed line endings 4. **Export** - - HTML export (direct from Rust renderer) - - PDF export (later; separate milestone due to complexity) + - PDF export ## Hardening ### Tasks 1. **Security** - - Prove: no fs access outside scoped locations - Prove: preview cannot execute scripts (CSP / sandbox strategy) 2. **Perf** - - Incremental render scheduling (debounce, worker thread) - Indexing in background with progress events 3. **Recovery** - - Corrupt settings/workspace → app resets safely - Missing location root → UI prompts to relink/remove diff --git a/justfile b/justfile new file mode 100644 index 0000000..ef737cd --- /dev/null +++ b/justfile @@ -0,0 +1,23 @@ +format: + cargo fmt + +alias fmt := format + +# Lint AND fix +lint: + cargo clippy --fix --allow-dirty + +compile: + cargo check + +# Overall code quality check +check: format lint compile test + +# Finds comments +find-comments: + rg -n --pcre2 '^\s*//(?![!/])' -g '*.rs' + +alias cmt := find-comments + +test: + cargo test --quiet diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 873e88a..d3cc5e7 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager, State}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_fs::FsExt; -use writer_core::{AppError, BackendEvent, CommandResult, LocationDescriptor, LocationId}; +use writer_core::{ + AppError, BackendEvent, CommandResult, DocContent, DocId, DocListOptions, DocMeta, LocationDescriptor, LocationId, + SaveResult, +}; use writer_store::Store; /// Application state shared across commands @@ -173,3 +176,159 @@ pub fn reconcile_locations(app: &AppHandle) -> Result<(), AppError> { Ok(()) } + +/// Lists documents in a location +#[tauri::command] +pub fn doc_list( + state: State<'_, AppState>, location_id: i64, options: Option, +) -> Result>, ()> { + let id = LocationId(location_id); + tracing::debug!("Listing documents for location: id={}", location_id); + + match state.store.doc_list(id, options) { + Ok(docs) => { + tracing::debug!("Found {} documents in location {}", docs.len(), location_id); + Ok(CommandResult::ok(docs)) + } + Err(e) => { + tracing::error!("Failed to list documents: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +/// Opens a document by location_id and relative path +#[tauri::command] +pub fn doc_open( + state: State<'_, AppState>, location_id: i64, rel_path: String, +) -> Result, ()> { + let location_id = LocationId(location_id); + let rel_path = PathBuf::from(&rel_path); + + tracing::debug!("Opening document: location={:?}, path={:?}", location_id, rel_path); + + match DocId::new(location_id, rel_path) { + Ok(doc_id) => match state.store.doc_open(&doc_id) { + Ok(content) => { + tracing::info!( + "Document opened successfully: location={:?}, size={} bytes", + doc_id.location_id, + content.meta.size_bytes + ); + Ok(CommandResult::ok(content)) + } + Err(e) => { + tracing::error!("Failed to open document: {}", e); + Ok(CommandResult::err(e)) + } + }, + Err(e) => { + tracing::error!("Invalid document reference: {}", e); + Ok(CommandResult::err(AppError::invalid_path(format!( + "Invalid path: {}", + e + )))) + } + } +} + +/// Saves a document with atomic write semantics +#[tauri::command] +pub fn doc_save( + app: AppHandle, state: State<'_, AppState>, location_id: i64, rel_path: String, text: String, +) -> Result, ()> { + let location_id = LocationId(location_id); + let rel_path = PathBuf::from(&rel_path); + + tracing::debug!( + "Saving document: location={:?}, path={:?}, size={} bytes", + location_id, + rel_path, + text.len() + ); + + match DocId::new(location_id, rel_path) { + Ok(doc_id) => match state.store.doc_save(&doc_id, &text, None) { + Ok(result) => { + if result.conflict_detected { + tracing::warn!( + "Conflicted copy detected: location={:?}, path={:?}", + doc_id.location_id, + doc_id.rel_path + ); + + let event = BackendEvent::ConflictDetected { + location_id: doc_id.location_id, + rel_path: doc_id.rel_path.clone(), + conflict_filename: doc_id + .rel_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()), + }; + + if let Err(e) = app.emit("backend-event", event) { + tracing::error!("Failed to emit conflict event: {}", e); + } + } + + tracing::info!( + "Document saved successfully: location={:?}, size={} bytes", + doc_id.location_id, + text.len() + ); + + Ok(CommandResult::ok(result)) + } + Err(e) => { + tracing::error!("Failed to save document: {}", e); + Ok(CommandResult::err(e)) + } + }, + Err(e) => { + tracing::error!("Invalid document reference: {}", e); + Ok(CommandResult::err(AppError::invalid_path(format!( + "Invalid path: {}", + e + )))) + } + } +} + +/// Checks if a document exists in a location +#[tauri::command] +pub fn doc_exists(state: State<'_, AppState>, location_id: i64, rel_path: String) -> Result, ()> { + let location_id = LocationId(location_id); + let rel_path = PathBuf::from(&rel_path); + + tracing::debug!( + "Checking document existence: location={:?}, path={:?}", + location_id, + rel_path + ); + + match DocId::new(location_id, rel_path) { + Ok(doc_id) => match state.store.location_get(doc_id.location_id) { + Ok(Some(location)) => { + let full_path = doc_id.resolve(&location.root_path); + let exists = full_path.exists(); + Ok(CommandResult::ok(exists)) + } + Ok(None) => { + tracing::warn!("Location not found: {:?}", doc_id.location_id); + Ok(CommandResult::err(AppError::not_found("Location not found"))) + } + Err(e) => { + tracing::error!("Failed to check location: {}", e); + Ok(CommandResult::err(e)) + } + }, + Err(e) => { + tracing::error!("Invalid document reference: {}", e); + Ok(CommandResult::err(AppError::invalid_path(format!( + "Invalid path: {}", + e + )))) + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bf0a2a4..1214a31 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,10 @@ use tauri::Manager; mod commands; -use commands::{location_add_via_dialog, location_list, location_remove, location_validate}; +use commands::{ + doc_exists, doc_list, doc_open, doc_save, location_add_via_dialog, location_list, location_remove, + location_validate, +}; pub use commands::AppState; @@ -42,7 +45,11 @@ pub fn run() { location_add_via_dialog, location_list, location_remove, - location_validate + location_validate, + doc_list, + doc_open, + doc_save, + doc_exists ]) .run(tauri::generate_context!()) .expect("error while running tauri application");