2026-02-27 04:29:51 +00:00
|
|
|
use std::fs;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Mutex;
|
|
|
|
|
|
|
|
|
|
use crate::models::Project;
|
|
|
|
|
|
|
|
|
|
pub struct ProjectsStore {
|
|
|
|
|
projects: Mutex<Vec<Project>>,
|
|
|
|
|
file_path: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ProjectsStore {
|
2026-02-28 20:42:55 +00:00
|
|
|
pub fn new() -> Result<Self, String> {
|
2026-02-27 04:29:51 +00:00
|
|
|
let data_dir = dirs::data_dir()
|
2026-02-28 20:42:55 +00:00
|
|
|
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
|
2026-02-27 04:29:51 +00:00
|
|
|
.join("triple-c");
|
|
|
|
|
|
|
|
|
|
fs::create_dir_all(&data_dir).ok();
|
|
|
|
|
|
|
|
|
|
let file_path = data_dir.join("projects.json");
|
|
|
|
|
|
2026-02-28 21:18:33 +00:00
|
|
|
let (projects, needs_save) = if file_path.exists() {
|
2026-02-27 04:29:51 +00:00
|
|
|
match fs::read_to_string(&file_path) {
|
2026-02-28 21:18:33 +00:00
|
|
|
Ok(data) => {
|
|
|
|
|
// First try to parse as Vec<Value> to run migration
|
|
|
|
|
match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
|
|
|
|
|
Ok(raw_values) => {
|
|
|
|
|
let mut migrated = false;
|
|
|
|
|
let migrated_values: Vec<serde_json::Value> = 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::<Vec<crate::models::Project>>(&json_str) {
|
|
|
|
|
Ok(parsed) => (parsed, migrated),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
|
|
|
|
let backup = file_path.with_extension("json.bak");
|
|
|
|
|
if let Err(be) = fs::copy(&file_path, &backup) {
|
|
|
|
|
log::error!("Failed to back up corrupted projects.json: {}", be);
|
|
|
|
|
}
|
|
|
|
|
(Vec::new(), false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
|
|
|
|
let backup = file_path.with_extension("json.bak");
|
|
|
|
|
if let Err(be) = fs::copy(&file_path, &backup) {
|
|
|
|
|
log::error!("Failed to back up corrupted projects.json: {}", be);
|
|
|
|
|
}
|
|
|
|
|
(Vec::new(), false)
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-28 21:18:33 +00:00
|
|
|
}
|
2026-02-27 04:29:51 +00:00
|
|
|
Err(e) => {
|
|
|
|
|
log::error!("Failed to read projects.json: {}", e);
|
2026-02-28 21:18:33 +00:00
|
|
|
(Vec::new(), false)
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-02-28 21:18:33 +00:00
|
|
|
(Vec::new(), false)
|
2026-02-27 04:29:51 +00:00
|
|
|
};
|
|
|
|
|
|
2026-03-04 07:12:49 -08:00
|
|
|
// Reconcile stale transient statuses: on a cold app start no Docker
|
|
|
|
|
// operations can be in flight, so Starting/Stopping are always stale.
|
2026-03-10 08:29:06 -07:00
|
|
|
// Running/Error are left as-is and reconciled against Docker later
|
|
|
|
|
// via the reconcile_project_statuses command.
|
2026-03-04 07:12:49 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 21:18:33 +00:00
|
|
|
let store = Self {
|
2026-02-27 04:29:51 +00:00
|
|
|
projects: Mutex::new(projects),
|
|
|
|
|
file_path,
|
2026-02-28 21:18:33 +00:00
|
|
|
};
|
|
|
|
|
|
2026-03-04 07:12:49 -08:00
|
|
|
// Persist migrated/reconciled format back to disk
|
2026-02-28 21:18:33 +00:00
|
|
|
if needs_save {
|
2026-03-04 07:12:49 -08:00
|
|
|
log::info!("Saving reconciled/migrated projects.json to disk");
|
2026-02-28 21:18:33 +00:00
|
|
|
let projects = store.lock();
|
|
|
|
|
if let Err(e) = store.save(&projects) {
|
2026-03-04 07:12:49 -08:00
|
|
|
log::error!("Failed to save projects: {}", e);
|
2026-02-28 21:18:33 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(store)
|
2026-02-27 04:29:51 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
|
|
|
|
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<Project> {
|
|
|
|
|
self.lock().clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn get(&self, id: &str) -> Option<Project> {
|
|
|
|
|
self.lock().iter().find(|p| p.id == id).cloned()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add(&self, project: Project) -> Result<Project, String> {
|
|
|
|
|
let mut projects = self.lock();
|
|
|
|
|
let cloned = project.clone();
|
|
|
|
|
projects.push(project);
|
|
|
|
|
self.save(&projects)?;
|
|
|
|
|
Ok(cloned)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn update(&self, updated: Project) -> Result<Project, String> {
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> 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))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|