Skip to main content

squigit_storage/threads/
index.rs

1// Copyright 2026 a7mddra
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::{BTreeMap, HashSet};
5use std::fs;
6use std::path::Path;
7use std::sync::Mutex;
8
9// Serialize catalog read/modify/write operations across addon worker threads.
10static CATALOG_WRITE_LOCK: Mutex<()> = Mutex::new(());
11
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Result, StorageError};
15
16use super::{SideChatMetadata, ThreadMetadata, ThreadStorage, WorkspaceMetadata};
17
18#[derive(Debug, Default, Serialize, Deserialize)]
19pub(super) struct ThreadIndex {
20    pub(super) workspaces: Vec<WorkspaceMetadata>,
21    pub(super) unassigned_threads: BTreeMap<String, ThreadMetadata>,
22    #[serde(default)]
23    pub(super) sidechat_threads: BTreeMap<String, SideChatMetadata>,
24}
25
26fn canonical_workspace_path(path: &Path) -> Result<std::path::PathBuf> {
27    if !path.is_dir() {
28        return Err(StorageError::InvalidWorkspacePath(
29            path.display().to_string(),
30        ));
31    }
32
33    let canonical = fs::canonicalize(path)?;
34    if canonical.parent().is_none() {
35        return Err(StorageError::InvalidWorkspacePath(
36            path.display().to_string(),
37        ));
38    }
39
40    if dirs::home_dir()
41        .and_then(|home| fs::canonicalize(home).ok())
42        .is_some_and(|home| home == canonical)
43    {
44        return Err(StorageError::InvalidWorkspacePath(
45            path.display().to_string(),
46        ));
47    }
48
49    #[cfg(unix)]
50    {
51        const PROTECTED_PATHS: &[&str] = &[
52            "/Applications",
53            "/Library",
54            "/System",
55            "/Users",
56            "/Volumes",
57            "/bin",
58            "/boot",
59            "/dev",
60            "/etc",
61            "/home",
62            "/lib",
63            "/lib64",
64            "/opt",
65            "/proc",
66            "/root",
67            "/run",
68            "/sbin",
69            "/sys",
70            "/usr",
71            "/var",
72        ];
73
74        if PROTECTED_PATHS
75            .iter()
76            .any(|protected| canonical == Path::new(protected))
77        {
78            return Err(StorageError::InvalidWorkspacePath(
79                path.display().to_string(),
80            ));
81        }
82    }
83
84    #[cfg(windows)]
85    {
86        let normalized = canonical
87            .to_string_lossy()
88            .replace('/', "\\")
89            .to_lowercase();
90        let drive_relative = normalized
91            .strip_prefix(r"\\?\")
92            .unwrap_or(normalized.as_str());
93        let components = drive_relative
94            .split('\\')
95            .filter(|component| !component.is_empty())
96            .collect::<Vec<_>>();
97        let protected = [
98            "program files",
99            "program files (x86)",
100            "programdata",
101            "users",
102            "windows",
103        ];
104
105        if components.len() <= 1
106            || components
107                .get(1)
108                .is_some_and(|component| protected.contains(component))
109        {
110            return Err(StorageError::InvalidWorkspacePath(
111                path.display().to_string(),
112            ));
113        }
114    }
115
116    Ok(canonical)
117}
118
119impl ThreadStorage {
120    pub(super) fn read_index(&self) -> Result<ThreadIndex> {
121        if !self.index_path.exists() {
122            return Ok(ThreadIndex::default());
123        }
124
125        let index_json = fs::read_to_string(&self.index_path)?;
126        serde_json::from_str::<ThreadIndex>(&index_json).map_err(Into::into)
127    }
128
129    fn write_index(&self, index: &ThreadIndex) -> Result<()> {
130        let json = serde_json::to_string_pretty(index)?;
131        super::atomic_write(&self.index_path, json.as_bytes())
132    }
133
134    fn validate_workspace_input(
135        name: &str,
136        directories: &[String],
137    ) -> Result<(String, Vec<String>)> {
138        let name = name.trim();
139        if name.is_empty() {
140            return Err(StorageError::InvalidWorkspaceName);
141        }
142        let directories = directories
143            .iter()
144            .map(|path| canonical_workspace_path(Path::new(path)))
145            .collect::<Result<Vec<_>>>()?;
146        Ok((
147            name.to_string(),
148            directories
149                .into_iter()
150                .map(|path| path.to_string_lossy().into_owned())
151                .collect(),
152        ))
153    }
154
155    pub fn create_workspace(
156        &self,
157        name: &str,
158        directories: &[String],
159    ) -> Result<WorkspaceMetadata> {
160        let (name, directories) = Self::validate_workspace_input(name, directories)?;
161        let workspace = WorkspaceMetadata::new(name, directories);
162        let _guard = CATALOG_WRITE_LOCK
163            .lock()
164            .unwrap_or_else(|poisoned| poisoned.into_inner());
165        let mut index = self.read_index()?;
166        index.workspaces.push(workspace.clone());
167        self.write_index(&index)?;
168        Ok(workspace)
169    }
170
171    pub fn update_workspace(
172        &self,
173        workspace_id: &str,
174        name: &str,
175        directories: &[String],
176    ) -> Result<WorkspaceMetadata> {
177        let name = name.trim();
178        if name.is_empty() {
179            return Err(StorageError::InvalidWorkspaceName);
180        }
181        let _guard = CATALOG_WRITE_LOCK
182            .lock()
183            .unwrap_or_else(|poisoned| poisoned.into_inner());
184        let mut index = self.read_index()?;
185        let workspace = index
186            .workspaces
187            .iter_mut()
188            .find(|workspace| workspace.id == workspace_id)
189            .ok_or_else(|| StorageError::WorkspaceNotFound(workspace_id.to_string()))?;
190        let directories = Self::validate_workspace_input(name, directories)?.1;
191        workspace.name = name.to_string();
192        workspace.directories = directories;
193        let updated = workspace.clone();
194        self.write_index(&index)?;
195        Ok(updated)
196    }
197
198    pub fn delete_workspace(&self, workspace_id: &str) -> Result<()> {
199        let _guard = CATALOG_WRITE_LOCK
200            .lock()
201            .unwrap_or_else(|poisoned| poisoned.into_inner());
202        let mut index = self.read_index()?;
203        let workspace_index = index
204            .workspaces
205            .iter()
206            .position(|workspace| workspace.id == workspace_id)
207            .ok_or_else(|| StorageError::WorkspaceNotFound(workspace_id.to_string()))?;
208        let removed = index.workspaces.remove(workspace_index);
209        index.unassigned_threads.extend(removed.threads);
210        self.write_index(&index)
211    }
212
213    pub(super) fn get_index_metadata(&self, thread_id: &str) -> Result<ThreadMetadata> {
214        let index = self.read_index()?;
215        index
216            .unassigned_threads
217            .get(thread_id)
218            .cloned()
219            .or_else(|| {
220                index
221                    .workspaces
222                    .iter()
223                    .find_map(|workspace| workspace.threads.get(thread_id).cloned())
224            })
225            .ok_or_else(|| StorageError::ThreadNotFound(thread_id.to_string()))
226    }
227
228    pub fn get_thread_workspace_id(&self, thread_id: &str) -> Result<Option<String>> {
229        let index = self.read_index()?;
230        if index.unassigned_threads.contains_key(thread_id) {
231            return Ok(None);
232        }
233        index
234            .workspaces
235            .iter()
236            .find(|workspace| workspace.threads.contains_key(thread_id))
237            .map(|workspace| Some(workspace.id.clone()))
238            .ok_or_else(|| StorageError::ThreadNotFound(thread_id.to_string()))
239    }
240
241    pub(super) fn update_index(&self, metadata: &ThreadMetadata) -> Result<()> {
242        let _guard = CATALOG_WRITE_LOCK
243            .lock()
244            .unwrap_or_else(|poisoned| poisoned.into_inner());
245        let mut index = self.read_index()?;
246        if let Some(workspace) = index
247            .workspaces
248            .iter_mut()
249            .find(|workspace| workspace.threads.contains_key(&metadata.id))
250        {
251            workspace
252                .threads
253                .insert(metadata.id.clone(), metadata.clone());
254        } else {
255            index
256                .unassigned_threads
257                .insert(metadata.id.clone(), metadata.clone());
258        }
259        self.write_index(&index)
260    }
261
262    pub(super) fn get_sidechat_metadata(&self, sidechat_id: &str) -> Result<SideChatMetadata> {
263        self.read_index()?
264            .sidechat_threads
265            .get(sidechat_id)
266            .cloned()
267            .ok_or_else(|| StorageError::ThreadNotFound(sidechat_id.to_string()))
268    }
269
270    pub(super) fn update_sidechat_index(&self, metadata: &SideChatMetadata) -> Result<()> {
271        let _guard = CATALOG_WRITE_LOCK
272            .lock()
273            .unwrap_or_else(|poisoned| poisoned.into_inner());
274        let mut index = self.read_index()?;
275        index
276            .sidechat_threads
277            .insert(metadata.id.clone(), metadata.clone());
278        self.write_index(&index)
279    }
280
281    pub fn list_sidechat_threads(&self) -> Result<Vec<SideChatMetadata>> {
282        Ok(self.read_index()?.sidechat_threads.into_values().collect())
283    }
284
285    pub(super) fn remove_sidechat_from_index(&self, sidechat_id: &str) -> Result<()> {
286        let _guard = CATALOG_WRITE_LOCK
287            .lock()
288            .unwrap_or_else(|poisoned| poisoned.into_inner());
289        let mut index = self.read_index()?;
290        index.sidechat_threads.remove(sidechat_id);
291        self.write_index(&index)
292    }
293
294    pub(super) fn update_index_in_workspace(
295        &self,
296        metadata: &ThreadMetadata,
297        workspace_id: Option<&str>,
298    ) -> Result<()> {
299        let _guard = CATALOG_WRITE_LOCK
300            .lock()
301            .unwrap_or_else(|poisoned| poisoned.into_inner());
302        let mut index = self.read_index()?;
303        if let Some(id) = workspace_id {
304            if !index.workspaces.iter().any(|workspace| workspace.id == id) {
305                return Err(StorageError::WorkspaceNotFound(id.to_string()));
306            }
307        }
308        let metadata = index
309            .unassigned_threads
310            .get(&metadata.id)
311            .or_else(|| {
312                index
313                    .workspaces
314                    .iter()
315                    .find_map(|workspace| workspace.threads.get(&metadata.id))
316            })
317            .cloned()
318            .unwrap_or_else(|| metadata.clone());
319        index.unassigned_threads.remove(&metadata.id);
320        for workspace in &mut index.workspaces {
321            workspace.threads.remove(&metadata.id);
322        }
323        if let Some(id) = workspace_id {
324            let workspace = index
325                .workspaces
326                .iter_mut()
327                .find(|workspace| workspace.id == id)
328                .unwrap();
329            workspace
330                .threads
331                .insert(metadata.id.clone(), metadata.clone());
332        } else {
333            index
334                .unassigned_threads
335                .insert(metadata.id.clone(), metadata.clone());
336        }
337        self.write_index(&index)
338    }
339
340    /// Group two unassigned threads in one catalog write.
341    pub fn group_threads(&self, first_id: &str, second_id: &str) -> Result<WorkspaceMetadata> {
342        let _guard = CATALOG_WRITE_LOCK
343            .lock()
344            .unwrap_or_else(|poisoned| poisoned.into_inner());
345        let mut index = self.read_index()?;
346        if first_id == second_id
347            || !index.unassigned_threads.contains_key(first_id)
348            || !index.unassigned_threads.contains_key(second_id)
349        {
350            return Err(StorageError::ThreadNotFound(second_id.to_string()));
351        }
352        let mut workspace = WorkspaceMetadata::new("New workspace".to_string(), Vec::new());
353        for id in [first_id, second_id] {
354            workspace
355                .threads
356                .insert(id.to_string(), index.unassigned_threads.remove(id).unwrap());
357        }
358        index.workspaces.push(workspace.clone());
359        self.write_index(&index)?;
360        Ok(workspace)
361    }
362
363    pub fn list_unassigned_threads(&self) -> Result<Vec<ThreadMetadata>> {
364        Ok(self
365            .read_index()?
366            .unassigned_threads
367            .into_values()
368            .collect())
369    }
370
371    pub(super) fn remove_many_from_index(&self, thread_ids: &[String]) -> Result<()> {
372        let removed = thread_ids
373            .iter()
374            .map(String::as_str)
375            .collect::<HashSet<_>>();
376        let _guard = CATALOG_WRITE_LOCK
377            .lock()
378            .unwrap_or_else(|poisoned| poisoned.into_inner());
379        let mut index = self.read_index()?;
380        index
381            .unassigned_threads
382            .retain(|id, _| !removed.contains(id.as_str()));
383        for workspace in &mut index.workspaces {
384            workspace
385                .threads
386                .retain(|id, _| !removed.contains(id.as_str()));
387        }
388        self.write_index(&index)
389    }
390}