diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d194800..6ef8ba3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3240,6 +3240,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-global-shortcut", "tauri-plugin-opener", + "tempfile", "tokio", "url", "uuid", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 822ec3c..1e028cb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,9 +36,11 @@ sha2 = "0.10" rand = "0.8" dotenvy = "0.15" +[dev-dependencies] +tempfile = "3" + [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.26" objc = "0.2" core-graphics = "0.24" core-foundation = "0.10" - diff --git a/src-tauri/src/data/local.rs b/src-tauri/src/data/local.rs index 6c4ac50..864f6bf 100644 --- a/src-tauri/src/data/local.rs +++ b/src-tauri/src/data/local.rs @@ -1,7 +1,9 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use uuid::Uuid; use super::store::DataStore; @@ -35,6 +37,14 @@ pub struct LocalDataStore { user_id: Option, } +#[derive(Debug, Clone)] +struct PromptFileCandidate { + folder: String, + filename: String, + content: String, + updated: String, +} + impl LocalDataStore { /// Create a new LocalDataStore for anonymous (pre-auth) usage pub fn new() -> Self { @@ -193,19 +203,42 @@ impl LocalDataStore { fs::write(self.index_path(), content).map_err(|e| format!("Failed to write index: {}", e)) } - /// Synchronous index load (public for SyncService) - pub fn load_index_sync(&self) -> Result { + /// Load existing local data without seeding sample prompts. + /// + /// This is used by sync safety checks so we can inspect actual on-disk + /// user data without creating synthetic sample prompts as a side effect. + pub(crate) fn load_existing_index_sync(&self) -> Result, String> { let index_path = self.index_path(); - if !index_path.exists() { - return self.seed_sample_prompts(); + if index_path.exists() { + let content = fs::read_to_string(&index_path) + .map_err(|e| format!("Failed to read index: {}", e))?; + + let mut index: PromptIndex = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse index: {}", e))?; + + let recovered = self.recover_orphaned_prompts(&mut index)?; + if recovered > 0 { + self.save_index_sync(&index)?; + } + + return Ok(Some(index)); } - let content = - fs::read_to_string(&index_path).map_err(|e| format!("Failed to read index: {}", e))?; + let rebuilt = self.rebuild_index_from_prompt_files()?; + if let Some(index) = rebuilt { + self.save_index_sync(&index)?; + return Ok(Some(index)); + } - let index: PromptIndex = - serde_json::from_str(&content).map_err(|e| format!("Failed to parse index: {}", e))?; + Ok(None) + } + + /// Synchronous index load (public for SyncService) + pub fn load_index_sync(&self) -> Result { + let Some(index) = self.load_existing_index_sync()? else { + return self.seed_sample_prompts(); + }; // Only seed if this is a fresh install (never seeded before) // Don't reseed if user intentionally deleted all prompts @@ -216,6 +249,82 @@ impl LocalDataStore { Ok(index) } + fn rebuild_index_from_prompt_files(&self) -> Result, String> { + let prompts_dir = self.prompts_dir(); + if !prompts_dir.exists() { + return Ok(None); + } + + let mut index = PromptIndex::default(); + index.seeded = true; + + let recovered = self.recover_orphaned_prompts(&mut index)?; + if recovered == 0 { + return Ok(None); + } + + Ok(Some(index)) + } + + fn recover_orphaned_prompts(&self, index: &mut PromptIndex) -> Result { + let prompts_dir = self.prompts_dir(); + if !prompts_dir.exists() { + return Ok(0); + } + + let indexed_paths: HashSet<(String, String)> = index + .prompts + .iter() + .map(|prompt| (prompt.folder.clone(), prompt.filename.clone())) + .collect(); + + let mut deduped_orphans: HashMap = HashMap::new(); + for candidate in collect_prompt_file_candidates(&prompts_dir)? { + let candidate_key = (candidate.folder.clone(), candidate.filename.clone()); + if indexed_paths.contains(&candidate_key) { + continue; + } + + let dedupe_key = dedupe_key_for_candidate(&candidate); + match deduped_orphans.get(&dedupe_key) { + Some(existing) if !prefer_candidate(&candidate, existing) => {} + _ => { + deduped_orphans.insert(dedupe_key, candidate); + } + } + } + + let recovered_count = deduped_orphans.len(); + let mut recovered: Vec = deduped_orphans.into_values().collect(); + recovered.sort_by(|a, b| { + a.folder + .cmp(&b.folder) + .then_with(|| a.filename.cmp(&b.filename)) + }); + + for candidate in recovered { + if !index.folders.contains(&candidate.folder) { + index.folders.push(candidate.folder.clone()); + } + + index.prompts.push(PromptMetadata { + id: recovered_prompt_id(&candidate.folder, &candidate.filename), + name: infer_prompt_name(&candidate.filename, &candidate.content), + folder: candidate.folder, + description: String::new(), + filename: candidate.filename, + use_count: 0, + last_used: None, + created: candidate.updated.clone(), + updated: candidate.updated, + icon: None, + color: None, + }); + } + + Ok(recovered_count) + } + /// Write prompt content synchronously (public for SyncService) pub fn write_prompt_content_sync( &self, @@ -902,6 +1011,126 @@ fn slugify(name: &str) -> String { .join("-") } +fn collect_prompt_file_candidates(prompts_dir: &Path) -> Result, String> { + let mut candidates = Vec::new(); + + for entry in fs::read_dir(prompts_dir) + .map_err(|e| format!("Failed to read prompts directory {:?}: {}", prompts_dir, e))? + { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let folder_path = entry.path(); + if !folder_path.is_dir() { + continue; + } + + let Some(folder_name) = folder_path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + for file_entry in fs::read_dir(&folder_path) + .map_err(|e| format!("Failed to read folder {:?}: {}", folder_path, e))? + { + let file_entry = file_entry.map_err(|e| format!("Failed to read file entry: {}", e))?; + let path = file_entry.path(); + if !path.is_file() || !is_markdown_file(&path) { + continue; + } + + let Some(filename) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + let content = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read prompt file {:?}: {}", path, e))?; + let updated = prompt_timestamp_for_path(&path)?; + + candidates.push(PromptFileCandidate { + folder: folder_name.to_string(), + filename: filename.to_string(), + content, + updated, + }); + } + } + + Ok(candidates) +} + +fn is_markdown_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("md")) + .unwrap_or(false) +} + +fn prompt_timestamp_for_path(path: &Path) -> Result { + let metadata = fs::metadata(path) + .map_err(|e| format!("Failed to read metadata for {:?}: {}", path, e))?; + + Ok(metadata + .modified() + .ok() + .map(DateTime::::from) + .unwrap_or_else(Utc::now) + .to_rfc3339()) +} + +fn dedupe_key_for_candidate(candidate: &PromptFileCandidate) -> String { + let mut hasher = Sha256::new(); + hasher.update(candidate.filename.as_bytes()); + hasher.update([0]); + hasher.update(candidate.content.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn prefer_candidate(candidate: &PromptFileCandidate, existing: &PromptFileCandidate) -> bool { + candidate.folder != "uncategorized" && existing.folder == "uncategorized" +} + +fn recovered_prompt_id(folder: &str, filename: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(folder.as_bytes()); + hasher.update([0]); + hasher.update(filename.as_bytes()); + let digest = format!("{:x}", hasher.finalize()); + format!("recovered-{}", &digest[..16]) +} + +fn infer_prompt_name(filename: &str, content: &str) -> String { + if let Some(heading) = content + .lines() + .map(str::trim) + .filter(|line| line.starts_with('#')) + .map(|line| line.trim_start_matches('#').trim()) + .find(|line| !line.is_empty()) + { + return heading.to_string(); + } + + humanize_filename(filename) +} + +fn humanize_filename(filename: &str) -> String { + let stem = filename.strip_suffix(".md").unwrap_or(filename); + stem.split(['-', '_', ' ']) + .filter(|part| !part.is_empty()) + .map(capitalize_word) + .collect::>() + .join(" ") +} + +fn capitalize_word(word: &str) -> String { + let mut chars = word.chars(); + match chars.next() { + Some(first) => { + let mut output = first.to_uppercase().collect::(); + output.push_str(chars.as_str()); + output + } + None => String::new(), + } +} + /// Recursively copy a directory and its contents fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<(), String> { fs::create_dir_all(dst) @@ -924,3 +1153,101 @@ fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn load_index_sync_reimports_orphaned_prompt_files() { + let temp_dir = tempdir().unwrap(); + let store = LocalDataStore::with_data_dir(temp_dir.path().to_path_buf()); + + fs::create_dir_all(temp_dir.path().join("prompts").join("code")).unwrap(); + fs::write( + temp_dir.path().join("prompts").join("code").join("debug.md"), + "# Debug\n\nTrace the execution path.", + ) + .unwrap(); + + store + .save_index_sync(&PromptIndex { + prompts: Vec::new(), + folders: vec!["uncategorized".to_string()], + folder_meta: None, + seeded: true, + }) + .unwrap(); + + let index = store.load_index_sync().unwrap(); + + assert_eq!(index.prompts.len(), 1); + assert_eq!(index.prompts[0].name, "Debug"); + assert_eq!(index.prompts[0].folder, "code"); + assert_eq!(index.prompts[0].filename, "debug.md"); + assert!(index.folders.contains(&"code".to_string())); + } + + #[test] + fn load_index_sync_prefers_non_uncategorized_duplicate_files() { + let temp_dir = tempdir().unwrap(); + let store = LocalDataStore::with_data_dir(temp_dir.path().to_path_buf()); + + fs::create_dir_all(temp_dir.path().join("prompts").join("code")).unwrap(); + fs::create_dir_all(temp_dir.path().join("prompts").join("uncategorized")).unwrap(); + + let content = "# Parallel\n\nCoordinate multiple workstreams."; + fs::write( + temp_dir.path().join("prompts").join("code").join("parallel.md"), + content, + ) + .unwrap(); + fs::write( + temp_dir.path() + .join("prompts") + .join("uncategorized") + .join("parallel.md"), + content, + ) + .unwrap(); + + store + .save_index_sync(&PromptIndex { + prompts: Vec::new(), + folders: vec!["uncategorized".to_string()], + folder_meta: None, + seeded: true, + }) + .unwrap(); + + let index = store.load_index_sync().unwrap(); + + assert_eq!(index.prompts.len(), 1); + assert_eq!(index.prompts[0].folder, "code"); + assert_eq!(index.prompts[0].name, "Parallel"); + } + + #[test] + fn load_index_sync_rebuilds_missing_index_from_prompt_files() { + let temp_dir = tempdir().unwrap(); + let store = LocalDataStore::with_data_dir(temp_dir.path().to_path_buf()); + + fs::create_dir_all(temp_dir.path().join("prompts").join("writing")).unwrap(); + fs::write( + temp_dir.path() + .join("prompts") + .join("writing") + .join("improve-writing.md"), + "Please improve the following text.", + ) + .unwrap(); + + let index = store.load_index_sync().unwrap(); + + assert_eq!(index.prompts.len(), 1); + assert_eq!(index.prompts[0].name, "Improve Writing"); + assert_eq!(index.prompts[0].folder, "writing"); + assert!(temp_dir.path().join("index.json").exists()); + } +} diff --git a/src-tauri/src/data/sync.rs b/src-tauri/src/data/sync.rs index 4cd1347..928da54 100644 --- a/src-tauri/src/data/sync.rs +++ b/src-tauri/src/data/sync.rs @@ -7,8 +7,9 @@ //! - Migration from anonymous to user storage on first login //! - Download/upload operations for explicit sync -use std::sync::{Arc, RwLock}; use async_trait::async_trait; +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; use super::firestore::{FirestoreClient, UserMeta}; use super::local::LocalDataStore; @@ -184,7 +185,7 @@ impl SyncService { /// /// SAFETY: Refuses to replace local data with empty cloud data if local has prompts pub async fn sync_from_firestore(&self) -> Result<(), String> { - let (user_id, id_token, firestore, local_prompt_count) = { + let (user_id, id_token, firestore, local_index) = { let state = self.state.read().unwrap(); let user_id = state.user_id.clone() @@ -193,16 +194,21 @@ impl SyncService { .ok_or("No auth token")?; let firestore = state.firestore.clone(); - // Check how many prompts we have locally (for safety check) - let local_index = state.local_store.load_index_sync().ok(); - let local_prompt_count = local_index.map(|i| i.prompts.len()).unwrap_or(0); + // Load real on-disk data without seeding sample prompts so safety + // checks only compare actual user content. + let local_index = state.local_store.load_existing_index_sync().ok().flatten(); - (user_id, id_token, firestore, local_prompt_count) + (user_id, id_token, firestore, local_index) }; // Download from Firestore (outside the lock) let (index, prompts) = firestore.download_all(&user_id, &id_token).await?; + let local_prompt_count = local_index + .as_ref() + .map(|local| local.prompts.len()) + .unwrap_or(0); + // SAFETY: Don't replace existing local data with empty cloud data // This prevents accidental data loss when cloud is empty or auth fails silently if index.prompts.is_empty() && local_prompt_count > 0 { @@ -213,6 +219,20 @@ impl SyncService { return Ok(()); // Silently succeed - don't wipe local data } + // SAFETY: Don't replace local data if the cloud snapshot would remove + // any existing local prompt IDs. This prevents partial cloud state from + // hiding prompts that still exist on disk. + if let Some(local) = &local_index { + if cloud_would_remove_local_prompts(local, &index) { + eprintln!( + "[SYNC SAFETY] Cloud snapshot is missing local prompts (local: {}, cloud: {}). Skipping sync to prevent data loss.", + local.prompts.len(), + index.prompts.len() + ); + return Ok(()); + } + } + // Save to local (re-acquire lock) { let state = self.state.read().unwrap(); @@ -421,6 +441,22 @@ impl DataStore for SyncService { /// Type alias for thread-safe SyncService pub type SyncServiceState = Arc; +fn cloud_would_remove_local_prompts(local: &PromptIndex, cloud: &PromptIndex) -> bool { + if local.prompts.is_empty() { + return false; + } + + let cloud_ids: HashSet<&str> = cloud + .prompts + .iter() + .map(|prompt| prompt.id.as_str()) + .collect(); + + local.prompts + .iter() + .any(|prompt| !cloud_ids.contains(prompt.id.as_str())) +} + #[cfg(test)] mod tests { use super::*; @@ -460,4 +496,96 @@ mod tests { // Can't directly test the token, but should not panic assert!(service.is_authenticated()); } + + #[test] + fn test_cloud_guard_detects_missing_local_prompt_ids() { + let local = PromptIndex { + prompts: vec![ + PromptMetadata { + id: "local-1".to_string(), + name: "Local".to_string(), + folder: "code".to_string(), + description: String::new(), + filename: "local.md".to_string(), + use_count: 0, + last_used: None, + created: "2025-01-01T00:00:00Z".to_string(), + updated: "2025-01-01T00:00:00Z".to_string(), + icon: None, + color: None, + }, + PromptMetadata { + id: "local-2".to_string(), + name: "Second".to_string(), + folder: "code".to_string(), + description: String::new(), + filename: "second.md".to_string(), + use_count: 0, + last_used: None, + created: "2025-01-01T00:00:00Z".to_string(), + updated: "2025-01-01T00:00:00Z".to_string(), + icon: None, + color: None, + }, + ], + folders: vec!["code".to_string()], + folder_meta: None, + seeded: true, + }; + + let cloud = PromptIndex { + prompts: vec![local.prompts[0].clone()], + folders: vec!["code".to_string()], + folder_meta: None, + seeded: true, + }; + + assert!(cloud_would_remove_local_prompts(&local, &cloud)); + } + + #[test] + fn test_cloud_guard_allows_superset_cloud_snapshot() { + let local = PromptIndex { + prompts: vec![PromptMetadata { + id: "local-1".to_string(), + name: "Local".to_string(), + folder: "code".to_string(), + description: String::new(), + filename: "local.md".to_string(), + use_count: 0, + last_used: None, + created: "2025-01-01T00:00:00Z".to_string(), + updated: "2025-01-01T00:00:00Z".to_string(), + icon: None, + color: None, + }], + folders: vec!["code".to_string()], + folder_meta: None, + seeded: true, + }; + + let cloud = PromptIndex { + prompts: vec![ + local.prompts[0].clone(), + PromptMetadata { + id: "cloud-2".to_string(), + name: "Cloud".to_string(), + folder: "writing".to_string(), + description: String::new(), + filename: "cloud.md".to_string(), + use_count: 0, + last_used: None, + created: "2025-01-01T00:00:00Z".to_string(), + updated: "2025-01-01T00:00:00Z".to_string(), + icon: None, + color: None, + }, + ], + folders: vec!["code".to_string(), "writing".to_string()], + folder_meta: None, + seeded: true, + }; + + assert!(!cloud_would_remove_local_prompts(&local, &cloud)); + } }