use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::CliError;
pub const STATUS_FILE: &str = "host-context-status.json";
pub const SCHEMA_VERSION: &str = "1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HostContextStatus {
pub schema_version: String,
pub host: String,
pub session_id: String,
pub compacted: bool,
pub observed_at_epoch_s: u64,
}
pub fn status_path(state_dir: &Path) -> PathBuf {
state_dir.join(STATUS_FILE)
}
pub fn write_status(state_dir: &Path, status: &HostContextStatus) -> Result<(), CliError> {
fs::create_dir_all(state_dir).map_err(|err| {
CliError::Input(format!(
"host-context status: failed to create state dir {}: {err}",
state_dir.display()
))
})?;
let json = serde_json::to_string_pretty(status).map_err(|err| {
CliError::Input(format!(
"host-context status: failed to serialize status: {err}"
))
})?;
let path = status_path(state_dir);
fs::write(&path, json).map_err(|err| {
CliError::Input(format!(
"host-context status: failed to write status {}: {err}",
path.display()
))
})
}
pub fn read_status(state_dir: &Path) -> Result<Option<HostContextStatus>, CliError> {
let path = status_path(state_dir);
if !path.is_file() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|err| {
CliError::Input(format!(
"host-context status: failed to read status {}: {err}",
path.display()
))
})?;
let status = serde_json::from_str::<HostContextStatus>(&raw).map_err(|err| {
CliError::Input(format!(
"host-context status: invalid status {}: {err}",
path.display()
))
})?;
Ok(Some(status))
}
pub fn clear_for_session(state_dir: &Path, session_id: &str) -> Result<bool, CliError> {
let Some(status) = read_status(state_dir)? else {
return Ok(false);
};
if status.session_id != session_id {
return Ok(false);
}
let path = status_path(state_dir);
fs::remove_file(&path).map_err(|err| {
CliError::Input(format!(
"host-context status: failed to clear status {}: {err}",
path.display()
))
})?;
Ok(true)
}
pub fn clear_unconditional(state_dir: &Path) -> Result<bool, CliError> {
let path = status_path(state_dir);
if !path.is_file() {
return Ok(false);
}
fs::remove_file(&path).map_err(|err| {
CliError::Input(format!(
"host-context status: failed to clear status {}: {err}",
path.display()
))
})?;
Ok(true)
}
pub fn clear_if_session_changed(
state_dir: &Path,
current_session_id: &str,
) -> Result<bool, CliError> {
let Some(status) = read_status(state_dir)? else {
return Ok(false);
};
if status.session_id == current_session_id {
return Ok(false);
}
let path = status_path(state_dir);
fs::remove_file(&path).map_err(|err| {
CliError::Input(format!(
"host-context status: failed to clear status {}: {err}",
path.display()
))
})?;
Ok(true)
}
pub fn record_compaction(
state_dir: &Path,
host: &str,
session_id: &str,
observed_at_epoch_s: u64,
) -> Result<(), CliError> {
let status = HostContextStatus {
schema_version: SCHEMA_VERSION.to_string(),
host: host.to_string(),
session_id: session_id.to_string(),
compacted: true,
observed_at_epoch_s,
};
write_status(state_dir, &status)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample(session_id: &str, ts: u64) -> HostContextStatus {
HostContextStatus {
schema_version: SCHEMA_VERSION.to_string(),
host: "claude".into(),
session_id: session_id.into(),
compacted: true,
observed_at_epoch_s: ts,
}
}
#[test]
fn read_returns_none_when_file_absent() {
let dir = TempDir::new().unwrap();
assert!(read_status(dir.path()).unwrap().is_none());
}
#[test]
fn write_then_read_round_trips_status() {
let dir = TempDir::new().unwrap();
let status = sample("ses_abc", 100);
write_status(dir.path(), &status).unwrap();
let loaded = read_status(dir.path()).unwrap().unwrap();
assert_eq!(loaded, status);
}
#[test]
fn record_compaction_writes_expected_fields() {
let dir = TempDir::new().unwrap();
record_compaction(dir.path(), "claude", "ses_xyz", 200).unwrap();
let loaded = read_status(dir.path()).unwrap().unwrap();
assert_eq!(loaded.host, "claude");
assert_eq!(loaded.session_id, "ses_xyz");
assert!(loaded.compacted);
assert_eq!(loaded.observed_at_epoch_s, 200);
assert_eq!(loaded.schema_version, SCHEMA_VERSION);
}
#[test]
fn record_compaction_creates_state_dir_when_absent() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("nested").join("state");
record_compaction(&nested, "claude", "ses_z", 1).unwrap();
assert!(nested.is_dir());
assert!(status_path(&nested).is_file());
}
#[test]
fn clear_for_matching_session_removes_file() {
let dir = TempDir::new().unwrap();
write_status(dir.path(), &sample("ses_abc", 100)).unwrap();
assert!(clear_for_session(dir.path(), "ses_abc").unwrap());
assert!(read_status(dir.path()).unwrap().is_none());
}
#[test]
fn clear_for_other_session_preserves_file() {
let dir = TempDir::new().unwrap();
write_status(dir.path(), &sample("ses_abc", 100)).unwrap();
assert!(!clear_for_session(dir.path(), "ses_other").unwrap());
let loaded = read_status(dir.path()).unwrap().unwrap();
assert_eq!(loaded.session_id, "ses_abc");
}
#[test]
fn clear_when_status_absent_is_noop() {
let dir = TempDir::new().unwrap();
assert!(!clear_for_session(dir.path(), "ses_anything").unwrap());
}
#[test]
fn read_rejects_malformed_json() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path()).unwrap();
fs::write(status_path(dir.path()), "{not json").unwrap();
let err = read_status(dir.path()).unwrap_err();
assert!(err.message().contains("invalid status"));
}
#[test]
fn clear_unconditional_removes_file_when_present() {
let dir = TempDir::new().unwrap();
write_status(dir.path(), &sample("ses_any", 1)).unwrap();
assert!(clear_unconditional(dir.path()).unwrap());
assert!(read_status(dir.path()).unwrap().is_none());
}
#[test]
fn clear_unconditional_returns_false_when_absent() {
let dir = TempDir::new().unwrap();
assert!(!clear_unconditional(dir.path()).unwrap());
}
#[test]
fn clear_if_session_changed_removes_when_id_differs() {
let dir = TempDir::new().unwrap();
write_status(dir.path(), &sample("ses_old", 1)).unwrap();
assert!(clear_if_session_changed(dir.path(), "ses_new").unwrap());
assert!(read_status(dir.path()).unwrap().is_none());
}
#[test]
fn clear_if_session_changed_preserves_when_id_matches() {
let dir = TempDir::new().unwrap();
write_status(dir.path(), &sample("ses_same", 1)).unwrap();
assert!(!clear_if_session_changed(dir.path(), "ses_same").unwrap());
let loaded = read_status(dir.path()).unwrap().unwrap();
assert_eq!(loaded.session_id, "ses_same");
}
#[test]
fn clear_if_session_changed_returns_false_when_absent() {
let dir = TempDir::new().unwrap();
assert!(!clear_if_session_changed(dir.path(), "anything").unwrap());
}
#[test]
fn read_rejects_unknown_fields() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path()).unwrap();
fs::write(
status_path(dir.path()),
r#"{"schema_version":"1","host":"claude","session_id":"s","compacted":true,"observed_at_epoch_s":1,"extra":"nope"}"#,
)
.unwrap();
assert!(read_status(dir.path()).is_err());
}
#[test]
fn record_compaction_overwrites_earlier_status_same_session() {
let dir = TempDir::new().unwrap();
record_compaction(dir.path(), "claude", "ses_abc", 100).unwrap();
record_compaction(dir.path(), "claude", "ses_abc", 200).unwrap();
let loaded = read_status(dir.path()).unwrap().unwrap();
assert_eq!(loaded.observed_at_epoch_s, 200);
}
}