use std::time::UNIX_EPOCH;
use chrono::{DateTime, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::model::{Attachment, Post, Thread, FORMAT_VERSION};
use crate::store::Store;
#[derive(Debug, Default, Clone)]
pub struct NewThread {
pub title: String,
pub body: String,
pub labels: Vec<String>,
pub refs: Vec<String>,
pub attachments: Vec<Attachment>,
}
#[derive(Debug, Default, Clone)]
pub struct NewReply {
pub body: String,
pub labels: Vec<String>,
pub refs: Vec<String>,
pub attachments: Vec<Attachment>,
}
pub fn thread_of(post_id: &str) -> String {
match post_id.split_once('.') {
Some((head, _)) => head.to_string(),
None => post_id.to_string(),
}
}
pub fn create_thread(
store: &Store,
spec: NewThread,
author: &str,
now: DateTime<Utc>,
) -> Result<Thread> {
let mut board = store.load_board()?;
let mut n = board.next_thread;
let mut id = format!("F-{n}");
while store.thread_exists(&id) {
n += 1;
id = format!("F-{n}");
}
board.next_thread = n + 1;
let mut root = Post::new(id.clone(), author, spec.body, now);
root.labels = spec.labels;
root.refs = spec.refs;
root.attachments = spec.attachments;
let thread = Thread {
version: FORMAT_VERSION,
id: id.clone(),
title: spec.title,
root,
created: now,
updated: now,
};
store.save_board(&board)?;
store.save_thread(&thread)?;
Ok(thread)
}
pub fn reply(
store: &Store,
parent_id: &str,
spec: NewReply,
author: &str,
now: DateTime<Utc>,
) -> Result<String> {
let tid = thread_of(parent_id);
let mut thread = store.load_thread(&tid)?;
let parent = thread
.root
.find_mut(parent_id)
.ok_or_else(|| Error::PostNotFound(parent_id.to_string()))?;
let child_id = format!("{parent_id}.{}", parent.next_reply);
parent.next_reply += 1;
let mut post = Post::new(child_id.clone(), author, spec.body, now);
post.labels = spec.labels;
post.refs = spec.refs;
post.attachments = spec.attachments;
parent.replies.push(post);
thread.updated = now;
store.save_thread(&thread)?;
Ok(child_id)
}
pub fn edit_post(store: &Store, id: &str, body: &str, now: DateTime<Utc>) -> Result<()> {
let tid = thread_of(id);
let mut thread = store.load_thread(&tid)?;
let post = thread
.root
.find_mut(id)
.ok_or_else(|| Error::PostNotFound(id.to_string()))?;
post.body = body.to_string();
post.edited = Some(now);
thread.updated = now;
store.save_thread(&thread)?;
Ok(())
}
pub fn delete_post(store: &Store, id: &str, now: DateTime<Utc>) -> Result<()> {
let tid = thread_of(id);
if id == tid {
return store.delete_thread(&tid);
}
let mut thread = store.load_thread(&tid)?;
if !thread.root.remove_child(id) {
return Err(Error::PostNotFound(id.to_string()));
}
thread.updated = now;
store.save_thread(&thread)?;
Ok(())
}
pub fn get_thread(store: &Store, thread_id: &str) -> Result<Thread> {
store.load_thread(&thread_of(thread_id))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PostView {
pub id: String,
pub thread_id: String,
pub thread_title: String,
pub author: String,
pub body: String,
pub labels: Vec<String>,
pub refs: Vec<String>,
pub depth: usize,
pub replies: usize,
pub attachments: usize,
pub created: DateTime<Utc>,
pub edited: Option<DateTime<Utc>>,
}
#[derive(Serialize, Deserialize)]
struct CacheFile {
signature: (usize, u128),
posts: Vec<PostView>,
}
fn forum_signature(store: &Store) -> (usize, u128) {
let dir = store.forum_dir();
let mut count = 0usize;
let mut sum: u128 = 0;
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
count += 1;
if let Ok(meta) = entry.metadata() {
sum = sum.wrapping_add(meta.len() as u128);
if let Ok(mt) = meta.modified() {
if let Ok(d) = mt.duration_since(UNIX_EPOCH) {
sum = sum.wrapping_add(d.as_nanos());
}
}
}
}
}
(count, sum)
}
fn cache_path(store: &Store) -> std::path::PathBuf {
store.cache_dir().join("forum-index.json")
}
fn flatten(thread: &Thread) -> Vec<PostView> {
let mut out = Vec::new();
thread.root.walk(0, &mut |p: &Post, depth: usize| {
out.push(PostView {
id: p.id.clone(),
thread_id: thread.id.clone(),
thread_title: thread.title.clone(),
author: p.author.clone(),
body: p.body.clone(),
labels: p.labels.clone(),
refs: p.refs.clone(),
depth,
replies: p.replies.len(),
attachments: p.attachments.len(),
created: p.created,
edited: p.edited,
});
});
out
}
pub fn index(store: &Store) -> Result<Vec<PostView>> {
let sig = forum_signature(store);
let cpath = cache_path(store);
if let Ok(bytes) = std::fs::read(&cpath) {
if let Ok(cache) = serde_json::from_slice::<CacheFile>(&bytes) {
if cache.signature == sig {
return Ok(cache.posts);
}
}
}
let mut posts: Vec<PostView> = Vec::new();
for thread in store.load_all_threads()? {
posts.extend(flatten(&thread));
}
posts.sort_by_key(|p| std::cmp::Reverse(p.created));
if let Some(dir) = cpath.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(mut s) = serde_json::to_string(&CacheFile {
signature: sig,
posts: posts.clone(),
}) {
s.push('\n');
let _ = std::fs::write(&cpath, s);
}
Ok(posts)
}
#[derive(Debug, Default, Clone)]
pub struct SearchQuery {
pub pattern: Option<Regex>,
pub author: Option<String>,
pub labels: Vec<String>,
pub scope: Option<String>,
pub max_depth: Option<usize>,
pub titles_only: bool,
pub limit: Option<usize>,
}
pub fn compile_pattern(pat: &str, case_insensitive: bool) -> Result<Regex> {
regex::RegexBuilder::new(pat)
.case_insensitive(case_insensitive)
.multi_line(true)
.build()
.map_err(|e| Error::msg(format!("invalid search pattern: {e}")))
}
fn in_scope(id: &str, scope: &str) -> bool {
id == scope || id.starts_with(&format!("{scope}."))
}
pub fn matches(p: &PostView, q: &SearchQuery) -> bool {
if q.titles_only && p.depth != 0 {
return false;
}
if let Some(scope) = &q.scope {
if !in_scope(&p.id, scope) {
return false;
}
}
if let Some(md) = q.max_depth {
if p.depth > md {
return false;
}
}
if let Some(a) = &q.author {
if !p.author.to_lowercase().contains(&a.to_lowercase()) {
return false;
}
}
if !q.labels.iter().all(|l| p.labels.contains(l)) {
return false;
}
if let Some(re) = &q.pattern {
let hit = if q.titles_only {
re.is_match(&p.thread_title)
} else {
re.is_match(&p.body) || (p.depth == 0 && re.is_match(&p.thread_title))
};
if !hit {
return false;
}
}
true
}
pub fn search(store: &Store, q: &SearchQuery) -> Result<Vec<PostView>> {
let mut out: Vec<PostView> = index(store)?
.into_iter()
.filter(|p| matches(p, q))
.collect();
if let Some(n) = q.limit {
out.truncate(n);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 7, 3, 12, 0, 0).unwrap()
}
fn project() -> (tempfile::TempDir, Store) {
let dir = tempfile::tempdir().unwrap();
let store = Store::init(dir.path(), "Forum", now()).unwrap();
(dir, store)
}
fn post(body: &str) -> NewReply {
NewReply {
body: body.into(),
..Default::default()
}
}
#[test]
fn threads_and_replies_get_dotted_ids() {
let (_d, s) = project();
let t = create_thread(
&s,
NewThread {
title: "Decisions".into(),
body: "Use OAuth".into(),
labels: vec!["decision".into()],
..Default::default()
},
"ada",
now(),
)
.unwrap();
assert_eq!(t.id, "F-1");
assert_eq!(t.root.id, "F-1");
let r1 = reply(&s, "F-1", post("agree"), "bob", now()).unwrap();
let r2 = reply(&s, "F-1", post("also"), "cara", now()).unwrap();
assert_eq!(r1, "F-1.1");
assert_eq!(r2, "F-1.2");
let nested = reply(&s, "F-1.1", post("why?"), "dan", now()).unwrap();
assert_eq!(nested, "F-1.1.1");
let t2 = create_thread(
&s,
NewThread {
title: "Gotchas".into(),
body: "watch the cache".into(),
..Default::default()
},
"ada",
now(),
)
.unwrap();
assert_eq!(t2.id, "F-2");
}
#[test]
fn deleting_a_post_removes_its_whole_subtree() {
let (_d, s) = project();
create_thread(
&s,
NewThread {
title: "T".into(),
body: "root".into(),
..Default::default()
},
"ada",
now(),
)
.unwrap();
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();
let ids: Vec<String> = index(&s).unwrap().into_iter().map(|p| p.id).collect();
assert!(ids.contains(&"F-1".to_string()));
assert!(ids.contains(&"F-1.2".to_string()));
assert!(!ids.iter().any(|i| i.starts_with("F-1.1")));
delete_post(&s, "F-1", now()).unwrap();
assert!(matches!(
s.load_thread("F-1"),
Err(Error::ThreadNotFound(_))
));
assert!(index(&s).unwrap().is_empty());
}
#[test]
fn search_filters_by_pattern_author_label_and_scope() {
let (_d, s) = project();
create_thread(
&s,
NewThread {
title: "Auth design".into(),
body: "We will use OAuth 2.1 with PKCE".into(),
labels: vec!["decision".into()],
..Default::default()
},
"ada@x.com",
now(),
)
.unwrap();
reply(
&s,
"F-1",
NewReply {
body: "beware token refresh races".into(),
labels: vec!["gotcha".into()],
..Default::default()
},
"claude",
now(),
)
.unwrap();
create_thread(
&s,
NewThread {
title: "Unrelated".into(),
body: "nothing to see".into(),
..Default::default()
},
"bob",
now(),
)
.unwrap();
let hits = search(
&s,
&SearchQuery {
pattern: Some(compile_pattern("OAuth|token", false).unwrap()),
..Default::default()
},
)
.unwrap();
assert_eq!(hits.len(), 2);
let by_agent = search(
&s,
&SearchQuery {
author: Some("claude".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(by_agent.len(), 1);
assert_eq!(by_agent[0].id, "F-1.1");
let gotchas = search(
&s,
&SearchQuery {
labels: vec!["gotcha".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(gotchas.len(), 1);
let titles = search(
&s,
&SearchQuery {
pattern: Some(compile_pattern("auth", true).unwrap()),
titles_only: true,
..Default::default()
},
)
.unwrap();
assert_eq!(titles.len(), 1);
assert_eq!(titles[0].id, "F-1");
let scoped = search(
&s,
&SearchQuery {
scope: Some("F-1".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(scoped.len(), 2);
}
#[test]
fn default_search_finds_threads_by_title() {
let (_d, s) = project();
create_thread(
&s,
NewThread {
title: "Auth design".into(),
body: "OAuth 2.1".into(),
..Default::default()
},
"a",
now(),
)
.unwrap();
reply(&s, "F-1", post("a reply"), "b", now()).unwrap();
let hits = search(
&s,
&SearchQuery {
pattern: Some(compile_pattern("design", true).unwrap()),
..Default::default()
},
)
.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "F-1");
}
#[test]
fn search_anchors_are_line_scoped() {
let (_d, s) = project();
create_thread(
&s,
NewThread {
title: "T".into(),
body: "context line\nERROR: boom".into(),
..Default::default()
},
"a",
now(),
)
.unwrap();
let hits = search(
&s,
&SearchQuery {
pattern: Some(compile_pattern("^ERROR", false).unwrap()),
..Default::default()
},
)
.unwrap();
assert_eq!(hits.len(), 1);
}
#[test]
fn create_thread_never_reuses_an_id() {
let (_d, s) = project();
let t1 = create_thread(
&s,
NewThread {
title: "T".into(),
body: "one".into(),
..Default::default()
},
"a",
now(),
)
.unwrap();
assert_eq!(t1.id, "F-1");
let mut b = s.load_board().unwrap();
b.next_thread = 1;
s.save_board(&b).unwrap();
let t2 = create_thread(
&s,
NewThread {
title: "T2".into(),
body: "two".into(),
..Default::default()
},
"a",
now(),
)
.unwrap();
assert_eq!(t2.id, "F-2");
assert_eq!(s.load_thread("F-1").unwrap().root.body, "one");
}
#[test]
fn path_traversal_ids_are_rejected() {
let (_d, s) = project();
create_thread(
&s,
NewThread {
title: "T".into(),
body: "x".into(),
..Default::default()
},
"a",
now(),
)
.unwrap();
for bad in [
"F-1/../../secret",
"..",
"F-1/../F-1",
"/etc/passwd",
"F-1\\..\\x",
] {
assert!(
get_thread(&s, bad).is_err(),
"get_thread({bad}) should error"
);
assert!(
reply(&s, bad, post("x"), "a", now()).is_err(),
"reply({bad}) should error"
);
assert!(
delete_post(&s, bad, now()).is_err(),
"delete_post({bad}) should error"
);
}
assert!(get_thread(&s, "F-1").is_ok());
}
#[test]
fn index_cache_reflects_new_posts() {
let (_d, s) = project();
create_thread(
&s,
NewThread {
title: "T".into(),
body: "one".into(),
..Default::default()
},
"ada",
now(),
)
.unwrap();
assert_eq!(index(&s).unwrap().len(), 1); assert_eq!(index(&s).unwrap().len(), 1); reply(&s, "F-1", post("two"), "bob", now()).unwrap();
assert_eq!(index(&s).unwrap().len(), 2);
}
}