1use std::fs;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5use std::process::{Command, Stdio};
6
7use anyhow::{bail, Context, Result};
8use serde::de::DeserializeOwned;
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12pub mod merge;
13pub mod entropy;
14pub mod prism;
15pub mod refs;
16pub mod sanitize;
17pub mod validation;
18
19#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum LoreKind {
22 Decision,
23 Assumption,
24 OpenQuestion,
25 Signal,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum AtomState {
31 Draft,
32 Proposed,
33 Accepted,
34 Deprecated,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize)]
38pub struct LoreAtom {
39 pub id: String,
40 pub kind: LoreKind,
41 pub state: AtomState,
42 pub title: String,
43 pub body: Option<String>,
44 pub scope: Option<String>,
45 pub path: Option<PathBuf>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub validation_script: Option<String>,
48 pub created_unix_seconds: u64,
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
52pub struct WorkspaceState {
53 pub version: u16,
54 pub atoms: Vec<LoreAtom>,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize)]
58pub struct Checkpoint {
59 pub id: String,
60 pub message: Option<String>,
61 pub created_unix_seconds: u64,
62 pub atoms: Vec<LoreAtom>,
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct StateTransitionPreview {
67 pub atom_id: String,
68 pub current_state: Option<AtomState>,
69 pub target_state: AtomState,
70 pub allowed: bool,
71 pub code: String,
72 pub message: String,
73 pub reason_required: bool,
74}
75
76#[derive(Clone, Debug, Serialize, Deserialize)]
77pub struct StateTransitionAuditEvent {
78 pub atom_id: String,
79 pub previous_state: AtomState,
80 pub target_state: AtomState,
81 pub reason: String,
82 pub actor: Option<String>,
83 pub transitioned_unix_seconds: u64,
84}
85
86#[derive(Clone, Debug)]
87pub struct Workspace {
88 root: PathBuf,
89}
90
91impl LoreAtom {
92 pub fn new(
93 kind: LoreKind,
94 state: AtomState,
95 title: String,
96 body: Option<String>,
97 scope: Option<String>,
98 path: Option<PathBuf>,
99 ) -> Self {
100 Self {
101 id: Uuid::new_v4().to_string(),
102 kind,
103 state,
104 title,
105 body,
106 scope,
107 path,
108 validation_script: None,
109 created_unix_seconds: now_unix_seconds(),
110 }
111 }
112
113 pub fn with_validation_script(mut self, validation_script: Option<String>) -> Self {
114 self.validation_script = validation_script;
115 self
116 }
117}
118
119impl WorkspaceState {
120 pub fn empty() -> Self {
121 Self {
122 version: 1,
123 atoms: Vec::new(),
124 }
125 }
126}
127
128impl Workspace {
129 pub fn init(path: impl AsRef<Path>) -> Result<Self> {
130 let root = path
131 .as_ref()
132 .canonicalize()
133 .unwrap_or_else(|_| path.as_ref().to_path_buf());
134 let workspace = Self { root };
135 workspace.ensure_layout()?;
136 Ok(workspace)
137 }
138
139 pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
140 let mut current = path.as_ref();
141
142 loop {
143 let candidate = current.join(".lore");
144 if candidate.exists() {
145 return Ok(Self {
146 root: current.to_path_buf(),
147 });
148 }
149
150 match current.parent() {
151 Some(parent) => current = parent,
152 None => bail!(
153 "could not find a .lore workspace starting from {}",
154 path.as_ref().display()
155 ),
156 }
157 }
158 }
159
160 pub fn root(&self) -> &Path {
161 &self.root
162 }
163
164 pub fn load_state(&self) -> Result<WorkspaceState> {
165 let state_path = self.state_path();
166 if !state_path.exists() {
167 return Ok(WorkspaceState::empty());
168 }
169
170 self.read_json(&state_path)
171 }
172
173 pub fn record_atom(&self, atom: LoreAtom) -> Result<()> {
174 self.ensure_layout()?;
175
176 if let Some(issue) = sanitize::scan_atoms(std::slice::from_ref(&atom)).into_iter().next() {
177 return Err(anyhow::anyhow!(
178 "sensitive content detected in atom {} field {}: {}",
179 issue.atom_id,
180 issue.field,
181 issue.reason
182 ));
183 }
184
185 let mut state = self.load_state()?;
186 let atom_path = self.active_atom_path(&atom.id);
187
188 state.atoms.push(atom.clone());
189 self.write_json(&self.state_path(), &state)?;
190 self.write_json(&atom_path, &atom)?;
191 Ok(())
192 }
193
194 pub fn write_checkpoint(&self, message: Option<String>) -> Result<Checkpoint> {
195 self.ensure_layout()?;
196
197 let state = self.load_state()?;
198 let checkpoint = Checkpoint {
199 id: Uuid::new_v4().to_string(),
200 message,
201 created_unix_seconds: now_unix_seconds(),
202 atoms: state.atoms,
203 };
204
205 let checkpoint_path = self
206 .checkpoints_dir()
207 .join(format!("{}.json", checkpoint.id));
208 self.write_json(&checkpoint_path, &checkpoint)?;
209 Ok(checkpoint)
210 }
211
212 pub fn entropy_report(&self) -> Result<entropy::EntropyReport> {
213 let state = self.load_state()?;
214 Ok(entropy::analyze_workspace(&state))
215 }
216
217 pub fn sanitize_report(&self) -> Result<Vec<sanitize::SanitizationIssue>> {
218 let state = self.load_state()?;
219 Ok(sanitize::scan_atoms(&state.atoms))
220 }
221
222 pub fn validation_report(&self) -> Result<Vec<validation::ValidationIssue>> {
223 let state = self.load_state()?;
224 Ok(validation::scan_atoms(self.root(), &state.atoms))
225 }
226
227 pub fn set_state(&self, state: &WorkspaceState) -> Result<()> {
228 self.ensure_layout()?;
229 self.write_json(&self.state_path(), state)
230 }
231
232 pub fn preview_state_transition(
233 &self,
234 atom_id: &str,
235 target_state: AtomState,
236 ) -> Result<StateTransitionPreview> {
237 self.ensure_layout()?;
238 let state = self.load_state()?;
239 let current_state = state
240 .atoms
241 .iter()
242 .find(|atom| atom.id == atom_id)
243 .map(|atom| atom.state.clone());
244
245 let evaluation = match current_state.clone() {
246 Some(current) => evaluate_state_transition(current, target_state.clone()),
247 None => TransitionEvaluation {
248 allowed: false,
249 code: "atom_not_found",
250 message: "atom id was not found in active lore state",
251 },
252 };
253
254 Ok(StateTransitionPreview {
255 atom_id: atom_id.to_string(),
256 current_state,
257 target_state,
258 allowed: evaluation.allowed,
259 code: evaluation.code.to_string(),
260 message: evaluation.message.to_string(),
261 reason_required: true,
262 })
263 }
264
265 pub fn transition_atom_state(
266 &self,
267 atom_id: &str,
268 target_state: AtomState,
269 reason: impl Into<String>,
270 actor: Option<String>,
271 ) -> Result<LoreAtom> {
272 self.ensure_layout()?;
273 let reason = reason.into();
274 if reason.trim().is_empty() {
275 bail!("state transition requires a non-empty reason");
276 }
277
278 let mut state = self.load_state()?;
279 let atom = state
280 .atoms
281 .iter_mut()
282 .find(|atom| atom.id == atom_id)
283 .ok_or_else(|| anyhow::anyhow!("atom {} not found in active lore state", atom_id))?;
284
285 let previous_state = atom.state.clone();
286 let evaluation = evaluate_state_transition(previous_state.clone(), target_state.clone());
287 if !evaluation.allowed {
288 if evaluation.code == "state_noop" {
289 return Ok(atom.clone());
290 }
291 bail!(
292 "state transition rejected [{}]: {}",
293 evaluation.code,
294 evaluation.message
295 );
296 }
297
298 atom.state = target_state.clone();
299 let updated_atom = atom.clone();
300
301 self.write_json(&self.state_path(), &state)?;
302 self.write_json(&self.active_atom_path(&updated_atom.id), &updated_atom)?;
303
304 if updated_atom.state == AtomState::Accepted {
305 self.write_accepted_atom(&updated_atom, None)?;
306 }
307
308 self.append_state_transition_audit(&StateTransitionAuditEvent {
309 atom_id: updated_atom.id.clone(),
310 previous_state,
311 target_state,
312 reason,
313 actor,
314 transitioned_unix_seconds: now_unix_seconds(),
315 })?;
316
317 Ok(updated_atom)
318 }
319
320 pub fn accept_active_atoms(&self, source_commit: Option<&str>) -> Result<()> {
321 self.ensure_layout()?;
322
323 let mut state = self.load_state()?;
324 for atom in &mut state.atoms {
325 if atom.state != AtomState::Deprecated {
326 atom.state = AtomState::Accepted;
327 self.write_accepted_atom(atom, source_commit)?;
328 }
329 }
330
331 self.write_json(&self.state_path(), &state)?;
332 Ok(())
333 }
334
335 fn ensure_layout(&self) -> Result<()> {
336 fs::create_dir_all(self.lore_dir())?;
337 fs::create_dir_all(self.active_dir())?;
338 fs::create_dir_all(self.checkpoints_dir())?;
339 fs::create_dir_all(self.prism_dir())?;
340 fs::create_dir_all(self.refs_lore_accepted_dir())?;
341 fs::create_dir_all(self.audit_dir())?;
342 Ok(())
343 }
344
345 fn append_state_transition_audit(&self, event: &StateTransitionAuditEvent) -> Result<()> {
346 let path = self.state_transition_audit_path();
347 let mut file = fs::OpenOptions::new()
348 .create(true)
349 .append(true)
350 .open(&path)
351 .with_context(|| format!("failed to open state transition audit log {}", path.display()))?;
352
353 let line = serde_json::to_string(event)?;
354 file.write_all(line.as_bytes())
355 .with_context(|| format!("failed to write state transition audit log {}", path.display()))?;
356 file.write_all(b"\n")
357 .with_context(|| format!("failed to finalize state transition audit log {}", path.display()))?;
358
359 Ok(())
360 }
361
362 fn write_json<T: Serialize>(&self, path: &Path, value: &T) -> Result<()> {
363 let content = serde_json::to_vec_pretty(value)?;
364 let compressed = gzip_compress(&content)
365 .with_context(|| format!("failed to compress {}", path.display()))?;
366 fs::write(path, compressed).with_context(|| format!("failed to write {}", path.display()))?;
367 Ok(())
368 }
369
370 fn read_json<T: DeserializeOwned>(&self, path: &Path) -> Result<T> {
371 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
372 let content = if bytes.starts_with(&[0x1f, 0x8b]) {
373 gzip_decompress_file(path).with_context(|| format!("failed to decompress {}", path.display()))?
374 } else {
375 bytes
376 };
377 let value = serde_json::from_slice(&content)
378 .with_context(|| format!("failed to parse {}", path.display()))?;
379 Ok(value)
380 }
381
382 fn lore_dir(&self) -> PathBuf {
383 self.root.join(".lore")
384 }
385
386 fn state_path(&self) -> PathBuf {
387 self.lore_dir().join("active_intent.json")
388 }
389
390 fn active_dir(&self) -> PathBuf {
391 self.lore_dir().join("active")
392 }
393
394 fn checkpoints_dir(&self) -> PathBuf {
395 self.lore_dir().join("checkpoints")
396 }
397
398 fn prism_dir(&self) -> PathBuf {
399 self.lore_dir().join("prism")
400 }
401
402 fn refs_lore_dir(&self) -> PathBuf {
403 self.lore_dir().join("refs").join("lore")
404 }
405
406 fn refs_lore_accepted_dir(&self) -> PathBuf {
407 self.refs_lore_dir().join("accepted")
408 }
409
410 fn audit_dir(&self) -> PathBuf {
411 self.lore_dir().join("audit")
412 }
413
414 fn state_transition_audit_path(&self) -> PathBuf {
415 self.audit_dir().join("state_transitions.jsonl")
416 }
417
418 fn active_atom_path(&self, atom_id: &str) -> PathBuf {
419 self.active_dir().join(format!("{atom_id}.json"))
420 }
421}
422
423fn now_unix_seconds() -> u64 {
424 SystemTime::now()
425 .duration_since(UNIX_EPOCH)
426 .map(|duration| duration.as_secs())
427 .unwrap_or(0)
428}
429
430#[derive(Clone, Debug)]
431struct TransitionEvaluation {
432 allowed: bool,
433 code: &'static str,
434 message: &'static str,
435}
436
437fn evaluate_state_transition(current: AtomState, target: AtomState) -> TransitionEvaluation {
438 if current == target {
439 return TransitionEvaluation {
440 allowed: false,
441 code: "state_noop",
442 message: "atom is already in the target state",
443 };
444 }
445
446 let allowed = matches!(
447 (current.clone(), target.clone()),
448 (AtomState::Draft, AtomState::Proposed)
449 | (AtomState::Draft, AtomState::Deprecated)
450 | (AtomState::Proposed, AtomState::Accepted)
451 | (AtomState::Proposed, AtomState::Deprecated)
452 | (AtomState::Accepted, AtomState::Deprecated)
453 );
454
455 if allowed {
456 TransitionEvaluation {
457 allowed: true,
458 code: "state_transition_allowed",
459 message: "state transition is allowed",
460 }
461 } else {
462 TransitionEvaluation {
463 allowed: false,
464 code: "state_transition_blocked",
465 message: "requested state transition is not allowed by policy",
466 }
467 }
468}
469
470fn gzip_compress(bytes: &[u8]) -> Result<Vec<u8>> {
471 let mut child = Command::new("gzip")
472 .arg("-c")
473 .stdin(Stdio::piped())
474 .stdout(Stdio::piped())
475 .stderr(Stdio::piped())
476 .spawn()
477 .context("failed to spawn gzip for compression")?;
478
479 if let Some(stdin) = child.stdin.as_mut() {
480 stdin.write_all(bytes).context("failed to feed gzip input")?;
481 }
482
483 let output = child.wait_with_output().context("failed to finish gzip compression")?;
484 if !output.status.success() {
485 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
486 return Err(anyhow::anyhow!("gzip compression failed: {stderr}"));
487 }
488
489 Ok(output.stdout)
490}
491
492fn gzip_decompress_file(path: &Path) -> Result<Vec<u8>> {
493 let child = Command::new("gzip")
494 .arg("-dc")
495 .stdout(Stdio::piped())
496 .stderr(Stdio::piped())
497 .arg(path)
498 .spawn()
499 .context("failed to spawn gzip for decompression")?;
500
501 let output = child.wait_with_output().context("failed to finish gzip decompression")?;
502 if !output.status.success() {
503 return Err(anyhow::anyhow!("gzip decompression failed"));
504 }
505
506 Ok(output.stdout)
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use std::fs;
513
514 #[test]
515 fn checkpoint_contains_recorded_atoms() {
516 let temp_root = std::env::temp_dir().join(format!("git-lore-test-{}", Uuid::new_v4()));
517 fs::create_dir_all(&temp_root).unwrap();
518 let workspace = Workspace::init(&temp_root).unwrap();
519
520 let atom = LoreAtom::new(
521 LoreKind::Decision,
522 AtomState::Proposed,
523 "Use Postgres".to_string(),
524 Some("Spatial queries need PostGIS".to_string()),
525 Some("db".to_string()),
526 Some(PathBuf::from("src/db.rs")),
527 );
528
529 workspace.record_atom(atom.clone()).unwrap();
530 let checkpoint = workspace
531 .write_checkpoint(Some("initial checkpoint".to_string()))
532 .unwrap();
533
534 assert_eq!(checkpoint.atoms.len(), 1);
535 assert_eq!(checkpoint.atoms[0].id, atom.id);
536 assert_eq!(checkpoint.message.as_deref(), Some("initial checkpoint"));
537 }
538
539 #[test]
540 fn accept_active_atoms_promotes_recorded_atoms() {
541 let temp_root = std::env::temp_dir().join(format!("git-lore-accept-test-{}", Uuid::new_v4()));
542 fs::create_dir_all(&temp_root).unwrap();
543 let workspace = Workspace::init(&temp_root).unwrap();
544
545 let atom = LoreAtom::new(
546 LoreKind::Decision,
547 AtomState::Proposed,
548 "Use SQLite".to_string(),
549 None,
550 None,
551 None,
552 );
553
554 workspace.record_atom(atom).unwrap();
555 workspace.accept_active_atoms(None).unwrap();
556
557 let state = workspace.load_state().unwrap();
558 assert_eq!(state.atoms[0].state, AtomState::Accepted);
559 }
560
561 #[test]
562 fn transition_atom_state_updates_state_and_writes_audit() {
563 let temp_root = std::env::temp_dir().join(format!("git-lore-transition-test-{}", Uuid::new_v4()));
564 fs::create_dir_all(&temp_root).unwrap();
565 let workspace = Workspace::init(&temp_root).unwrap();
566
567 let atom = LoreAtom::new(
568 LoreKind::Decision,
569 AtomState::Proposed,
570 "Keep parser deterministic".to_string(),
571 None,
572 Some("parser".to_string()),
573 Some(PathBuf::from("src/parser/mod.rs")),
574 );
575 let atom_id = atom.id.clone();
576 workspace.record_atom(atom).unwrap();
577
578 let transitioned = workspace
579 .transition_atom_state(
580 &atom_id,
581 AtomState::Accepted,
582 "validated in integration test",
583 Some("unit-test".to_string()),
584 )
585 .unwrap();
586
587 assert_eq!(transitioned.state, AtomState::Accepted);
588 let state = workspace.load_state().unwrap();
589 assert_eq!(state.atoms[0].state, AtomState::Accepted);
590
591 let audit_path = temp_root.join(".lore/audit/state_transitions.jsonl");
592 let audit = fs::read_to_string(audit_path).unwrap();
593 assert!(audit.contains(&atom_id));
594 assert!(audit.contains("validated in integration test"));
595 }
596
597 #[test]
598 fn transition_preview_reports_blocked_transition() {
599 let temp_root = std::env::temp_dir().join(format!("git-lore-transition-preview-test-{}", Uuid::new_v4()));
600 fs::create_dir_all(&temp_root).unwrap();
601 let workspace = Workspace::init(&temp_root).unwrap();
602
603 let atom = LoreAtom::new(
604 LoreKind::Decision,
605 AtomState::Accepted,
606 "Keep sync idempotent".to_string(),
607 None,
608 Some("sync".to_string()),
609 Some(PathBuf::from("src/git/mod.rs")),
610 );
611 let atom_id = atom.id.clone();
612 workspace.record_atom(atom).unwrap();
613
614 let preview = workspace
615 .preview_state_transition(&atom_id, AtomState::Proposed)
616 .unwrap();
617
618 assert!(!preview.allowed);
619 assert_eq!(preview.code, "state_transition_blocked");
620 }
621}