use async_trait::async_trait;
use log::{info, warn};
use std::path::PathBuf;
use tokio::fs;
use uuid::Uuid;
use crate::application::errors::citadel_error::CitadelError;
use crate::core::platform::container::citadel::{
BattalionState, PaladinState, StateSummary, StateType,
};
use paladin_ports::output::citadel_port::CitadelPort;
#[doc(hidden)]
#[derive(Debug, Clone)]
pub struct FileCitadel {
state_dir: PathBuf,
}
impl FileCitadel {
pub fn new(state_dir: impl Into<PathBuf>) -> Result<Self, CitadelError> {
let state_dir = state_dir.into();
if state_dir.exists() {
if !state_dir.is_dir() {
return Err(CitadelError::invalid_directory(format!(
"Path exists but is not a directory: {}",
state_dir.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = std::fs::metadata(&state_dir) {
let permissions = metadata.permissions();
if permissions.mode() & 0o200 == 0 {
return Err(CitadelError::permission_denied(format!(
"Directory is not writable: {}",
state_dir.display()
)));
}
}
}
} else {
std::fs::create_dir_all(&state_dir).map_err(|e| {
CitadelError::directory_creation_failed(format!(
"Failed to create state directory {}: {}",
state_dir.display(),
e
))
})?;
info!("Created state directory: {}", state_dir.display());
}
Ok(Self { state_dir })
}
fn paladin_path(&self, id: Uuid) -> PathBuf {
self.state_dir.join(format!("paladin-{}.json", id))
}
fn battalion_path(&self, id: Uuid) -> PathBuf {
self.state_dir.join(format!("battalion-{}.json", id))
}
fn parse_filename(filename: &str) -> Option<(Uuid, StateType)> {
if let Some(stripped) = filename.strip_prefix("paladin-") {
if let Some(uuid_str) = stripped.strip_suffix(".json")
&& let Ok(uuid) = Uuid::parse_str(uuid_str)
{
return Some((uuid, StateType::Paladin));
}
} else if let Some(stripped) = filename.strip_prefix("battalion-")
&& let Some(uuid_str) = stripped.strip_suffix(".json")
&& let Ok(uuid) = Uuid::parse_str(uuid_str)
{
return Some((uuid, StateType::Battalion));
}
None
}
}
#[async_trait]
impl CitadelPort for FileCitadel {
async fn save_paladin(&self, state: &PaladinState) -> Result<(), CitadelError> {
let path = self.paladin_path(state.paladin.uuid);
let json = serde_json::to_string_pretty(state)?;
fs::write(&path, json).await.map_err(|e| {
CitadelError::IoError(std::io::Error::new(
e.kind(),
format!("Failed to write Paladin state to {}: {}", path.display(), e),
))
})?;
info!(
"Saved Paladin state: {} to {}",
state.paladin.uuid,
path.display()
);
Ok(())
}
async fn load_paladin(&self, id: Uuid) -> Result<Option<PaladinState>, CitadelError> {
let path = self.paladin_path(id);
if !path.exists() {
return Ok(None);
}
let json = fs::read_to_string(&path).await.map_err(|e| {
CitadelError::IoError(std::io::Error::new(
e.kind(),
format!(
"Failed to read Paladin state from {}: {}",
path.display(),
e
),
))
})?;
let state: PaladinState = serde_json::from_str(&json).map_err(|e| {
CitadelError::corrupted(format!(
"Failed to parse Paladin state from {}: {}",
path.display(),
e
))
})?;
if state.schema_version != "1.0.0" {
warn!(
"Loading Paladin state with schema version {} (expected 1.0.0)",
state.schema_version
);
}
info!("Loaded Paladin state: {} from {}", id, path.display());
Ok(Some(state))
}
async fn save_battalion(&self, state: &BattalionState) -> Result<(), CitadelError> {
let path = self.battalion_path(state.id);
let json = serde_json::to_string_pretty(state)?;
fs::write(&path, json).await.map_err(|e| {
CitadelError::IoError(std::io::Error::new(
e.kind(),
format!(
"Failed to write Battalion state to {}: {}",
path.display(),
e
),
))
})?;
info!("Saved Battalion state: {} to {}", state.id, path.display());
Ok(())
}
async fn load_battalion(&self, id: Uuid) -> Result<Option<BattalionState>, CitadelError> {
let path = self.battalion_path(id);
if !path.exists() {
return Ok(None);
}
let json = fs::read_to_string(&path).await.map_err(|e| {
CitadelError::IoError(std::io::Error::new(
e.kind(),
format!(
"Failed to read Battalion state from {}: {}",
path.display(),
e
),
))
})?;
let state: BattalionState = serde_json::from_str(&json).map_err(|e| {
CitadelError::corrupted(format!(
"Failed to parse Battalion state from {}: {}",
path.display(),
e
))
})?;
if state.schema_version != "1.0.0" {
warn!(
"Loading Battalion state with schema version {} (expected 1.0.0)",
state.schema_version
);
}
info!("Loaded Battalion state: {} from {}", id, path.display());
Ok(Some(state))
}
async fn list_saved(&self) -> Result<Vec<StateSummary>, CitadelError> {
let mut summaries = Vec::new();
let mut entries = fs::read_dir(&self.state_dir).await.map_err(|e| {
CitadelError::IoError(std::io::Error::new(
e.kind(),
format!(
"Failed to read state directory {}: {}",
self.state_dir.display(),
e
),
))
})?;
while let Some(entry) = entries.next_entry().await.map_err(|e| {
CitadelError::IoError(std::io::Error::new(
e.kind(),
format!("Failed to read directory entry: {}", e),
))
})? {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(filename) = path.file_name().and_then(|n| n.to_str())
&& let Some((id, state_type)) = Self::parse_filename(filename)
{
if let Ok(metadata) = fs::metadata(&path).await
&& let Ok(created) = metadata.created()
&& let Ok(modified) = metadata.modified()
{
summaries.push(StateSummary {
id,
state_type,
created_at: created.into(),
updated_at: modified.into(),
file_path: path.clone(),
});
}
}
}
info!("Listed {} saved states", summaries.len());
Ok(summaries)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::base::entity::node::Node;
use crate::core::platform::container::citadel::{
BattalionConfig, CheckpointData, PaladinData, PaladinStatus,
};
use crate::core::platform::container::paladin::MaxLoops;
use tempfile::TempDir;
fn create_test_paladin_state() -> PaladinState {
let paladin_data = PaladinData {
system_prompt: "test".to_string(),
name: "test".to_string(),
user_name: "test".to_string(),
model: "gpt-4".to_string(),
temperature: 0.7,
max_loops: MaxLoops::Fixed(3),
stop_words: vec![],
status: PaladinStatus::Idle,
vision_enabled: false,
..Default::default()
};
let paladin = Node::new(paladin_data, Some("test".to_string()));
PaladinState::new(paladin, vec![], vec![])
}
#[test]
fn test_file_citadel_creation() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path());
assert!(citadel.is_ok());
}
#[test]
fn test_file_citadel_creates_directory() {
let temp_dir = TempDir::new().unwrap();
let state_dir = temp_dir.path().join("new_dir");
assert!(!state_dir.exists());
let citadel = FileCitadel::new(&state_dir);
assert!(citadel.is_ok());
assert!(state_dir.exists());
assert!(state_dir.is_dir());
}
#[test]
fn test_file_citadel_rejects_file_as_directory() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("notadir");
std::fs::write(&file_path, "test").unwrap();
let citadel = FileCitadel::new(&file_path);
assert!(citadel.is_err());
assert!(matches!(
citadel.unwrap_err(),
CitadelError::InvalidDirectory(_)
));
}
#[test]
fn test_paladin_path_generation() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let id = Uuid::new_v4();
let path = citadel.paladin_path(id);
assert_eq!(path, temp_dir.path().join(format!("paladin-{}.json", id)));
}
#[test]
fn test_battalion_path_generation() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let id = Uuid::new_v4();
let path = citadel.battalion_path(id);
assert_eq!(path, temp_dir.path().join(format!("battalion-{}.json", id)));
}
#[test]
fn test_parse_filename_paladin() {
let id = Uuid::new_v4();
let filename = format!("paladin-{}.json", id);
let result = FileCitadel::parse_filename(&filename);
assert!(result.is_some());
let (parsed_id, state_type) = result.unwrap();
assert_eq!(parsed_id, id);
assert_eq!(state_type, StateType::Paladin);
}
#[test]
fn test_parse_filename_battalion() {
let id = Uuid::new_v4();
let filename = format!("battalion-{}.json", id);
let result = FileCitadel::parse_filename(&filename);
assert!(result.is_some());
let (parsed_id, state_type) = result.unwrap();
assert_eq!(parsed_id, id);
assert_eq!(state_type, StateType::Battalion);
}
#[test]
fn test_parse_filename_invalid() {
assert!(FileCitadel::parse_filename("invalid.json").is_none());
assert!(FileCitadel::parse_filename("paladin-notauuid.json").is_none());
assert!(FileCitadel::parse_filename("paladin-123.txt").is_none());
}
#[tokio::test]
async fn test_save_and_load_paladin() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let state = create_test_paladin_state();
let id = state.paladin.uuid;
citadel.save_paladin(&state).await.unwrap();
let path = citadel.paladin_path(id);
assert!(path.exists());
let loaded = citadel.load_paladin(id).await.unwrap();
assert!(loaded.is_some());
let loaded_state = loaded.unwrap();
assert_eq!(loaded_state.paladin.uuid, id);
assert_eq!(loaded_state.schema_version, "1.0.0");
}
#[tokio::test]
async fn test_load_nonexistent_paladin() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let result = citadel.load_paladin(Uuid::new_v4()).await;
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[tokio::test]
async fn test_save_overwrites_existing() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let state = create_test_paladin_state();
citadel.save_paladin(&state).await.unwrap();
citadel.save_paladin(&state).await.unwrap();
let entries: Vec<_> = std::fs::read_dir(temp_dir.path()).unwrap().collect();
assert_eq!(entries.len(), 1);
}
#[tokio::test]
async fn test_save_and_load_battalion() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let state = BattalionState::new(
"Formation",
BattalionConfig::default(),
vec![],
Some(CheckpointData::new()),
);
let id = state.id;
citadel.save_battalion(&state).await.unwrap();
let loaded = citadel.load_battalion(id).await.unwrap();
assert!(loaded.is_some());
let loaded_state = loaded.unwrap();
assert_eq!(loaded_state.id, id);
assert_eq!(loaded_state.battalion_type, "Formation");
}
#[tokio::test]
async fn test_list_saved_empty() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let summaries = citadel.list_saved().await.unwrap();
assert_eq!(summaries.len(), 0);
}
#[tokio::test]
async fn test_list_saved_multiple() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let state1 = create_test_paladin_state();
let state2 = create_test_paladin_state();
let battalion = BattalionState::new("Phalanx", BattalionConfig::default(), vec![], None);
citadel.save_paladin(&state1).await.unwrap();
citadel.save_paladin(&state2).await.unwrap();
citadel.save_battalion(&battalion).await.unwrap();
let summaries = citadel.list_saved().await.unwrap();
assert_eq!(summaries.len(), 3);
let paladin_count = summaries
.iter()
.filter(|s| s.state_type == StateType::Paladin)
.count();
let battalion_count = summaries
.iter()
.filter(|s| s.state_type == StateType::Battalion)
.count();
assert_eq!(paladin_count, 2);
assert_eq!(battalion_count, 1);
}
#[tokio::test]
async fn test_json_is_human_readable() {
let temp_dir = TempDir::new().unwrap();
let citadel = FileCitadel::new(temp_dir.path()).unwrap();
let state = create_test_paladin_state();
let id = state.paladin.uuid;
citadel.save_paladin(&state).await.unwrap();
let path = citadel.paladin_path(id);
let contents = std::fs::read_to_string(path).unwrap();
assert!(contents.contains('\n')); assert!(contents.contains(" ")); assert!(contents.contains("\"schema_version\""));
}
}