use crate::config::Config;
use crate::error::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommandProvenance {
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub docs_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skill_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub id: String,
pub tool: String,
pub task: String,
pub command: String,
pub exit_code: i32,
pub executed_at: DateTime<Utc>,
pub dry_run: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<CommandProvenance>,
}
pub struct HistoryStore;
impl HistoryStore {
fn history_path() -> Result<PathBuf> {
Ok(Config::data_dir()?.join("history.jsonl"))
}
pub fn append(entry: HistoryEntry) -> Result<()> {
let path = Self::history_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(&entry)?;
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
writeln!(file, "{line}")?;
Ok(())
}
pub fn load_all() -> Result<Vec<HistoryEntry>> {
let path = Self::history_path()?;
if !path.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&path)?;
let mut entries = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<HistoryEntry>(line) {
entries.push(entry);
}
}
Ok(entries)
}
pub fn clear() -> Result<()> {
let path = Self::history_path()?;
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use crate::ENV_LOCK;
fn make_entry(id: &str, tool: &str, dry_run: bool) -> HistoryEntry {
HistoryEntry {
id: id.to_string(),
tool: tool.to_string(),
task: format!("do something with {tool}"),
command: format!("{tool} --help"),
exit_code: 0,
executed_at: Utc::now(),
dry_run,
server: None,
provenance: None,
}
}
fn make_entry_with_provenance(id: &str) -> HistoryEntry {
HistoryEntry {
id: id.to_string(),
tool: "samtools".to_string(),
task: "sort bam".to_string(),
command: "samtools sort -o out.bam in.bam".to_string(),
exit_code: 0,
executed_at: Utc::now(),
dry_run: false,
server: None,
provenance: Some(CommandProvenance {
tool_version: Some("1.17".to_string()),
docs_hash: Some("abc123".to_string()),
skill_name: Some("samtools".to_string()),
model: Some("gpt-4o".to_string()),
}),
}
}
#[test]
fn test_append_and_load_all() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OXO_CALL_DATA_DIR", tmp.path());
}
HistoryStore::clear().unwrap();
assert!(HistoryStore::load_all().unwrap().is_empty());
let e1 = make_entry("id-1", "samtools", false);
let e2 = make_entry("id-2", "bwa", true);
HistoryStore::append(e1).unwrap();
HistoryStore::append(e2).unwrap();
let entries = HistoryStore::load_all().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].id, "id-1");
assert_eq!(entries[0].tool, "samtools");
assert!(!entries[0].dry_run);
assert_eq!(entries[1].id, "id-2");
assert!(entries[1].dry_run);
}
#[test]
fn test_clear_removes_entries() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OXO_CALL_DATA_DIR", tmp.path());
}
HistoryStore::append(make_entry("id-x", "gatk", false)).unwrap();
assert!(!HistoryStore::load_all().unwrap().is_empty());
HistoryStore::clear().unwrap();
assert!(HistoryStore::load_all().unwrap().is_empty());
}
#[test]
fn test_clear_idempotent_on_empty() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OXO_CALL_DATA_DIR", tmp.path());
}
HistoryStore::clear().unwrap();
HistoryStore::clear().unwrap();
}
#[test]
fn test_provenance_round_trip() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OXO_CALL_DATA_DIR", tmp.path());
}
HistoryStore::clear().unwrap();
HistoryStore::append(make_entry_with_provenance("prov-1")).unwrap();
let entries = HistoryStore::load_all().unwrap();
assert_eq!(entries.len(), 1);
let prov = entries[0].provenance.as_ref().unwrap();
assert_eq!(prov.tool_version.as_deref(), Some("1.17"));
assert_eq!(prov.skill_name.as_deref(), Some("samtools"));
assert_eq!(prov.model.as_deref(), Some("gpt-4o"));
}
#[test]
fn test_command_provenance_default() {
let p = CommandProvenance::default();
assert!(p.tool_version.is_none());
assert!(p.docs_hash.is_none());
assert!(p.skill_name.is_none());
assert!(p.model.is_none());
}
#[test]
fn test_history_entry_serializes_without_null_provenance() {
let entry = make_entry("no-prov", "bwa", false);
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("provenance"));
}
#[test]
fn test_load_all_empty_when_no_file() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OXO_CALL_DATA_DIR", tmp.path());
}
let entries = HistoryStore::load_all().unwrap();
assert!(entries.is_empty());
}
fn make_server_entry(id: &str, server: &str) -> HistoryEntry {
HistoryEntry {
id: id.to_string(),
tool: "samtools".to_string(),
task: "sort bam".to_string(),
command: "samtools sort -o out.bam in.bam".to_string(),
exit_code: 0,
executed_at: Utc::now(),
dry_run: false,
server: Some(server.to_string()),
provenance: None,
}
}
#[test]
fn test_server_field_round_trip_via_store() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OXO_CALL_DATA_DIR", tmp.path());
}
HistoryStore::clear().unwrap();
HistoryStore::append(make_server_entry("srv-1", "hpc-cluster")).unwrap();
let entries = HistoryStore::load_all().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].server.as_deref(), Some("hpc-cluster"));
}
#[test]
fn test_server_field_is_none_for_local_runs() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OXO_CALL_DATA_DIR", tmp.path());
}
HistoryStore::clear().unwrap();
HistoryStore::append(make_entry("local-1", "bwa", false)).unwrap();
let entries = HistoryStore::load_all().unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0].server.is_none(),
"local entry should have server = None"
);
}
#[test]
fn test_server_field_omitted_in_json_when_none() {
let entry = make_entry("no-server", "gatk", false);
let json = serde_json::to_string(&entry).unwrap();
assert!(
!json.contains("\"server\""),
"server field should be omitted when None, got: {json}"
);
}
#[test]
fn test_server_field_present_in_json_when_set() {
let entry = make_server_entry("srv-json", "my-server");
let json = serde_json::to_string(&entry).unwrap();
assert!(
json.contains("\"server\":\"my-server\""),
"server field should be present when set, got: {json}"
);
}
#[test]
fn test_old_history_entry_without_server_still_deserializes() {
let old_json = r#"{"id":"old-1","tool":"samtools","task":"sort","command":"samtools sort -o out.bam in.bam","exit_code":0,"executed_at":"2024-01-01T00:00:00Z","dry_run":false}"#;
let entry: HistoryEntry = serde_json::from_str(old_json).unwrap();
assert_eq!(entry.id, "old-1");
assert!(
entry.server.is_none(),
"old entries without server should deserialize with server = None"
);
}
#[test]
fn test_dry_run_entry_serialization() {
let entry = make_entry("dry-1", "samtools", true);
let json = serde_json::to_string(&entry).unwrap();
let back: HistoryEntry = serde_json::from_str(&json).unwrap();
assert!(back.dry_run, "dry_run should survive round-trip");
assert!(
back.server.is_none(),
"server should be None for local dry-run"
);
}
#[test]
fn test_server_dry_run_entry() {
let entry = HistoryEntry {
id: "sdr-1".to_string(),
tool: "ls".to_string(),
task: "list files".to_string(),
command: "ls -la".to_string(),
exit_code: 0,
executed_at: Utc::now(),
dry_run: true,
server: Some("remote-box".to_string()),
provenance: None,
};
let json = serde_json::to_string(&entry).unwrap();
let back: HistoryEntry = serde_json::from_str(&json).unwrap();
assert!(back.dry_run);
assert_eq!(back.server.as_deref(), Some("remote-box"));
}
#[test]
fn test_mixed_local_and_server_entries() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var("OXO_CALL_DATA_DIR", tmp.path());
}
HistoryStore::clear().unwrap();
HistoryStore::append(make_entry("local-mix", "bwa", false)).unwrap();
HistoryStore::append(make_server_entry("srv-mix", "hpc1")).unwrap();
HistoryStore::append(make_entry("dry-mix", "samtools", true)).unwrap();
let entries = HistoryStore::load_all().unwrap();
assert_eq!(entries.len(), 3);
assert!(entries[0].server.is_none());
assert_eq!(entries[1].server.as_deref(), Some("hpc1"));
assert!(entries[2].dry_run);
}
}