use std::fs;
use std::io::Write;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::document::Selection;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub id: String,
pub step_id: usize,
pub role: ChatRole,
pub text: String,
pub rendered: String,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<ChatContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChatContext {
pub selected: Option<String>,
pub latex: Option<String>,
pub step_title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ChatRole {
User,
Assistant,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ChatStore {
pub messages: Vec<ChatMessage>,
}
pub struct Session {
pub dir: PathBuf,
pub board_path: PathBuf,
}
impl Session {
pub fn create(title: &str) -> std::io::Result<Session> {
let base = sessions_dir();
let slug = slugify(title);
let timestamp = chrono::Local::now().format("%Y-%m-%d").to_string();
let dir_name = format!("{}-{}", timestamp, slug);
let dir = base.join(&dir_name);
fs::create_dir_all(&dir)?;
let board_path = dir.join("board.cb.md");
let frontmatter = format!("---\ntitle: {}\n---\n\n", title);
fs::write(&board_path, frontmatter)?;
let current_path = base.join("current");
fs::write(¤t_path, dir.to_string_lossy().as_bytes())?;
Ok(Session { dir, board_path })
}
pub fn find_current() -> Option<Session> {
let current_path = sessions_dir().join("current");
let dir_str = fs::read_to_string(¤t_path).ok()?;
let dir = PathBuf::from(dir_str.trim());
let board_path = dir.join("board.cb.md");
if board_path.exists() {
Some(Session { dir, board_path })
} else {
None
}
}
pub fn append(&self, content: &str) -> std::io::Result<()> {
use fs4::fs_std::FileExt;
let file = fs::OpenOptions::new()
.append(true)
.open(&self.board_path)?;
file.lock_exclusive()?;
let mut file = file;
write!(file, "{}", content)?;
file.unlock()?;
Ok(())
}
pub fn read_board(&self) -> std::io::Result<String> {
fs::read_to_string(&self.board_path)
}
pub fn write_selection(&self, selection: &Selection) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(selection)
.map_err(std::io::Error::other)?;
let session_path = self.dir.join("selection.json");
fs::write(&session_path, &json)?;
let global_path = cliboard_dir().join("selection.json");
fs::write(&global_path, &json)?;
Ok(())
}
pub fn read_selection(&self) -> Option<Selection> {
let path = self.dir.join("selection.json");
let json = fs::read_to_string(&path).ok()?;
serde_json::from_str(&json).ok()
}
pub fn pid_path(&self) -> PathBuf {
self.dir.join("server.pid")
}
pub fn write_pid(&self, pid: u32) -> std::io::Result<()> {
fs::write(self.pid_path(), pid.to_string())
}
pub fn read_pid(&self) -> Option<u32> {
fs::read_to_string(self.pid_path())
.ok()?
.trim()
.parse()
.ok()
}
pub fn remove_pid(&self) {
let _ = fs::remove_file(self.pid_path());
}
pub fn port_path(&self) -> PathBuf {
self.dir.join("server.port")
}
pub fn write_port(&self, port: u16) -> std::io::Result<()> {
fs::write(self.port_path(), port.to_string())
}
pub fn read_port(&self) -> Option<u16> {
fs::read_to_string(self.port_path())
.ok()?
.trim()
.parse()
.ok()
}
pub fn messages_path(&self) -> PathBuf {
self.dir.join("messages.json")
}
pub fn read_messages(&self) -> std::io::Result<ChatStore> {
let path = self.messages_path();
if !path.exists() {
return Ok(ChatStore::default());
}
let data = fs::read_to_string(&path)?;
serde_json::from_str(&data).map_err(std::io::Error::other)
}
pub fn append_message(&self, msg: ChatMessage) -> std::io::Result<()> {
use fs4::fs_std::FileExt;
use std::io::{Read, Seek, SeekFrom, Write};
let path = self.messages_path();
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&path)?;
file.lock_exclusive()?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let mut store: ChatStore = if contents.is_empty() {
ChatStore::default()
} else {
serde_json::from_str(&contents).unwrap_or_default()
};
store.messages.push(msg);
let json = serde_json::to_string_pretty(&store).map_err(std::io::Error::other)?;
file.seek(SeekFrom::Start(0))?;
file.set_len(0)?;
file.write_all(json.as_bytes())?;
file.flush()?;
file.unlock()?;
Ok(())
}
pub fn pending_messages(&self) -> std::io::Result<Vec<ChatMessage>> {
let store = self.read_messages()?;
let mut last_by_step: std::collections::HashMap<usize, &ChatMessage> =
std::collections::HashMap::new();
for msg in &store.messages {
last_by_step.insert(msg.step_id, msg);
}
let pending_step_ids: std::collections::HashSet<usize> = last_by_step
.iter()
.filter(|(_, msg)| msg.role == ChatRole::User)
.map(|(step_id, _)| *step_id)
.collect();
Ok(store
.messages
.into_iter()
.filter(|m| pending_step_ids.contains(&m.step_id))
.collect())
}
pub fn messages_for_step(&self, step_id: usize) -> std::io::Result<Vec<ChatMessage>> {
let store = self.read_messages()?;
Ok(store
.messages
.into_iter()
.filter(|m| m.step_id == step_id)
.collect())
}
}
fn cliboard_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".cliboard")
}
fn sessions_dir() -> PathBuf {
cliboard_dir().join("sessions")
}
fn slugify(title: &str) -> String {
title
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("E = mc^2"), "e-mc-2");
assert_eq!(slugify(" spaces "), "spaces");
assert_eq!(slugify("Already-Slugged"), "already-slugged");
}
#[test]
fn test_cliboard_dir() {
let dir = cliboard_dir();
assert!(dir.to_string_lossy().contains(".cliboard"));
}
#[test]
fn test_sessions_dir() {
let dir = sessions_dir();
assert!(dir.to_string_lossy().contains("sessions"));
}
#[test]
fn test_slugify_special_chars() {
assert_eq!(slugify("Hello, World!"), "hello-world");
assert_eq!(slugify("a/b\\c"), "a-b-c");
assert_eq!(slugify("---dashes---"), "dashes");
assert_eq!(slugify("MiXeD CaSe"), "mixed-case");
assert_eq!(slugify("123 Numbers"), "123-numbers");
}
#[test]
fn test_slugify_unicode() {
assert_eq!(slugify("café"), "café");
assert_eq!(slugify("naïve"), "naïve");
}
#[test]
fn test_create_session() {
let tmp = tempfile::tempdir().unwrap();
let tmp_path = tmp.path().to_path_buf();
let session_dir = tmp_path.join("test-session");
fs::create_dir_all(&session_dir).unwrap();
let board_path = session_dir.join("board.cb.md");
let frontmatter = "---\ntitle: Test\n---\n\n";
fs::write(&board_path, frontmatter).unwrap();
let session = Session {
dir: session_dir.clone(),
board_path: board_path.clone(),
};
let content = session.read_board().unwrap();
assert!(content.contains("title: Test"));
session.append("\n## Step 1\n\n$$x = 1$$\n").unwrap();
let content = session.read_board().unwrap();
assert!(content.contains("## Step 1"));
assert!(content.contains("$$x = 1$$"));
}
#[test]
fn test_pid_write_read_remove() {
let tmp = tempfile::tempdir().unwrap();
let session = Session {
dir: tmp.path().to_path_buf(),
board_path: tmp.path().join("board.cb.md"),
};
assert!(session.read_pid().is_none());
session.write_pid(12345).unwrap();
assert_eq!(session.read_pid(), Some(12345));
session.remove_pid();
assert!(session.read_pid().is_none());
}
#[test]
fn test_port_write_read() {
let tmp = tempfile::tempdir().unwrap();
let session = Session {
dir: tmp.path().to_path_buf(),
board_path: tmp.path().join("board.cb.md"),
};
assert!(session.read_port().is_none());
session.write_port(8377).unwrap();
assert_eq!(session.read_port(), Some(8377));
}
#[test]
fn test_selection_write_read_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let session_dir = tmp.path().to_path_buf();
let board_path = session_dir.join("board.cb.md");
fs::write(&board_path, "").unwrap();
let session = Session {
dir: session_dir.clone(),
board_path,
};
let selection = Selection {
step_id: 1,
title: "Test Step".to_string(),
latex: "E = mc^2".to_string(),
unicode: "E = mc\u{00B2}".to_string(),
formatted: "E = mc^2".to_string(),
notes: vec!["Famous equation".to_string()],
selected_at: "2026-03-16T00:00:00".to_string(),
};
let json = serde_json::to_string_pretty(&selection).unwrap();
fs::write(session_dir.join("selection.json"), &json).unwrap();
let read_back = session.read_selection().unwrap();
assert_eq!(read_back.step_id, 1);
assert_eq!(read_back.title, "Test Step");
assert_eq!(read_back.latex, "E = mc^2");
assert_eq!(read_back.unicode, "E = mc\u{00B2}");
}
#[test]
fn test_find_current_returns_none_without_session() {
let _result = Session::find_current();
}
#[test]
fn test_append_multiple_times() {
let tmp = tempfile::tempdir().unwrap();
let board_path = tmp.path().join("board.cb.md");
fs::write(&board_path, "---\ntitle: T\n---\n").unwrap();
let session = Session {
dir: tmp.path().to_path_buf(),
board_path: board_path.clone(),
};
session.append("\n## Step 1\n\n$$a$$\n").unwrap();
session.append("\n## Step 2\n\n$$b$$\n").unwrap();
session.append("\n## Step 3\n\n$$c$$\n").unwrap();
let content = session.read_board().unwrap();
assert!(content.contains("## Step 1"));
assert!(content.contains("## Step 2"));
assert!(content.contains("## Step 3"));
}
}