1use 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#[derive(Debug, Default, Clone)]
36pub struct NewThread {
37 pub title: String,
39 pub body: String,
41 pub labels: Vec<String>,
43 pub refs: Vec<String>,
45 pub attachments: Vec<Attachment>,
47}
48
49#[derive(Debug, Default, Clone)]
51pub struct NewReply {
52 pub body: String,
54 pub labels: Vec<String>,
56 pub refs: Vec<String>,
58 pub attachments: Vec<Attachment>,
60}
61
62pub 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
70pub 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 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 store.save_board(&board)?;
105 store.save_thread(&thread)?;
106 Ok(thread)
107}
108
109pub 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
138pub 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 thread.updated = now;
151 store.save_thread(&thread)?;
152 Ok(())
153}
154
155pub fn delete_post(store: &Store, id: &str, now: DateTime<Utc>) -> Result<()> {
158 let tid = thread_of(id);
159 if id == tid {
160 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
172pub fn get_thread(store: &Store, thread_id: &str) -> Result<Thread> {
174 store.load_thread(&thread_of(thread_id))
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct PostView {
182 pub id: String,
184 pub thread_id: String,
186 pub thread_title: String,
188 pub author: String,
190 pub body: String,
192 pub labels: Vec<String>,
194 pub refs: Vec<String>,
196 pub depth: usize,
198 pub replies: usize,
200 pub attachments: usize,
202 pub created: DateTime<Utc>,
204 pub edited: Option<DateTime<Utc>>,
206}
207
208#[derive(Serialize, Deserialize)]
209struct CacheFile {
210 signature: (usize, u128),
212 posts: Vec<PostView>,
213}
214
215fn 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
269pub 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)); 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#[derive(Debug, Default, Clone)]
307pub struct SearchQuery {
308 pub pattern: Option<Regex>,
310 pub author: Option<String>,
312 pub labels: Vec<String>,
314 pub scope: Option<String>,
316 pub max_depth: Option<usize>,
318 pub titles_only: bool,
320 pub limit: Option<usize>,
322}
323
324pub 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
336fn in_scope(id: &str, scope: &str) -> bool {
339 id == scope || id.starts_with(&format!("{scope}."))
340}
341
342pub 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 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
381pub 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 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(); reply(&s, "F-1.1", post("a.a"), "b", now()).unwrap(); reply(&s, "F-1", post("c"), "b", now()).unwrap(); 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 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 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 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 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 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 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 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 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 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 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 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); assert_eq!(index(&s).unwrap().len(), 1); reply(&s, "F-1", post("two"), "bob", now()).unwrap();
731 assert_eq!(index(&s).unwrap().len(), 2);
733 }
734}