1use crate::{error::Result, Error, PlanningEngine};
2use serde::{Deserialize, Serialize};
3use serde_json::{Map, Value};
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7pub const APLAN_MAGIC: [u8; 4] = *b"PLAN";
8pub const APLAN_VERSION: u16 = 1;
9#[allow(dead_code)]
10pub const APLAN_HEADER_SIZE: usize = 128;
11#[allow(dead_code)]
12pub const APLAN_FOOTER_SIZE: usize = 64;
13pub const APLAN_INTEGRITY_MARKER: [u8; 8] = *b"PLANEND\0";
14
15#[repr(C, packed)]
16#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
17pub struct AplanHeader {
18 pub magic: [u8; 4],
19 pub version: u16,
20 pub flags: u32,
21 pub created_at: i64,
22 pub modified_at: i64,
23 pub goal_count: u32,
24 pub decision_count: u32,
25 pub commitment_count: u32,
26 pub dream_count: u32,
27 pub federation_count: u32,
28 pub goal_section_offset: u64,
29 pub decision_section_offset: u64,
30 pub commitment_section_offset: u64,
31 pub dream_section_offset: u64,
32 pub federation_section_offset: u64,
33 pub index_section_offset: u64,
34 pub checksum: [u8; 32],
35 pub reserved: [u8; 14],
36}
37
38#[repr(C, packed)]
39#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
40pub struct AplanFooter {
41 pub file_size: u64,
42 pub write_count: u64,
43 pub last_session: [u8; 16],
44 pub integrity: [u8; 8],
45 pub footer_checksum: [u8; 16],
46 pub reserved: [u8; 8],
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50struct PersistedPlan {
51 header: AplanHeader,
52 goals: std::collections::HashMap<crate::GoalId, crate::Goal>,
53 decisions: std::collections::HashMap<crate::DecisionId, crate::Decision>,
54 commitments: std::collections::HashMap<crate::CommitmentId, crate::Commitment>,
55 dreams: std::collections::HashMap<crate::DreamId, crate::Dream>,
56 federations: std::collections::HashMap<crate::FederationId, crate::Federation>,
57 soul_archive: std::collections::HashMap<crate::GoalId, crate::GoalSoulArchive>,
58 indexes: crate::PlanIndexes,
59 footer: AplanFooter,
60}
61
62fn canonicalize_json(value: Value) -> Value {
63 match value {
64 Value::Object(obj) => {
65 let mut sorted_keys: Vec<_> = obj.keys().cloned().collect();
66 sorted_keys.sort();
67 let mut canonical = Map::new();
68 for key in sorted_keys {
69 if let Some(v) = obj.get(&key) {
70 canonical.insert(key, canonicalize_json(v.clone()));
71 }
72 }
73 Value::Object(canonical)
74 }
75 Value::Array(arr) => Value::Array(arr.into_iter().map(canonicalize_json).collect()),
76 other => other,
77 }
78}
79
80impl PlanningEngine {
81 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
82 let path = path.as_ref();
83 let tmp_path = temp_path_for(path);
84
85 if !path.exists() && tmp_path.exists() {
87 std::fs::rename(&tmp_path, path)?;
88 }
89 if path.exists() && tmp_path.exists() {
91 let _ = std::fs::remove_file(&tmp_path);
92 }
93
94 if path.exists() {
95 Self::load(path)
96 } else {
97 Self::create(path)
98 }
99 }
100
101 pub fn create(path: impl AsRef<Path>) -> Result<Self> {
102 let mut engine = Self::in_memory();
103 engine.path = Some(path.as_ref().to_path_buf());
104 engine.save()?;
105 Ok(engine)
106 }
107
108 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
109 let path_ref = path.as_ref();
110 let bytes = std::fs::read(path_ref)?;
111 let persisted: PersistedPlan = serde_json::from_slice(&bytes)?;
112
113 if persisted.header.magic != APLAN_MAGIC || persisted.header.version != APLAN_VERSION {
114 return Err(Error::InvalidFile);
115 }
116 if persisted.footer.integrity != APLAN_INTEGRITY_MARKER {
117 return Err(Error::InvalidFile);
118 }
119
120 let is_legacy = persisted.header.checksum == [0u8; 32];
122 if !is_legacy {
123 let sorted_goals: BTreeMap<_, _> = persisted.goals.iter().collect();
124 let sorted_decisions: BTreeMap<_, _> = persisted.decisions.iter().collect();
125 let sorted_commitments: BTreeMap<_, _> = persisted.commitments.iter().collect();
126 let sorted_dreams: BTreeMap<_, _> = persisted.dreams.iter().collect();
127 let sorted_federations: BTreeMap<_, _> = persisted.federations.iter().collect();
128 let sorted_souls: BTreeMap<_, _> = persisted.soul_archive.iter().collect();
129 let canonical_indexes = canonicalize_json(serde_json::to_value(&persisted.indexes)?);
130
131 let encoded_state = serde_json::to_vec(&(
132 &sorted_goals,
133 &sorted_decisions,
134 &sorted_commitments,
135 &sorted_dreams,
136 &sorted_federations,
137 &sorted_souls,
138 canonical_indexes,
139 ))?;
140 let computed = blake3::hash(&encoded_state);
141 if *computed.as_bytes() != persisted.header.checksum {
142 return Err(Error::CorruptedFile(format!(
143 "checksum mismatch: expected {:?}, computed {:?}",
144 &persisted.header.checksum[..4],
145 &computed.as_bytes()[..4]
146 )));
147 }
148 }
149
150 let mut engine = Self::in_memory();
151 engine.path = Some(path_ref.to_path_buf());
152 engine.goal_store = persisted.goals;
153 engine.decision_store = persisted.decisions;
154 engine.commitment_store = persisted.commitments;
155 engine.dream_store = persisted.dreams;
156 engine.federation_store = persisted.federations;
157 engine.soul_archive = persisted.soul_archive;
158 engine.indexes = persisted.indexes;
159 engine.write_count = persisted.footer.write_count;
160 engine.dirty = false;
161 Ok(engine)
162 }
163
164 pub fn save(&mut self) -> Result<()> {
165 let Some(path) = self.path.clone() else {
166 return Ok(());
167 };
168
169 let _file_lock = crate::locking::FileLock::acquire(&path).map_err(|e| {
171 Error::Io(std::io::Error::new(
172 std::io::ErrorKind::WouldBlock,
173 format!("failed to acquire file lock: {e}"),
174 ))
175 })?;
176
177 let now = crate::Timestamp::now().0;
178
179 self.write_count += 1;
181
182 let sorted_goals: BTreeMap<_, _> = self.goal_store.iter().collect();
184 let sorted_decisions: BTreeMap<_, _> = self.decision_store.iter().collect();
185 let sorted_commitments: BTreeMap<_, _> = self.commitment_store.iter().collect();
186 let sorted_dreams: BTreeMap<_, _> = self.dream_store.iter().collect();
187 let sorted_federations: BTreeMap<_, _> = self.federation_store.iter().collect();
188 let sorted_souls: BTreeMap<_, _> = self.soul_archive.iter().collect();
189 let canonical_indexes = canonicalize_json(serde_json::to_value(&self.indexes)?);
190
191 let encoded_state = serde_json::to_vec(&(
192 &sorted_goals,
193 &sorted_decisions,
194 &sorted_commitments,
195 &sorted_dreams,
196 &sorted_federations,
197 &sorted_souls,
198 canonical_indexes,
199 ))?;
200
201 let digest = blake3::hash(&encoded_state);
203
204 let goal_bytes = serde_json::to_vec(&sorted_goals)?;
207 let decision_bytes = serde_json::to_vec(&sorted_decisions)?;
208 let commitment_bytes = serde_json::to_vec(&sorted_commitments)?;
209 let dream_bytes = serde_json::to_vec(&sorted_dreams)?;
210 let federation_bytes = serde_json::to_vec(&sorted_federations)?;
211
212 let mut offset: u64 = 0;
213 let goal_offset = offset;
214 offset += goal_bytes.len() as u64;
215 let decision_offset = offset;
216 offset += decision_bytes.len() as u64;
217 let commitment_offset = offset;
218 offset += commitment_bytes.len() as u64;
219 let dream_offset = offset;
220 offset += dream_bytes.len() as u64;
221 let federation_offset = offset;
222 offset += federation_bytes.len() as u64;
223 let index_offset = offset;
224
225 let header = AplanHeader {
226 magic: APLAN_MAGIC,
227 version: APLAN_VERSION,
228 flags: 0,
229 created_at: now,
230 modified_at: now,
231 goal_count: self.goal_store.len() as u32,
232 decision_count: self.decision_store.len() as u32,
233 commitment_count: self.commitment_store.len() as u32,
234 dream_count: self.dream_store.len() as u32,
235 federation_count: self.federation_store.len() as u32,
236 goal_section_offset: goal_offset,
237 decision_section_offset: decision_offset,
238 commitment_section_offset: commitment_offset,
239 dream_section_offset: dream_offset,
240 federation_section_offset: federation_offset,
241 index_section_offset: index_offset,
242 checksum: *digest.as_bytes(),
243 reserved: [0; 14],
244 };
245
246 let session_bytes = *self.session_id.as_bytes();
248 let footer_payload = [
249 &(encoded_state.len() as u64).to_le_bytes()[..],
250 &self.write_count.to_le_bytes(),
251 &session_bytes,
252 ]
253 .concat();
254 let footer_hash = blake3::hash(&footer_payload);
255
256 let footer = AplanFooter {
257 file_size: encoded_state.len() as u64,
258 write_count: self.write_count,
259 last_session: session_bytes,
260 integrity: APLAN_INTEGRITY_MARKER,
261 footer_checksum: footer_hash.as_bytes()[..16].try_into().unwrap_or([0; 16]),
262 reserved: [0; 8],
263 };
264
265 let persisted = PersistedPlan {
266 header,
267 goals: self.goal_store.clone(),
268 decisions: self.decision_store.clone(),
269 commitments: self.commitment_store.clone(),
270 dreams: self.dream_store.clone(),
271 federations: self.federation_store.clone(),
272 soul_archive: self.soul_archive.clone(),
273 indexes: self.indexes.clone(),
274 footer,
275 };
276
277 let data = serde_json::to_vec_pretty(&persisted)?;
278
279 let temp_path = temp_path_for(&path);
281 let temp_file = std::fs::File::create(&temp_path)?;
282 use std::io::Write;
283 let mut writer = std::io::BufWriter::new(temp_file);
284 writer.write_all(&data)?;
285 writer.flush()?;
286 writer.get_ref().sync_all()?;
287 drop(writer);
288
289 std::fs::rename(&temp_path, &path)?;
290
291 self.dirty = false;
292 Ok(())
293 }
294}
295
296fn temp_path_for(path: &Path) -> PathBuf {
297 let mut temp = path.to_path_buf();
298 temp.set_extension("aplan.tmp");
299 temp
300}