1use std::collections::{BTreeMap, HashSet};
5use std::fs;
6use std::path::Path;
7use std::sync::Mutex;
8
9static 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 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}