use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef}; use crate::models::Project; /// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it. /// /// Derived from the file rather than from `dirs::data_dir()` so the marker /// always lands in the directory the store is actually using — and so the /// writer can be tested against a temp directory. fn corrupt_marker_for(file_path: &Path) -> PathBuf { file_path.with_extension("json.corrupt") } /// Keep the bytes of an unparseable `projects.json`, and record that it /// happened. /// /// **The existing `.bak` is never overwritten.** A second corruption used to /// clobber the first, and the first is the valuable one: it was taken before /// the app rewrote the file with whatever it had in memory, so it is the only /// copy that can still hold the full project list. Later ones are copies of an /// already-degraded file and get a timestamped name. fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime) { let first = file_path.with_extension("json.bak"); let backup = if first.exists() { file_path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S"))) } else { first }; if !backup.exists() { if let Err(e) = fs::copy(file_path, &backup) { log::error!("Failed to back up corrupted projects.json: {}", e); } else { log::error!( "A copy of the unreadable projects.json was kept at {}", backup.display() ); } } // Sticky, and written even though nothing in the app reads it back on this // branch: the Disk panel's `project_store_trust` was the reader and went to // `hold/disk-and-dragout`. The marker stays because it is the only durable // record that a project list was lost — the in-memory symptom does not // survive the next save — and because re-deriving *when* it happened is // impossible after the fact. let marker = corrupt_marker_for(file_path); if marker.exists() { // The *first* corruption is the one that dates the loss. return; } if let Err(e) = fs::write(&marker, now.to_rfc3339()) { log::error!( "Could not record the corrupt projects.json load at {}: {} — nothing will be able to \ tell later that the project list was incomplete", marker.display(), e ); } } pub struct ProjectsStore { projects: Mutex>, file_path: PathBuf, } impl ProjectsStore { pub fn new() -> Result { let data_dir = dirs::data_dir() .ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())? .join("triple-c"); fs::create_dir_all(&data_dir).ok(); let file_path = data_dir.join("projects.json"); let (projects, needs_save) = if file_path.exists() { match fs::read_to_string(&file_path) { Ok(data) => { // First try to parse as Vec to run migration match serde_json::from_str::>(&data) { Ok(raw_values) => { let mut migrated = false; let migrated_values: Vec = raw_values .into_iter() .map(|v| { let has_path = v.as_object().map_or(false, |o| o.contains_key("path") && !o.contains_key("paths")); if has_path { migrated = true; } crate::models::Project::migrate_from_value(v) }) .collect(); // Now deserialize the migrated values let json_str = serde_json::to_string(&migrated_values).unwrap_or_default(); match serde_json::from_str::>(&json_str) { Ok(parsed) => (parsed, migrated), Err(e) => { log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e); record_corrupt_load(&file_path, &chrono::Utc::now()); (Vec::new(), false) } } } Err(e) => { log::error!("Failed to parse projects.json: {}. Starting with empty list.", e); record_corrupt_load(&file_path, &chrono::Utc::now()); (Vec::new(), false) } } } Err(e) => { log::error!("Failed to read projects.json: {}", e); (Vec::new(), false) } } } else { (Vec::new(), false) }; // Reconcile stale transient statuses: on a cold app start no Docker // operations can be in flight, so Starting/Stopping are always stale. // Running/Error are left as-is and reconciled against Docker later // via the reconcile_project_statuses command. let mut projects = projects; let mut needs_save = needs_save; for p in projects.iter_mut() { match p.status { crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => { log::warn!( "Reconciling stale '{}' status for project '{}' ({}) → Stopped", serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'), p.name, p.id ); p.status = crate::models::ProjectStatus::Stopped; p.updated_at = chrono::Utc::now().to_rfc3339(); needs_save = true; } _ => {} } } let store = Self { projects: Mutex::new(projects), file_path, }; // Persist migrated/reconciled format back to disk if needs_save { log::info!("Saving reconciled/migrated projects.json to disk"); let projects = store.lock(); if let Err(e) = store.save(&projects) { log::error!("Failed to save projects: {}", e); } } Ok(store) } fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { self.projects.lock().unwrap_or_else(|e| e.into_inner()) } fn save(&self, projects: &[Project]) -> Result<(), String> { let data = serde_json::to_string_pretty(projects) .map_err(|e| format!("Failed to serialize projects: {}", e))?; // Atomic write: write to temp file, then rename let tmp_path = self.file_path.with_extension("json.tmp"); fs::write(&tmp_path, data) .map_err(|e| format!("Failed to write temp projects file: {}", e))?; fs::rename(&tmp_path, &self.file_path) .map_err(|e| format!("Failed to rename projects file: {}", e))?; Ok(()) } pub fn list(&self) -> Vec { self.lock().clone() } pub fn get(&self, id: &str) -> Option { self.lock().iter().find(|p| p.id == id).cloned() } pub fn add(&self, project: Project) -> Result { let mut projects = self.lock(); let cloned = project.clone(); projects.push(project); self.save(&projects)?; Ok(cloned) } pub fn update(&self, updated: Project) -> Result { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == updated.id) { *p = updated.clone(); self.save(&projects)?; Ok(updated) } else { Err(format!("Project {} not found", updated.id)) } } /// Replace a project with `updated`, after `restore` has copied onto it /// the fields the store owns from the record *as stored under the lock*. /// `update_project` restores from a copy it read earlier, so an install /// or a status change landing in between would otherwise be written over /// (re-review round 2). pub fn update_restoring( &self, mut updated: Project, restore: impl FnOnce(&mut Project, &Project), ) -> Result { let mut projects = self.lock(); let p = projects .iter_mut() .find(|p| p.id == updated.id) .ok_or_else(|| format!("Project {} not found", updated.id))?; restore(&mut updated, p); *p = updated.clone(); self.save(&projects)?; Ok(updated) } pub fn remove(&self, id: &str) -> Result<(), String> { let mut projects = self.lock(); let initial_len = projects.len(); projects.retain(|p| p.id != id); if projects.len() == initial_len { return Err(format!("Project {} not found", id)); } self.save(&projects)?; Ok(()) } pub fn update_status(&self, id: &str, status: crate::models::ProjectStatus) -> Result<(), String> { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == id) { p.status = status; p.updated_at = chrono::Utc::now().to_rfc3339(); self.save(&projects)?; Ok(()) } else { Err(format!("Project {} not found", id)) } } /// Granular setter for the auth bridge opt-in, so toggling it can't clobber /// concurrent edits to the rest of the project record. pub fn set_auth_bridge_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) { p.auth_bridge_enabled = enabled; p.updated_at = chrono::Utc::now().to_rfc3339(); self.save(&projects)?; Ok(()) } else { Err(format!("Project {} not found", project_id)) } } /// Granular setter for the browser view's opt-in, for the same reason /// [`Self::set_auth_bridge_enabled`] has one: the pane toggles this while /// the Config tab may be holding an older copy of the whole record. pub fn set_browser_view_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) { p.browser_view_enabled = enabled; p.updated_at = chrono::Utc::now().to_rfc3339(); self.save(&projects)?; Ok(()) } else { Err(format!("Project {} not found", project_id)) } } /// Read-modify-write of one project's marketplace installs and opt-outs /// under the store's lock, touching nothing else (PR review #2): the /// marketplace commands must not write back a whole record read before a /// start changed its status or container id. When `f` fails nothing is /// saved. Returns `f`'s value and the saved project. pub fn update_marketplace_fields( &self, project_id: &str, f: impl FnOnce( &mut Vec, &mut Vec, ) -> Result, ) -> Result<(T, Project), String> { let mut projects = self.lock(); let p = projects .iter_mut() .find(|p| p.id == project_id) .ok_or_else(|| format!("Project {} not found", project_id))?; let mut installs = p.marketplace_installs.clone(); let mut disabled = p.marketplace_disabled.clone(); let out = f(&mut installs, &mut disabled)?; p.marketplace_installs = installs; p.marketplace_disabled = disabled; p.updated_at = chrono::Utc::now().to_rfc3339(); let saved = p.clone(); self.save(&projects)?; Ok((out, saved)) } /// [`Self::update_marketplace_fields`] over every project at once, in one /// save. Projects `f` leaves as they were are not touched at all. pub fn update_all_marketplace_fields( &self, mut f: impl FnMut(&mut Vec, &mut Vec), ) -> Result<(), String> { let mut projects = self.lock(); let mut changed = false; for p in projects.iter_mut() { let mut installs = p.marketplace_installs.clone(); let mut disabled = p.marketplace_disabled.clone(); f(&mut installs, &mut disabled); if installs != p.marketplace_installs || disabled != p.marketplace_disabled { p.marketplace_installs = installs; p.marketplace_disabled = disabled; p.updated_at = chrono::Utc::now().to_rfc3339(); changed = true; } } if changed { self.save(&projects)?; } Ok(()) } pub fn set_container_id(&self, project_id: &str, container_id: Option) -> Result<(), String> { let mut projects = self.lock(); if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) { p.container_id = container_id; p.updated_at = chrono::Utc::now().to_rfc3339(); self.save(&projects)?; Ok(()) } else { Err(format!("Project {} not found", project_id)) } } } #[cfg(test)] mod tests { use super::*; fn temp_dir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "triple-c-store-{}-{}", tag, uuid::Uuid::new_v4().simple() )); fs::create_dir_all(&dir).expect("temp dir"); dir } #[test] fn a_corrupt_load_leaves_a_marker_the_next_write_cannot_erase() { // H-3, the whole chain in one test. `ProjectsStore::new()` swallows an // unparseable file into an empty list *without rewriting it*, and the // first `save()` after that — as little as `update_status()` — writes // `[{one project}]` over it. Everything the old guard keyed on ("the // list is empty and the file exists") is gone at that point, while // every *other* project's volumes are still on the daemon claimed by // nobody. let dir = temp_dir("corrupt"); let file = dir.join("projects.json"); fs::write(&file, "{ this is not a project list").unwrap(); let now = chrono::Utc::now(); record_corrupt_load(&file, &now); let marker = corrupt_marker_for(&file); assert!(marker.exists(), "the corrupt load must be recorded on disk"); assert_eq!(fs::read_to_string(&marker).unwrap(), now.to_rfc3339()); assert!( dir.join("projects.json.bak").exists(), "the unreadable bytes must be kept" ); // The write that used to erase the evidence. The marker is a separate // file, so it does not care. fs::write(&file, r#"[{"id":"the-one-project-started-since"}]"#).unwrap(); assert!(marker.exists()); fs::remove_dir_all(&dir).ok(); } #[test] fn a_second_corruption_keeps_the_first_copy_and_the_first_date() { // The `.bak` used to be a fixed name, so a second corruption clobbered // the first — and the first is the only copy taken before the app // rewrote the file with whatever it had in memory, i.e. the only one // that can still hold the full project list. let dir = temp_dir("second"); let file = dir.join("projects.json"); fs::write(&file, "original bytes").unwrap(); let first = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&chrono::Utc); record_corrupt_load(&file, &first); fs::write(&file, "degraded bytes").unwrap(); let second = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z") .unwrap() .with_timezone(&chrono::Utc); record_corrupt_load(&file, &second); assert_eq!( fs::read_to_string(dir.join("projects.json.bak")).unwrap(), "original bytes", "the first copy must survive the second corruption" ); assert_eq!( fs::read_to_string(dir.join("projects.json.corrupt-20260601-000000.bak")).unwrap(), "degraded bytes" ); // And the marker still dates the loss from the first failure, which is // when the project list actually stopped being complete. assert_eq!( fs::read_to_string(corrupt_marker_for(&file)).unwrap(), first.to_rfc3339() ); fs::remove_dir_all(&dir).ok(); } /// A store over a temp file. `new()` insists on `dirs::data_dir()`, which /// is the real user's; the fields are right here, so the granular setters /// can be exercised against a directory the test owns. fn store_over(dir: &Path, projects: Vec) -> ProjectsStore { ProjectsStore { projects: Mutex::new(projects), file_path: dir.join("projects.json"), } } #[test] fn the_browser_view_flag_is_written_to_disk_and_read_back() { // The point of the whole exercise: before this the flag lived in a // `HashSet` in `BrowserViewManager` and an app restart forgot it. let dir = temp_dir("browser-view"); let project = Project::new("demo".to_string(), Vec::new()); let id = project.id.clone(); let store = store_over(&dir, vec![project]); assert!(!store.get(&id).unwrap().browser_view_enabled); store.set_browser_view_enabled(&id, true).unwrap(); assert!(store.get(&id).unwrap().browser_view_enabled); // Durable, not merely in memory — this is what a restart reads. let on_disk: Vec = serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap(); assert!(on_disk[0].browser_view_enabled); store.set_browser_view_enabled(&id, false).unwrap(); assert!(!store.get(&id).unwrap().browser_view_enabled); assert!(store.set_browser_view_enabled("no-such-project", true).is_err()); fs::remove_dir_all(&dir).ok(); } #[test] fn a_granular_toggle_leaves_every_other_field_alone() { // Why these setters exist at all: the Config tab can be holding an // older copy of the whole record while the pane flips one flag. let dir = temp_dir("granular"); let mut project = Project::new("demo".to_string(), Vec::new()); project.claude_instructions = Some("keep me".to_string()); let id = project.id.clone(); let store = store_over(&dir, vec![project]); store.set_browser_view_enabled(&id, true).unwrap(); store.set_auth_bridge_enabled(&id, false).unwrap(); let saved = store.get(&id).unwrap(); assert_eq!(saved.claude_instructions.as_deref(), Some("keep me")); assert!(saved.browser_view_enabled); assert!(!saved.auth_bridge_enabled); fs::remove_dir_all(&dir).ok(); } fn market_install(key: &str) -> crate::models::marketplace::MarketplaceInstall { crate::models::marketplace::MarketplaceInstall { marketplace_id: "m1".into(), kind: crate::models::marketplace::ItemKind::Agent, key: key.into(), commit: "a".repeat(40), } } #[test] fn marketplace_edits_keep_a_concurrent_status_and_container_change() { // PR review #2: a marketplace install/uninstall used to write back a // whole record read before a start flipped status and container_id, // leaving the project stuck at Starting with no container. let dir = temp_dir("marketplace-fields"); let project = Project::new("demo".to_string(), Vec::new()); let id = project.id.clone(); let store = store_over(&dir, vec![project]); // The start flow moves on while a marketplace command is running. store.set_container_id(&id, Some("cid-1".into())).unwrap(); store.update_status(&id, crate::models::ProjectStatus::Starting).unwrap(); let (added, saved) = store .update_marketplace_fields(&id, |installs, disabled| { installs.push(market_install("a")); disabled.push(market_install("g").item_ref()); Ok(installs.len()) }) .unwrap(); assert_eq!(added, 1); assert_eq!(saved.container_id.as_deref(), Some("cid-1")); assert_eq!(saved.status, crate::models::ProjectStatus::Starting); let on_disk: Vec = serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap(); assert_eq!(on_disk[0].container_id.as_deref(), Some("cid-1")); assert_eq!(on_disk[0].marketplace_installs, vec![market_install("a")]); // A refusal inside the closure writes nothing. let before = fs::read_to_string(dir.join("projects.json")).unwrap(); let err = store .update_marketplace_fields(&id, |installs, _| { installs.clear(); Err::<(), _>("not installed".to_string()) }) .unwrap_err(); assert_eq!(err, "not installed"); assert_eq!(store.get(&id).unwrap().marketplace_installs.len(), 1); assert_eq!(fs::read_to_string(dir.join("projects.json")).unwrap(), before); assert!(store.update_marketplace_fields("nope", |_, _| Ok(())).is_err()); fs::remove_dir_all(&dir).ok(); } #[test] fn a_project_save_keeps_a_marketplace_install_made_after_it_read_the_record() { // Re-review round 2: `update_project` read the stored record, then // wrote the whole payload back later. An install landing in between // was lost. The restore now runs against the record under the lock. let dir = temp_dir("save-restore"); let project = Project::new("demo".to_string(), Vec::new()); let id = project.id.clone(); let store = store_over(&dir, vec![project]); let mut payload = store.get(&id).unwrap(); // the Config tab's copy payload.name = "renamed".to_string(); store .update_marketplace_fields(&id, |installs, _| { installs.push(market_install("late")); Ok(()) }) .unwrap(); let saved = store .update_restoring(payload, |incoming, stored| { incoming.marketplace_installs = stored.marketplace_installs.clone(); incoming.marketplace_disabled = stored.marketplace_disabled.clone(); }) .unwrap(); assert_eq!(saved.name, "renamed"); assert_eq!(saved.marketplace_installs, vec![market_install("late")]); assert_eq!(store.get(&id).unwrap().marketplace_installs, vec![market_install("late")]); let mut ghost = Project::new("ghost".to_string(), Vec::new()); ghost.id = "nope".into(); assert!(store.update_restoring(ghost, |_, _| {}).is_err()); fs::remove_dir_all(&dir).ok(); } #[test] fn marketplace_edits_across_all_projects_touch_only_those_fields() { let dir = temp_dir("marketplace-all"); let mut a = Project::new("a".to_string(), Vec::new()); a.marketplace_installs = vec![market_install("x")]; let b = Project::new("b".to_string(), Vec::new()); let (a_id, b_id) = (a.id.clone(), b.id.clone()); let store = store_over(&dir, vec![a, b]); store.set_container_id(&b_id, Some("cid-b".into())).unwrap(); store.update_status(&a_id, crate::models::ProjectStatus::Running).unwrap(); let b_updated_at = store.get(&b_id).unwrap().updated_at; store .update_all_marketplace_fields(|installs, _| { installs.retain(|i| i.marketplace_id != "m1") }) .unwrap(); let a = store.get(&a_id).unwrap(); assert!(a.marketplace_installs.is_empty()); assert_eq!(a.status, crate::models::ProjectStatus::Running); let b = store.get(&b_id).unwrap(); assert_eq!(b.container_id.as_deref(), Some("cid-b")); assert_eq!(b.updated_at, b_updated_at, "an untouched project is not rewritten"); fs::remove_dir_all(&dir).ok(); } }