use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::project::ProjectLayout;
fn sessions_dir(layout: &ProjectLayout) -> PathBuf {
layout.root.join(".inkhaven").join("linguistic-sessions")
}
pub(crate) fn session_slug(name: &str) -> String {
let s: String = name
.trim()
.chars()
.map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect();
let s = s.trim_matches('-').to_string();
if s.is_empty() { "default".to_string() } else { s }
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionTurn {
pub prompt: String,
pub response: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Session {
pub name: String,
pub slug: String,
pub created: String,
#[serde(default)]
pub turns: Vec<SessionTurn>,
}
impl Session {
fn path(layout: &ProjectLayout, slug: &str) -> PathBuf {
sessions_dir(layout).join(format!("{slug}.json"))
}
fn load(layout: &ProjectLayout, slug: &str) -> Option<Session> {
let raw = std::fs::read_to_string(Session::path(layout, slug)).ok()?;
serde_json::from_str(&raw).ok()
}
pub(crate) fn open_or_create(
layout: &ProjectLayout,
display_name: &str,
now: String,
) -> Result<Session> {
let slug = session_slug(display_name);
if let Some(s) = Session::load(layout, &slug) {
return Ok(s);
}
let s = Session {
name: display_name.trim().to_string(),
slug,
created: now,
turns: Vec::new(),
};
s.save(layout)?;
Ok(s)
}
pub(crate) fn record(&mut self, turn: SessionTurn, layout: &ProjectLayout) -> Result<()> {
self.turns.push(turn);
self.save(layout)
}
fn save(&self, layout: &ProjectLayout) -> Result<()> {
let dir = sessions_dir(layout);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let json = serde_json::to_string_pretty(self).context("serialise session")?;
crate::io_atomic::write(&Session::path(layout, &self.slug), json.as_bytes())
.with_context(|| "write linguistic session")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_is_stable_and_filesystem_safe() {
assert_eq!(session_slug("Quenya notes"), "quenya-notes");
assert_eq!(session_slug(" ?? "), "default");
assert_eq!(session_slug(""), "default");
assert_eq!(session_slug("A/B:C"), "a-b-c");
}
#[test]
fn open_or_create_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let layout = ProjectLayout::new(dir.path());
std::fs::create_dir_all(dir.path().join(".inkhaven")).unwrap();
let mut s = Session::open_or_create(&layout, "Work", "2026-07-16T00:00:00Z".into()).unwrap();
s.record(
SessionTurn {
prompt: "how many vowels?".into(),
response: "five".into(),
scope: Some("Quenya".into()),
timestamp: "2026-07-16T00:00:01Z".into(),
},
&layout,
)
.unwrap();
let reloaded = Session::open_or_create(&layout, "Work", "later".into()).unwrap();
assert_eq!(reloaded.turns.len(), 1);
assert_eq!(reloaded.turns[0].prompt, "how many vowels?");
assert_eq!(reloaded.turns[0].scope.as_deref(), Some("Quenya"));
assert_eq!(reloaded.created, "2026-07-16T00:00:00Z");
}
}