Skip to main content

markon_core/
workspace.rs

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
14// ── Workspace config (input) ────────────────────────────────────────────────
15
16pub 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
24// ── Workspace entry (runtime) ───────────────────────────────────────────────
25
26pub 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    /// Broadcast channel: fires `()` each time flags change so WS clients can reload.
34    pub config_tx: broadcast::Sender<()>,
35    /// Populated asynchronously in a background thread when enable_search is true.
36    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// ── Serialisable info for the API ───────────────────────────────────────────
46
47#[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
58// ── Registry ────────────────────────────────────────────────────────────────
59
60pub struct WorkspaceRegistry {
61    inner: RwLock<HashMap<String, Arc<WorkspaceEntry>>>,
62    pub salt: String,
63}
64
65/// Stable workspace ID: hash(path + salt).
66/// When salt includes the port (default), the same directory on the same port
67/// always produces the same ID — bookmarks and shared links survive restarts.
68fn 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
77/// Generate a hard-to-guess management token from timestamp + pid entropy.
78pub 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    /// Update feature flags for an existing workspace in-place.
131    /// If enable_search transitions to true and the index isn't built yet, kick off indexing.
132    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        // Start indexing if search was just enabled and index not yet built.
152        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
186// ── Search indexer ──────────────────────────────────────────────────────────
187
188fn 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
200// ── Per-workspace file watcher ──────────────────────────────────────────────
201
202fn 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// ── Server lock file ────────────────────────────────────────────────────────
245
246#[derive(serde::Serialize, serde::Deserialize)]
247pub struct ServerLock {
248    pub port: u16,
249    /// Management API token — required in `X-Markon-Token` header for /api/* requests.
250    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    /// Returns true if a TCP connection to 127.0.0.1:port succeeds quickly.
279    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        // Tiny sleep to ensure different timestamp
334        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        // Unknown ID returns false
469        assert!(!reg.update_flags("nonexistent", true, true, true, true));
470        // Known ID returns true
471        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}