pub mod index;
pub mod instructions;
pub mod manager;
pub mod model;
pub mod preview;
pub use manager::HistoryManager;
pub use model::{DisplayMessage, Session, SessionMetadata, SessionTokenCounters};
use crate::error::Result;
use std::fs;
use std::path::PathBuf;
pub(super) fn atomic_write(path: &PathBuf, content: &str) -> Result<()> {
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, content)?;
fs::rename(&tmp_path, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::{Message, SystemPrompt};
use crate::session::history::manager::{SESSIONS_DIR, SOFOS_DIR};
use crate::session::history::preview::MAX_PREVIEW_LENGTH;
use tempfile::TempDir;
#[test]
fn test_history_manager_creation() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf());
assert!(manager.is_ok());
let sofos_dir = temp_dir.path().join(SOFOS_DIR).join(SESSIONS_DIR);
assert!(sofos_dir.exists());
}
#[test]
fn test_session_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let session_id = HistoryManager::generate_session_id();
let messages = vec![Message::user("Test message")];
let system_prompt =
SystemPrompt::new_cached_with_ttl("Test system prompt".to_string(), None);
manager
.save_session(
&session_id,
&messages,
&[],
std::slice::from_ref(&system_prompt),
SessionTokenCounters::default(),
"",
false,
)
.unwrap();
let loaded = manager.load_session(&session_id).unwrap();
assert_eq!(loaded.id, session_id);
assert_eq!(loaded.api_messages.len(), 1);
assert_eq!(loaded.system_prompt, vec![system_prompt]);
}
#[test]
fn test_list_sessions() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let session_id1 = HistoryManager::generate_session_id();
let system_prompt = SystemPrompt::new_cached_with_ttl("System".to_string(), None);
manager
.save_session(
&session_id1,
&[Message::user("First session")],
&[],
std::slice::from_ref(&system_prompt),
SessionTokenCounters::default(),
"",
false,
)
.unwrap();
std::thread::sleep(std::time::Duration::from_secs(1));
let session_id2 = HistoryManager::generate_session_id();
manager
.save_session(
&session_id2,
&[Message::user("Second session")],
&[],
&[system_prompt],
SessionTokenCounters::default(),
"",
false,
)
.unwrap();
let sessions = manager.list_sessions().unwrap();
assert_eq!(sessions.len(), 2);
assert_eq!(sessions[0].preview, "Second session");
assert_eq!(sessions[1].preview, "First session");
}
#[test]
fn test_preview_extraction() {
let messages = vec![Message::user("This is a test message")];
let preview = HistoryManager::extract_preview(&messages);
assert_eq!(preview, "This is a test message");
let long_message = "a".repeat(150);
let messages = vec![Message::user(long_message)];
let preview = HistoryManager::extract_preview(&messages);
assert_eq!(preview.len(), MAX_PREVIEW_LENGTH + 3);
assert!(preview.ends_with("..."));
let cyrillic_message = "създай текстов файл test-3.txt";
let messages = vec![Message::user(cyrillic_message)];
let preview = HistoryManager::extract_preview(&messages);
assert!(preview.chars().count() <= MAX_PREVIEW_LENGTH + 3); if preview.ends_with("...") {
assert!(preview.chars().count() <= MAX_PREVIEW_LENGTH + 3);
}
}
#[test]
fn all_token_counters_survive_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let session_id = HistoryManager::generate_session_id();
let system_prompt = SystemPrompt::new_cached_with_ttl("sys".to_string(), None);
let counters = SessionTokenCounters {
total_input_tokens: 123_456,
total_output_tokens: 7_890,
total_cache_read_tokens: 65_000,
total_cache_creation_tokens: 4_321,
peak_single_turn_input_tokens: 300_000,
};
manager
.save_session(
&session_id,
&[Message::user("crossed the cliff")],
&[],
std::slice::from_ref(&system_prompt),
counters,
"",
false,
)
.unwrap();
let loaded = manager.load_session(&session_id).unwrap();
assert_eq!(loaded.token_counters, counters);
}
#[test]
fn old_session_files_without_counter_fields_load_with_zero() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let session_id = "session_pre_persistence";
let session_path = manager.sessions_dir().join(format!("{}.json", session_id));
let legacy_json = serde_json::json!({
"id": session_id,
"api_messages": [],
"system_prompt": [],
"created_at": 0,
"updated_at": 0,
});
fs::write(&session_path, serde_json::to_string(&legacy_json).unwrap()).unwrap();
let loaded = manager.load_session(session_id).unwrap();
assert_eq!(loaded.token_counters, SessionTokenCounters::default());
}
#[test]
fn save_session_survives_corrupted_prior_file() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let session_id = HistoryManager::generate_session_id();
let session_path = manager.sessions_dir().join(format!("{}.json", session_id));
fs::write(&session_path, "{not valid json at all").unwrap();
let system_prompt = SystemPrompt::new_cached_with_ttl("System".to_string(), None);
let save_result = manager.save_session(
&session_id,
&[Message::user("After corruption")],
&[],
std::slice::from_ref(&system_prompt),
SessionTokenCounters::default(),
"",
false,
);
assert!(
save_result.is_ok(),
"save_session should recover: {save_result:?}"
);
let loaded = manager.load_session(&session_id).unwrap();
assert_eq!(loaded.api_messages.len(), 1);
}
#[test]
fn save_lock_serialises_concurrent_index_updates() {
use std::sync::{Arc, Barrier};
use std::thread;
let temp_dir = TempDir::new().unwrap();
HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let writer_count = 8;
let saves_per_writer = 5;
let barrier = Arc::new(Barrier::new(writer_count));
let workspace = temp_dir.path().to_path_buf();
let mut handles = Vec::new();
for w in 0..writer_count {
let barrier = Arc::clone(&barrier);
let workspace = workspace.clone();
handles.push(thread::spawn(move || {
let manager = HistoryManager::new(workspace).unwrap();
let system_prompt = SystemPrompt::new_cached_with_ttl("sys".to_string(), None);
let session_id = format!("session_writer_{}", w);
barrier.wait();
for n in 0..saves_per_writer {
manager
.save_session(
&session_id,
&[Message::user(format!("writer {} save {}", w, n))],
&[],
std::slice::from_ref(&system_prompt),
SessionTokenCounters::default(),
"",
false,
)
.unwrap();
}
}));
}
for h in handles {
h.join().unwrap();
}
let sessions = HistoryManager::new(workspace)
.unwrap()
.list_sessions()
.unwrap();
assert_eq!(
sessions.len(),
writer_count,
"all writers' ids should survive in the index: {sessions:?}"
);
}
#[test]
fn model_and_safe_mode_survive_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let session_id = HistoryManager::generate_session_id();
let system_prompt = SystemPrompt::new_cached_with_ttl("sys".to_string(), None);
manager
.save_session(
&session_id,
&[Message::user("hi")],
&[],
std::slice::from_ref(&system_prompt),
SessionTokenCounters::default(),
"claude-opus-4-7",
true,
)
.unwrap();
let loaded = manager.load_session(&session_id).unwrap();
assert_eq!(loaded.model.as_deref(), Some("claude-opus-4-7"));
assert_eq!(loaded.safe_mode, Some(true));
}
#[test]
fn legacy_session_without_model_or_safe_mode_loads_with_defaults() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let session_id = "session_pre_model";
let session_path = manager.sessions_dir().join(format!("{}.json", session_id));
let legacy_json = serde_json::json!({
"id": session_id,
"api_messages": [],
"system_prompt": [],
"created_at": 0,
"updated_at": 0,
});
fs::write(&session_path, serde_json::to_string(&legacy_json).unwrap()).unwrap();
let loaded = manager.load_session(session_id).unwrap();
assert!(loaded.model.is_none());
assert!(loaded.safe_mode.is_none());
}
#[test]
fn save_and_load_reject_traversing_session_ids() {
let temp_dir = TempDir::new().unwrap();
let manager = HistoryManager::new(temp_dir.path().to_path_buf()).unwrap();
let system_prompt = SystemPrompt::new_cached_with_ttl("sys".to_string(), None);
for bad in ["..", ".", "../escape", "a/b", "a\\b", ""] {
let save_err = manager
.save_session(
bad,
&[Message::user("x")],
&[],
std::slice::from_ref(&system_prompt),
SessionTokenCounters::default(),
"",
false,
)
.err();
assert!(save_err.is_some(), "save_session must reject '{}'", bad);
let load_err = manager.load_session(bad).err();
assert!(load_err.is_some(), "load_session must reject '{}'", bad);
}
}
}