Skip to main content

battlecommand_forge/
db.rs

1//! File-based mission database.
2//!
3//! Stores mission history as JSON files in .battlecommand/missions/.
4//! Can be upgraded to PostgreSQL later (Phase 12 evolution).
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11const MISSIONS_DIR: &str = ".battlecommand/missions";
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct MissionRecord {
15    pub id: String,
16    pub prompt: String,
17    pub preset: String,
18    pub tier: String,
19    pub subtasks: u32,
20    pub rounds: u32,
21    pub final_score: f32,
22    pub passed: bool,
23    pub model: String,
24    pub files_generated: Vec<String>,
25    pub duration_secs: f64,
26    pub timestamp: String,
27}
28
29/// Save a mission record.
30pub fn save_mission(record: &MissionRecord) -> Result<()> {
31    let dir = Path::new(MISSIONS_DIR);
32    fs::create_dir_all(dir)?;
33
34    let path = dir.join(format!("{}.json", record.id));
35    let json = serde_json::to_string_pretty(record)?;
36    fs::write(path, json)?;
37    Ok(())
38}
39
40/// Load a mission record by ID.
41pub fn load_mission(id: &str) -> Result<MissionRecord> {
42    let path = PathBuf::from(MISSIONS_DIR).join(format!("{}.json", id));
43    let json = fs::read_to_string(path)?;
44    let record: MissionRecord = serde_json::from_str(&json)?;
45    Ok(record)
46}
47
48/// List all mission records, sorted by timestamp (newest first).
49pub fn list_missions() -> Result<Vec<MissionRecord>> {
50    let dir = Path::new(MISSIONS_DIR);
51    if !dir.exists() {
52        return Ok(vec![]);
53    }
54
55    let mut missions = Vec::new();
56    for entry in fs::read_dir(dir)? {
57        let entry = entry?;
58        if entry
59            .path()
60            .extension()
61            .map(|e| e == "json")
62            .unwrap_or(false)
63        {
64            if let Ok(json) = fs::read_to_string(entry.path()) {
65                if let Ok(record) = serde_json::from_str::<MissionRecord>(&json) {
66                    missions.push(record);
67                }
68            }
69        }
70    }
71
72    missions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
73    Ok(missions)
74}
75
76/// Get mission statistics.
77pub fn get_stats() -> Result<MissionStats> {
78    let missions = list_missions()?;
79    let total = missions.len() as u32;
80    let passed = missions.iter().filter(|m| m.passed).count() as u32;
81    let avg_score = if missions.is_empty() {
82        0.0
83    } else {
84        missions.iter().map(|m| m.final_score).sum::<f32>() / missions.len() as f32
85    };
86    let total_duration: f64 = missions.iter().map(|m| m.duration_secs).sum();
87
88    Ok(MissionStats {
89        total_missions: total,
90        passed,
91        failed: total - passed,
92        avg_score,
93        total_duration_secs: total_duration,
94    })
95}
96
97#[derive(Debug)]
98pub struct MissionStats {
99    pub total_missions: u32,
100    pub passed: u32,
101    pub failed: u32,
102    pub avg_score: f32,
103    pub total_duration_secs: f64,
104}
105
106impl std::fmt::Display for MissionStats {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(
109            f,
110            "Missions: {} total ({} passed, {} failed) | Avg score: {:.1} | Total time: {:.0}s",
111            self.total_missions,
112            self.passed,
113            self.failed,
114            self.avg_score,
115            self.total_duration_secs.abs()
116        )
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_list_missions_empty() {
126        let missions = list_missions().unwrap();
127        // May or may not have missions depending on prior runs
128        let _ = missions; // just verify no panic
129    }
130
131    #[test]
132    fn test_get_stats() {
133        let stats = get_stats().unwrap();
134        assert!(stats.avg_score >= 0.0);
135    }
136}