1use crate::search::SearchIndex;
2use notify::{EventKind, RecursiveMode, Watcher};
3use serde::Serialize;
4use std::{
5 collections::HashMap,
6 path::PathBuf,
7 sync::{
8 atomic::{AtomicBool, Ordering},
9 Arc, Mutex, RwLock,
10 },
11};
12use tokio::sync::broadcast;
13
14pub struct WorkspaceConfig {
17 pub path: PathBuf,
18 pub enable_search: bool,
19 pub enable_viewed: bool,
20 pub enable_edit: bool,
21 pub shared_annotation: bool,
22}
23
24pub struct WorkspaceEntry {
27 pub id: String,
28 pub root: PathBuf,
29 pub enable_search: AtomicBool,
30 pub enable_viewed: AtomicBool,
31 pub enable_edit: AtomicBool,
32 pub shared_annotation: AtomicBool,
33 pub config_tx: broadcast::Sender<()>,
35 pub search_index: Arc<Mutex<Option<Arc<SearchIndex>>>>,
37}
38
39impl WorkspaceEntry {
40 pub fn search_ready(&self) -> bool {
41 self.enable_search.load(Ordering::Relaxed) && self.search_index.lock().unwrap().is_some()
42 }
43}
44
45#[derive(Serialize)]
48pub struct WorkspaceInfo {
49 pub id: String,
50 pub path: String,
51 pub enable_search: bool,
52 pub enable_viewed: bool,
53 pub enable_edit: bool,
54 pub shared_annotation: bool,
55 pub search_ready: bool,
56}
57
58pub struct WorkspaceRegistry {
61 inner: RwLock<HashMap<String, Arc<WorkspaceEntry>>>,
62 pub salt: String,
63}
64
65fn hash_id(path: &std::path::Path, salt: &str) -> String {
69 use std::collections::hash_map::DefaultHasher;
70 use std::hash::{Hash, Hasher};
71 let mut h = DefaultHasher::new();
72 salt.hash(&mut h);
73 path.hash(&mut h);
74 format!("{:08x}", h.finish() as u32)
75}
76
77pub fn generate_token() -> String {
79 use std::time::{SystemTime, UNIX_EPOCH};
80 let now = SystemTime::now()
81 .duration_since(UNIX_EPOCH)
82 .unwrap_or_default();
83 let a = now.as_nanos() as u64;
84 let b = std::process::id() as u64;
85 let c = now.subsec_nanos() as u64;
86 format!(
87 "{:016x}{:016x}",
88 a.wrapping_mul(6364136223846793005).wrapping_add(b),
89 c.wrapping_mul(2862933555777941757).wrapping_add(a),
90 )
91}
92
93impl WorkspaceRegistry {
94 pub fn new(salt: String) -> Self {
95 Self {
96 inner: RwLock::new(HashMap::new()),
97 salt,
98 }
99 }
100
101 pub fn add(&self, config: WorkspaceConfig) -> String {
102 let id = hash_id(&config.path, &self.salt);
103
104 let search_slot: Arc<Mutex<Option<Arc<SearchIndex>>>> = Arc::new(Mutex::new(None));
105
106 let (config_tx, _) = broadcast::channel(4);
107 let entry = Arc::new(WorkspaceEntry {
108 id: id.clone(),
109 root: config.path.clone(),
110 enable_search: AtomicBool::new(config.enable_search),
111 enable_viewed: AtomicBool::new(config.enable_viewed),
112 enable_edit: AtomicBool::new(config.enable_edit),
113 shared_annotation: AtomicBool::new(config.shared_annotation),
114 config_tx,
115 search_index: search_slot.clone(),
116 });
117
118 self.inner
119 .write()
120 .unwrap()
121 .insert(id.clone(), entry.clone());
122
123 if config.enable_search {
124 spawn_search_indexer(config.path, search_slot);
125 }
126
127 id
128 }
129
130 pub fn update_flags(
133 &self,
134 id: &str,
135 enable_search: bool,
136 enable_viewed: bool,
137 enable_edit: bool,
138 shared_annotation: bool,
139 ) -> bool {
140 let guard = self.inner.read().unwrap();
141 let Some(entry) = guard.get(id) else {
142 return false;
143 };
144 let was_search = entry.enable_search.swap(enable_search, Ordering::Relaxed);
145 entry.enable_viewed.store(enable_viewed, Ordering::Relaxed);
146 entry.enable_edit.store(enable_edit, Ordering::Relaxed);
147 entry
148 .shared_annotation
149 .store(shared_annotation, Ordering::Relaxed);
150 let _ = entry.config_tx.send(());
151 if enable_search && !was_search && entry.search_index.lock().unwrap().is_none() {
153 spawn_search_indexer(entry.root.clone(), entry.search_index.clone());
154 }
155 true
156 }
157
158 pub fn remove(&self, id: &str) -> bool {
159 self.inner.write().unwrap().remove(id).is_some()
160 }
161
162 pub fn get(&self, id: &str) -> Option<Arc<WorkspaceEntry>> {
163 self.inner.read().unwrap().get(id).cloned()
164 }
165
166 pub fn list(&self) -> Vec<Arc<WorkspaceEntry>> {
167 self.inner.read().unwrap().values().cloned().collect()
168 }
169
170 pub fn info_list(&self) -> Vec<WorkspaceInfo> {
171 self.list()
172 .into_iter()
173 .map(|e| WorkspaceInfo {
174 id: e.id.clone(),
175 path: e.root.to_string_lossy().to_string(),
176 enable_search: e.enable_search.load(Ordering::Relaxed),
177 enable_viewed: e.enable_viewed.load(Ordering::Relaxed),
178 enable_edit: e.enable_edit.load(Ordering::Relaxed),
179 shared_annotation: e.shared_annotation.load(Ordering::Relaxed),
180 search_ready: e.search_ready(),
181 })
182 .collect()
183 }
184}
185
186fn spawn_search_indexer(root: PathBuf, slot: Arc<Mutex<Option<Arc<SearchIndex>>>>) {
189 std::thread::spawn(move || match SearchIndex::new(&root) {
190 Ok(idx) => {
191 let idx = Arc::new(idx);
192 *slot.lock().unwrap() = Some(idx.clone());
193 eprintln!("[search] Index ready for {:?}", root);
194 start_file_watcher(idx, root);
195 }
196 Err(e) => eprintln!("[search] Index error for {:?}: {e}", root),
197 });
198}
199
200fn start_file_watcher(index: Arc<SearchIndex>, root: PathBuf) {
203 std::thread::spawn(move || {
204 let (tx, rx) = std::sync::mpsc::channel();
205 let mut watcher = match notify::recommended_watcher(move |res: Result<notify::Event, _>| {
206 if let Ok(event) = res {
207 let _ = tx.send(event);
208 }
209 }) {
210 Ok(w) => w,
211 Err(e) => {
212 eprintln!("[search] Failed to create watcher for {:?}: {e}", root);
213 return;
214 }
215 };
216
217 if let Err(e) = watcher.watch(&root, RecursiveMode::Recursive) {
218 eprintln!("[search] Failed to watch {:?}: {e}", root);
219 return;
220 }
221
222 eprintln!("[search] Watching {:?}", root);
223
224 while let Ok(event) = rx.recv() {
225 for path in event.paths {
226 match event.kind {
227 EventKind::Create(_) | EventKind::Modify(_) => {
228 if let Err(e) = index.update_file(&path) {
229 eprintln!("[search] Error updating index: {e}");
230 }
231 }
232 EventKind::Remove(_) => {
233 if let Err(e) = index.delete_file(&path) {
234 eprintln!("[search] Error removing from index: {e}");
235 }
236 }
237 _ => {}
238 }
239 }
240 }
241 });
242}
243
244#[derive(serde::Serialize, serde::Deserialize)]
247pub struct ServerLock {
248 pub port: u16,
249 pub token: String,
251}
252
253impl ServerLock {
254 pub fn path() -> PathBuf {
255 dirs::home_dir()
256 .unwrap_or_else(|| PathBuf::from("."))
257 .join(".markon")
258 .join("server.lock")
259 }
260
261 pub fn write(&self) -> std::io::Result<()> {
262 let path = Self::path();
263 if let Some(parent) = path.parent() {
264 std::fs::create_dir_all(parent)?;
265 }
266 std::fs::write(&path, serde_json::to_string(self).unwrap())
267 }
268
269 pub fn read() -> Option<Self> {
270 let content = std::fs::read_to_string(Self::path()).ok()?;
271 serde_json::from_str(&content).ok()
272 }
273
274 pub fn remove() {
275 let _ = std::fs::remove_file(Self::path());
276 }
277
278 pub fn is_alive(&self) -> bool {
280 std::net::TcpStream::connect_timeout(
281 &std::net::SocketAddr::from(([127, 0, 0, 1], self.port)),
282 std::time::Duration::from_millis(500),
283 )
284 .is_ok()
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::path::Path;
292
293 #[test]
294 fn hash_id_is_deterministic() {
295 let path = Path::new("/tmp/test");
296 let a = hash_id(path, "salt1");
297 let b = hash_id(path, "salt1");
298 assert_eq!(a, b);
299 }
300
301 #[test]
302 fn hash_id_length_is_8_hex() {
303 let id = hash_id(Path::new("/some/path"), "s");
304 assert_eq!(id.len(), 8);
305 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
306 }
307
308 #[test]
309 fn hash_id_differs_by_salt() {
310 let path = Path::new("/tmp/test");
311 let a = hash_id(path, "salt1");
312 let b = hash_id(path, "salt2");
313 assert_ne!(a, b);
314 }
315
316 #[test]
317 fn hash_id_differs_by_path() {
318 let a = hash_id(Path::new("/a"), "salt");
319 let b = hash_id(Path::new("/b"), "salt");
320 assert_ne!(a, b);
321 }
322
323 #[test]
324 fn generate_token_length_and_hex() {
325 let token = generate_token();
326 assert_eq!(token.len(), 32);
327 assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
328 }
329
330 #[test]
331 fn generate_token_uniqueness() {
332 let a = generate_token();
333 std::thread::sleep(std::time::Duration::from_millis(1));
335 let b = generate_token();
336 assert_ne!(a, b);
337 }
338
339 #[test]
340 fn search_ready_false_when_search_disabled() {
341 let (tx, _) = broadcast::channel(1);
342 let entry = WorkspaceEntry {
343 id: "test".into(),
344 root: PathBuf::from("/tmp"),
345 enable_search: AtomicBool::new(false),
346 enable_viewed: AtomicBool::new(false),
347 enable_edit: AtomicBool::new(false),
348 shared_annotation: AtomicBool::new(false),
349 config_tx: tx,
350 search_index: Arc::new(Mutex::new(None)),
351 };
352 assert!(!entry.search_ready());
353 }
354
355 #[test]
356 fn search_ready_false_when_index_not_built() {
357 let (tx, _) = broadcast::channel(1);
358 let entry = WorkspaceEntry {
359 id: "test".into(),
360 root: PathBuf::from("/tmp"),
361 enable_search: AtomicBool::new(true),
362 enable_viewed: AtomicBool::new(false),
363 enable_edit: AtomicBool::new(false),
364 shared_annotation: AtomicBool::new(false),
365 config_tx: tx,
366 search_index: Arc::new(Mutex::new(None)),
367 };
368 assert!(!entry.search_ready());
369 }
370
371 #[test]
372 fn search_ready_true_when_enabled_and_index_present() {
373 let (tx, _) = broadcast::channel(1);
374 let idx = crate::search::SearchIndex::new(Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
375 let entry = WorkspaceEntry {
376 id: "test".into(),
377 root: PathBuf::from("/tmp"),
378 enable_search: AtomicBool::new(true),
379 enable_viewed: AtomicBool::new(false),
380 enable_edit: AtomicBool::new(false),
381 shared_annotation: AtomicBool::new(false),
382 config_tx: tx,
383 search_index: Arc::new(Mutex::new(Some(Arc::new(idx)))),
384 };
385 assert!(entry.search_ready());
386 }
387
388 #[test]
389 fn registry_add_and_get() {
390 let reg = WorkspaceRegistry::new("test-salt".into());
391 let id = reg.add(WorkspaceConfig {
392 path: PathBuf::from("/tmp/ws1"),
393 enable_search: false,
394 enable_viewed: false,
395 enable_edit: false,
396 shared_annotation: false,
397 });
398 assert!(!id.is_empty());
399 assert!(reg.get(&id).is_some());
400 assert_eq!(reg.get(&id).unwrap().id, id);
401 }
402
403 #[test]
404 fn registry_add_idempotent_id() {
405 let reg = WorkspaceRegistry::new("test-salt".into());
406 let id1 = reg.add(WorkspaceConfig {
407 path: PathBuf::from("/tmp/ws1"),
408 enable_search: false,
409 enable_viewed: false,
410 enable_edit: false,
411 shared_annotation: false,
412 });
413 let id2 = reg.add(WorkspaceConfig {
414 path: PathBuf::from("/tmp/ws1"),
415 enable_search: false,
416 enable_viewed: false,
417 enable_edit: false,
418 shared_annotation: false,
419 });
420 assert_eq!(id1, id2);
421 }
422
423 #[test]
424 fn registry_remove() {
425 let reg = WorkspaceRegistry::new("test-salt".into());
426 let id = reg.add(WorkspaceConfig {
427 path: PathBuf::from("/tmp/ws_rm"),
428 enable_search: false,
429 enable_viewed: false,
430 enable_edit: false,
431 shared_annotation: false,
432 });
433 assert!(reg.remove(&id));
434 assert!(!reg.remove(&id));
435 assert!(reg.get(&id).is_none());
436 }
437
438 #[test]
439 fn registry_list() {
440 let reg = WorkspaceRegistry::new("test-salt".into());
441 reg.add(WorkspaceConfig {
442 path: PathBuf::from("/tmp/a"),
443 enable_search: false,
444 enable_viewed: false,
445 enable_edit: false,
446 shared_annotation: false,
447 });
448 reg.add(WorkspaceConfig {
449 path: PathBuf::from("/tmp/b"),
450 enable_search: false,
451 enable_viewed: false,
452 enable_edit: false,
453 shared_annotation: false,
454 });
455 assert_eq!(reg.list().len(), 2);
456 }
457
458 #[test]
459 fn registry_update_flags() {
460 let reg = WorkspaceRegistry::new("test-salt".into());
461 let id = reg.add(WorkspaceConfig {
462 path: PathBuf::from("/tmp/ws_flags"),
463 enable_search: false,
464 enable_viewed: false,
465 enable_edit: false,
466 shared_annotation: false,
467 });
468 assert!(!reg.update_flags("nonexistent", true, true, true, true));
470 assert!(reg.update_flags(&id, false, true, true, false));
472 let entry = reg.get(&id).unwrap();
473 assert!(!entry.enable_search.load(Ordering::Relaxed));
474 assert!(entry.enable_viewed.load(Ordering::Relaxed));
475 assert!(entry.enable_edit.load(Ordering::Relaxed));
476 assert!(!entry.shared_annotation.load(Ordering::Relaxed));
477 }
478
479 #[test]
480 fn registry_info_list() {
481 let reg = WorkspaceRegistry::new("test-salt".into());
482 reg.add(WorkspaceConfig {
483 path: PathBuf::from("/tmp/info"),
484 enable_search: false,
485 enable_viewed: true,
486 enable_edit: false,
487 shared_annotation: false,
488 });
489 let infos = reg.info_list();
490 assert_eq!(infos.len(), 1);
491 assert!(infos[0].enable_viewed);
492 assert!(!infos[0].enable_search);
493 assert!(!infos[0].search_ready);
494 }
495
496 #[test]
497 fn server_lock_serde_roundtrip() {
498 let lock = ServerLock {
499 port: 8080,
500 token: "abc123".into(),
501 };
502 let json = serde_json::to_string(&lock).unwrap();
503 let parsed: ServerLock = serde_json::from_str(&json).unwrap();
504 assert_eq!(parsed.port, 8080);
505 assert_eq!(parsed.token, "abc123");
506 }
507}