Skip to main content

wipe_core/
forum.rs

1//! The project forum: a git-tracked, thread-structured discussion hub that lives
2//! beside the board under `.wipe/forum/`.
3//!
4//! Where tickets track *work*, the forum is for everything around it - decisions,
5//! discovered rules, gotchas, questions, and knowledge that should compound over a
6//! project's life. Any human or agent can post; posts nest into reply trees like a
7//! Reddit thread; everything is deterministic JSON so git tracks every message.
8//!
9//! Reads go through a small on-disk cache (`.wipe/.cache/forum-index.json`) that is
10//! rebuilt only when the forum files actually change, so search / list / watch stay
11//! fast and cheap even as the forum grows.
12//!
13//! Concurrency, honestly: like the rest of wipe, thread files are written with a
14//! last-writer-wins atomic rename and no cross-process lock. Replies to *different*
15//! threads (even on different git branches) never conflict; two writers racing on
16//! the *same* thread can lose one update, exactly like two edits to one ticket -
17//! git is the merge/audit layer. Thread-id allocation is guarded against reuse so a
18//! crash or branch divergence can never overwrite an existing thread. The read
19//! cache keys off each file's size + mtime; on filesystems with coarse mtime
20//! resolution (e.g. some removable/network mounts) a same-length edit landing in
21//! the same tick can serve a stale search result until the next change - deleting
22//! `.cache/` always forces a clean rebuild.
23
24use std::time::UNIX_EPOCH;
25
26use chrono::{DateTime, Utc};
27use regex::Regex;
28use serde::{Deserialize, Serialize};
29
30use crate::error::{Error, Result};
31use crate::model::{Attachment, Post, Thread, FORMAT_VERSION};
32use crate::store::Store;
33
34/// Spec for opening a new thread.
35#[derive(Debug, Default, Clone)]
36pub struct NewThread {
37    /// Thread headline.
38    pub title: String,
39    /// Root message body.
40    pub body: String,
41    /// Labels (same pool as tickets).
42    pub labels: Vec<String>,
43    /// Free-form references (ticket IDs, post IDs, URLs).
44    pub refs: Vec<String>,
45    /// Pre-staged attachments (see [`crate::ops::stage_media`]).
46    pub attachments: Vec<Attachment>,
47}
48
49/// Spec for replying to a post.
50#[derive(Debug, Default, Clone)]
51pub struct NewReply {
52    /// Reply body.
53    pub body: String,
54    /// Labels.
55    pub labels: Vec<String>,
56    /// References.
57    pub refs: Vec<String>,
58    /// Pre-staged attachments.
59    pub attachments: Vec<Attachment>,
60}
61
62/// The thread ID that owns a post ID (`F-1.2.3` -> `F-1`).
63pub fn thread_of(post_id: &str) -> String {
64    match post_id.split_once('.') {
65        Some((head, _)) => head.to_string(),
66        None => post_id.to_string(),
67    }
68}
69
70/// Open a new thread with a root post. Returns the created thread.
71pub fn create_thread(
72    store: &Store,
73    spec: NewThread,
74    author: &str,
75    now: DateTime<Utc>,
76) -> Result<Thread> {
77    let mut board = store.load_board()?;
78    // Never reuse an id: skip past any thread file already on disk (board.json and
79    // the forum/*.json files can diverge across git operations or a crash between
80    // the two writes below).
81    let mut n = board.next_thread;
82    let mut id = format!("F-{n}");
83    while store.thread_exists(&id) {
84        n += 1;
85        id = format!("F-{n}");
86    }
87    board.next_thread = n + 1;
88
89    let mut root = Post::new(id.clone(), author, spec.body, now);
90    root.labels = spec.labels;
91    root.refs = spec.refs;
92    root.attachments = spec.attachments;
93
94    let thread = Thread {
95        version: FORMAT_VERSION,
96        id: id.clone(),
97        title: spec.title,
98        root,
99        created: now,
100        updated: now,
101    };
102    // Persist the counter FIRST: if we're interrupted between the two writes, the
103    // id is over-allocated (a harmless gap) rather than reused next time.
104    store.save_board(&board)?;
105    store.save_thread(&thread)?;
106    Ok(thread)
107}
108
109/// Reply to an existing post (at any depth). Returns the new post's ID.
110pub fn reply(
111    store: &Store,
112    parent_id: &str,
113    spec: NewReply,
114    author: &str,
115    now: DateTime<Utc>,
116) -> Result<String> {
117    let tid = thread_of(parent_id);
118    let mut thread = store.load_thread(&tid)?;
119    let parent = thread
120        .root
121        .find_mut(parent_id)
122        .ok_or_else(|| Error::PostNotFound(parent_id.to_string()))?;
123
124    let child_id = format!("{parent_id}.{}", parent.next_reply);
125    parent.next_reply += 1;
126
127    let mut post = Post::new(child_id.clone(), author, spec.body, now);
128    post.labels = spec.labels;
129    post.refs = spec.refs;
130    post.attachments = spec.attachments;
131    parent.replies.push(post);
132
133    thread.updated = now;
134    store.save_thread(&thread)?;
135    Ok(child_id)
136}
137
138/// Edit a post's body (records an `edited` timestamp).
139pub fn edit_post(store: &Store, id: &str, body: &str, now: DateTime<Utc>) -> Result<()> {
140    let tid = thread_of(id);
141    let mut thread = store.load_thread(&tid)?;
142    let post = thread
143        .root
144        .find_mut(id)
145        .ok_or_else(|| Error::PostNotFound(id.to_string()))?;
146    post.body = body.to_string();
147    post.edited = Some(now);
148    // Editing the root headline is done via a title arg on the CLI; here we only
149    // touch the body so the operation is minimal and predictable.
150    thread.updated = now;
151    store.save_thread(&thread)?;
152    Ok(())
153}
154
155/// Delete a post and its entire subtree. Deleting a thread's root removes the
156/// whole thread file.
157pub fn delete_post(store: &Store, id: &str, now: DateTime<Utc>) -> Result<()> {
158    let tid = thread_of(id);
159    if id == tid {
160        // Root: the whole thread goes.
161        return store.delete_thread(&tid);
162    }
163    let mut thread = store.load_thread(&tid)?;
164    if !thread.root.remove_child(id) {
165        return Err(Error::PostNotFound(id.to_string()));
166    }
167    thread.updated = now;
168    store.save_thread(&thread)?;
169    Ok(())
170}
171
172/// Load a whole thread by thread ID.
173pub fn get_thread(store: &Store, thread_id: &str) -> Result<Thread> {
174    store.load_thread(&thread_of(thread_id))
175}
176
177// --- flattened view + cache ------------------------------------------------
178
179/// A single post flattened out of its tree, with context for search/list/watch.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct PostView {
182    /// Dotted post ID.
183    pub id: String,
184    /// Owning thread ID.
185    pub thread_id: String,
186    /// Owning thread title.
187    pub thread_title: String,
188    /// Author identity.
189    pub author: String,
190    /// Message body.
191    pub body: String,
192    /// Labels on this post.
193    pub labels: Vec<String>,
194    /// References on this post.
195    pub refs: Vec<String>,
196    /// Depth in the tree (root = 0).
197    pub depth: usize,
198    /// Number of direct replies.
199    pub replies: usize,
200    /// Number of attachments.
201    pub attachments: usize,
202    /// Created timestamp.
203    pub created: DateTime<Utc>,
204    /// Edited timestamp, if any.
205    pub edited: Option<DateTime<Utc>>,
206}
207
208#[derive(Serialize, Deserialize)]
209struct CacheFile {
210    /// (file count, summed mtime nanos) - changes on any add/remove/edit.
211    signature: (usize, u128),
212    posts: Vec<PostView>,
213}
214
215/// A cheap fingerprint of the forum directory: the file count plus a running sum
216/// of each thread file's size and modification time. Any post added, removed, or
217/// edited changes a file's size and/or mtime, and therefore this signature. Size
218/// is included so the cache still invalidates when two writes land within the
219/// filesystem's mtime resolution (which happens in fast/back-to-back updates).
220fn forum_signature(store: &Store) -> (usize, u128) {
221    let dir = store.forum_dir();
222    let mut count = 0usize;
223    let mut sum: u128 = 0;
224    if let Ok(entries) = std::fs::read_dir(&dir) {
225        for entry in entries.flatten() {
226            let path = entry.path();
227            if path.extension().and_then(|e| e.to_str()) != Some("json") {
228                continue;
229            }
230            count += 1;
231            if let Ok(meta) = entry.metadata() {
232                sum = sum.wrapping_add(meta.len() as u128);
233                if let Ok(mt) = meta.modified() {
234                    if let Ok(d) = mt.duration_since(UNIX_EPOCH) {
235                        sum = sum.wrapping_add(d.as_nanos());
236                    }
237                }
238            }
239        }
240    }
241    (count, sum)
242}
243
244fn cache_path(store: &Store) -> std::path::PathBuf {
245    store.cache_dir().join("forum-index.json")
246}
247
248fn flatten(thread: &Thread) -> Vec<PostView> {
249    let mut out = Vec::new();
250    thread.root.walk(0, &mut |p: &Post, depth: usize| {
251        out.push(PostView {
252            id: p.id.clone(),
253            thread_id: thread.id.clone(),
254            thread_title: thread.title.clone(),
255            author: p.author.clone(),
256            body: p.body.clone(),
257            labels: p.labels.clone(),
258            refs: p.refs.clone(),
259            depth,
260            replies: p.replies.len(),
261            attachments: p.attachments.len(),
262            created: p.created,
263            edited: p.edited,
264        });
265    });
266    out
267}
268
269/// Every post across the forum, flattened, newest-first. Served from an on-disk
270/// cache that is rebuilt only when the forum files change.
271pub fn index(store: &Store) -> Result<Vec<PostView>> {
272    let sig = forum_signature(store);
273    let cpath = cache_path(store);
274
275    if let Ok(bytes) = std::fs::read(&cpath) {
276        if let Ok(cache) = serde_json::from_slice::<CacheFile>(&bytes) {
277            if cache.signature == sig {
278                return Ok(cache.posts);
279            }
280        }
281    }
282
283    let mut posts: Vec<PostView> = Vec::new();
284    for thread in store.load_all_threads()? {
285        posts.extend(flatten(&thread));
286    }
287    posts.sort_by_key(|p| std::cmp::Reverse(p.created)); // newest first
288
289    // Best-effort cache write; never fail a read over a cache issue.
290    if let Some(dir) = cpath.parent() {
291        let _ = std::fs::create_dir_all(dir);
292    }
293    if let Ok(mut s) = serde_json::to_string(&CacheFile {
294        signature: sig,
295        posts: posts.clone(),
296    }) {
297        s.push('\n');
298        let _ = std::fs::write(&cpath, s);
299    }
300    Ok(posts)
301}
302
303// --- search ----------------------------------------------------------------
304
305/// A forum search. All set filters must match (AND).
306#[derive(Debug, Default, Clone)]
307pub struct SearchQuery {
308    /// Regex matched against the body (or the thread title when `titles_only`).
309    pub pattern: Option<Regex>,
310    /// Author substring (case-insensitive).
311    pub author: Option<String>,
312    /// Labels that must all be present.
313    pub labels: Vec<String>,
314    /// Restrict to a thread (or a subtree, by ID prefix) e.g. `F-1` or `F-1.2`.
315    pub scope: Option<String>,
316    /// Only consider posts at this depth or shallower.
317    pub max_depth: Option<usize>,
318    /// Match only thread titles (root posts).
319    pub titles_only: bool,
320    /// Cap the number of results.
321    pub limit: Option<usize>,
322}
323
324/// Compile a search pattern. `case_insensitive` adds the `i` flag; multi-line
325/// mode is on so `^`/`$` anchor to each line of a multi-line post body (the
326/// intuitive behavior for line-oriented content). Rust's `regex` is linear-time,
327/// so user patterns cannot cause catastrophic backtracking / ReDoS.
328pub fn compile_pattern(pat: &str, case_insensitive: bool) -> Result<Regex> {
329    regex::RegexBuilder::new(pat)
330        .case_insensitive(case_insensitive)
331        .multi_line(true)
332        .build()
333        .map_err(|e| Error::msg(format!("invalid search pattern: {e}")))
334}
335
336/// True if `id` is within the `scope` thread/subtree (prefix match on dotted IDs).
337/// The trailing `.` makes `F-1` match `F-1.2` but not `F-10`.
338fn in_scope(id: &str, scope: &str) -> bool {
339    id == scope || id.starts_with(&format!("{scope}."))
340}
341
342/// Whether a post matches a query (all set filters AND together). Shared by
343/// [`search`] and `wipe forum watch` so both apply identical semantics.
344pub fn matches(p: &PostView, q: &SearchQuery) -> bool {
345    if q.titles_only && p.depth != 0 {
346        return false;
347    }
348    if let Some(scope) = &q.scope {
349        if !in_scope(&p.id, scope) {
350            return false;
351        }
352    }
353    if let Some(md) = q.max_depth {
354        if p.depth > md {
355            return false;
356        }
357    }
358    if let Some(a) = &q.author {
359        if !p.author.to_lowercase().contains(&a.to_lowercase()) {
360            return false;
361        }
362    }
363    if !q.labels.iter().all(|l| p.labels.contains(l)) {
364        return false;
365    }
366    if let Some(re) = &q.pattern {
367        let hit = if q.titles_only {
368            re.is_match(&p.thread_title)
369        } else {
370            // A plain search also finds a thread by its title (via the root post),
371            // so title keywords are discoverable without hiding replies.
372            re.is_match(&p.body) || (p.depth == 0 && re.is_match(&p.thread_title))
373        };
374        if !hit {
375            return false;
376        }
377    }
378    true
379}
380
381/// Run a search over the (cached) flattened forum.
382pub fn search(store: &Store, q: &SearchQuery) -> Result<Vec<PostView>> {
383    let mut out: Vec<PostView> = index(store)?
384        .into_iter()
385        .filter(|p| matches(p, q))
386        .collect();
387    if let Some(n) = q.limit {
388        out.truncate(n);
389    }
390    Ok(out)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use chrono::TimeZone;
397
398    fn now() -> DateTime<Utc> {
399        Utc.with_ymd_and_hms(2026, 7, 3, 12, 0, 0).unwrap()
400    }
401
402    fn project() -> (tempfile::TempDir, Store) {
403        let dir = tempfile::tempdir().unwrap();
404        let store = Store::init(dir.path(), "Forum", now()).unwrap();
405        (dir, store)
406    }
407
408    fn post(body: &str) -> NewReply {
409        NewReply {
410            body: body.into(),
411            ..Default::default()
412        }
413    }
414
415    #[test]
416    fn threads_and_replies_get_dotted_ids() {
417        let (_d, s) = project();
418        let t = create_thread(
419            &s,
420            NewThread {
421                title: "Decisions".into(),
422                body: "Use OAuth".into(),
423                labels: vec!["decision".into()],
424                ..Default::default()
425            },
426            "ada",
427            now(),
428        )
429        .unwrap();
430        assert_eq!(t.id, "F-1");
431        assert_eq!(t.root.id, "F-1");
432
433        let r1 = reply(&s, "F-1", post("agree"), "bob", now()).unwrap();
434        let r2 = reply(&s, "F-1", post("also"), "cara", now()).unwrap();
435        assert_eq!(r1, "F-1.1");
436        assert_eq!(r2, "F-1.2");
437        let nested = reply(&s, "F-1.1", post("why?"), "dan", now()).unwrap();
438        assert_eq!(nested, "F-1.1.1");
439
440        // A second thread starts a fresh counter.
441        let t2 = create_thread(
442            &s,
443            NewThread {
444                title: "Gotchas".into(),
445                body: "watch the cache".into(),
446                ..Default::default()
447            },
448            "ada",
449            now(),
450        )
451        .unwrap();
452        assert_eq!(t2.id, "F-2");
453    }
454
455    #[test]
456    fn deleting_a_post_removes_its_whole_subtree() {
457        let (_d, s) = project();
458        create_thread(
459            &s,
460            NewThread {
461                title: "T".into(),
462                body: "root".into(),
463                ..Default::default()
464            },
465            "ada",
466            now(),
467        )
468        .unwrap();
469        reply(&s, "F-1", post("a"), "b", now()).unwrap(); // F-1.1
470        reply(&s, "F-1.1", post("a.a"), "b", now()).unwrap(); // F-1.1.1
471        reply(&s, "F-1", post("c"), "b", now()).unwrap(); // F-1.2
472
473        // Delete F-1.1 -> F-1.1 and F-1.1.1 gone, F-1 and F-1.2 remain.
474        delete_post(&s, "F-1.1", now()).unwrap();
475        let ids: Vec<String> = index(&s).unwrap().into_iter().map(|p| p.id).collect();
476        assert!(ids.contains(&"F-1".to_string()));
477        assert!(ids.contains(&"F-1.2".to_string()));
478        assert!(!ids.iter().any(|i| i.starts_with("F-1.1")));
479
480        // Deleting the root removes the whole thread file.
481        delete_post(&s, "F-1", now()).unwrap();
482        assert!(matches!(
483            s.load_thread("F-1"),
484            Err(Error::ThreadNotFound(_))
485        ));
486        assert!(index(&s).unwrap().is_empty());
487    }
488
489    #[test]
490    fn search_filters_by_pattern_author_label_and_scope() {
491        let (_d, s) = project();
492        create_thread(
493            &s,
494            NewThread {
495                title: "Auth design".into(),
496                body: "We will use OAuth 2.1 with PKCE".into(),
497                labels: vec!["decision".into()],
498                ..Default::default()
499            },
500            "ada@x.com",
501            now(),
502        )
503        .unwrap();
504        reply(
505            &s,
506            "F-1",
507            NewReply {
508                body: "beware token refresh races".into(),
509                labels: vec!["gotcha".into()],
510                ..Default::default()
511            },
512            "claude",
513            now(),
514        )
515        .unwrap();
516        create_thread(
517            &s,
518            NewThread {
519                title: "Unrelated".into(),
520                body: "nothing to see".into(),
521                ..Default::default()
522            },
523            "bob",
524            now(),
525        )
526        .unwrap();
527
528        // Regex on body.
529        let hits = search(
530            &s,
531            &SearchQuery {
532                pattern: Some(compile_pattern("OAuth|token", false).unwrap()),
533                ..Default::default()
534            },
535        )
536        .unwrap();
537        assert_eq!(hits.len(), 2);
538
539        // Author filter.
540        let by_agent = search(
541            &s,
542            &SearchQuery {
543                author: Some("claude".into()),
544                ..Default::default()
545            },
546        )
547        .unwrap();
548        assert_eq!(by_agent.len(), 1);
549        assert_eq!(by_agent[0].id, "F-1.1");
550
551        // Label filter.
552        let gotchas = search(
553            &s,
554            &SearchQuery {
555                labels: vec!["gotcha".into()],
556                ..Default::default()
557            },
558        )
559        .unwrap();
560        assert_eq!(gotchas.len(), 1);
561
562        // Titles only.
563        let titles = search(
564            &s,
565            &SearchQuery {
566                pattern: Some(compile_pattern("auth", true).unwrap()),
567                titles_only: true,
568                ..Default::default()
569            },
570        )
571        .unwrap();
572        assert_eq!(titles.len(), 1);
573        assert_eq!(titles[0].id, "F-1");
574
575        // Scope to a thread subtree.
576        let scoped = search(
577            &s,
578            &SearchQuery {
579                scope: Some("F-1".into()),
580                ..Default::default()
581            },
582        )
583        .unwrap();
584        assert_eq!(scoped.len(), 2);
585    }
586
587    #[test]
588    fn default_search_finds_threads_by_title() {
589        let (_d, s) = project();
590        create_thread(
591            &s,
592            NewThread {
593                title: "Auth design".into(),
594                body: "OAuth 2.1".into(),
595                ..Default::default()
596            },
597            "a",
598            now(),
599        )
600        .unwrap();
601        reply(&s, "F-1", post("a reply"), "b", now()).unwrap();
602        // "design" appears only in the title; a plain search still finds the root.
603        let hits = search(
604            &s,
605            &SearchQuery {
606                pattern: Some(compile_pattern("design", true).unwrap()),
607                ..Default::default()
608            },
609        )
610        .unwrap();
611        assert_eq!(hits.len(), 1);
612        assert_eq!(hits[0].id, "F-1");
613    }
614
615    #[test]
616    fn search_anchors_are_line_scoped() {
617        let (_d, s) = project();
618        create_thread(
619            &s,
620            NewThread {
621                title: "T".into(),
622                body: "context line\nERROR: boom".into(),
623                ..Default::default()
624            },
625            "a",
626            now(),
627        )
628        .unwrap();
629        // `^ERROR` must match a line inside a multi-line body.
630        let hits = search(
631            &s,
632            &SearchQuery {
633                pattern: Some(compile_pattern("^ERROR", false).unwrap()),
634                ..Default::default()
635            },
636        )
637        .unwrap();
638        assert_eq!(hits.len(), 1);
639    }
640
641    #[test]
642    fn create_thread_never_reuses_an_id() {
643        let (_d, s) = project();
644        let t1 = create_thread(
645            &s,
646            NewThread {
647                title: "T".into(),
648                body: "one".into(),
649                ..Default::default()
650            },
651            "a",
652            now(),
653        )
654        .unwrap();
655        assert_eq!(t1.id, "F-1");
656        // Simulate board/forum divergence: rewind the counter while F-1.json stays.
657        let mut b = s.load_board().unwrap();
658        b.next_thread = 1;
659        s.save_board(&b).unwrap();
660        let t2 = create_thread(
661            &s,
662            NewThread {
663                title: "T2".into(),
664                body: "two".into(),
665                ..Default::default()
666            },
667            "a",
668            now(),
669        )
670        .unwrap();
671        // Must skip the existing F-1 and allocate F-2, not overwrite F-1.
672        assert_eq!(t2.id, "F-2");
673        assert_eq!(s.load_thread("F-1").unwrap().root.body, "one");
674    }
675
676    #[test]
677    fn path_traversal_ids_are_rejected() {
678        let (_d, s) = project();
679        create_thread(
680            &s,
681            NewThread {
682                title: "T".into(),
683                body: "x".into(),
684                ..Default::default()
685            },
686            "a",
687            now(),
688        )
689        .unwrap();
690        for bad in [
691            "F-1/../../secret",
692            "..",
693            "F-1/../F-1",
694            "/etc/passwd",
695            "F-1\\..\\x",
696        ] {
697            assert!(
698                get_thread(&s, bad).is_err(),
699                "get_thread({bad}) should error"
700            );
701            assert!(
702                reply(&s, bad, post("x"), "a", now()).is_err(),
703                "reply({bad}) should error"
704            );
705            assert!(
706                delete_post(&s, bad, now()).is_err(),
707                "delete_post({bad}) should error"
708            );
709        }
710        // The legitimate thread is untouched.
711        assert!(get_thread(&s, "F-1").is_ok());
712    }
713
714    #[test]
715    fn index_cache_reflects_new_posts() {
716        let (_d, s) = project();
717        create_thread(
718            &s,
719            NewThread {
720                title: "T".into(),
721                body: "one".into(),
722                ..Default::default()
723            },
724            "ada",
725            now(),
726        )
727        .unwrap();
728        assert_eq!(index(&s).unwrap().len(), 1); // builds + caches
729        assert_eq!(index(&s).unwrap().len(), 1); // served from cache
730        reply(&s, "F-1", post("two"), "bob", now()).unwrap();
731        // Signature changed -> cache rebuilt -> new post visible.
732        assert_eq!(index(&s).unwrap().len(), 2);
733    }
734}