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(script) = atom.validation_script.as_deref() {
177 validation::validate_script(script)?;
178 }
179
180 if let Some(issue) = sanitize::scan_atoms(std::slice::from_ref(&atom)).into_iter().next() {
181 return Err(anyhow::anyhow!(
182 "sensitive content detected in atom {} field {}: {}",
183 issue.atom_id,
184 issue.field,
185 issue.reason
186 ));
187 }
188
189 let mut state = self.load_state()?;
190 let atom_path = self.active_atom_path(&atom.id);
191
192 state.atoms.push(atom.clone());
193 self.write_json(&self.state_path(), &state)?;
194 self.write_json(&atom_path, &atom)?;
195 Ok(())
196 }
197
198 pub fn write_checkpoint(&self, message: Option<String>) -> Result<Checkpoint> {
199 self.ensure_layout()?;
200
201 let state = self.load_state()?;
202 let checkpoint = Checkpoint {
203 id: Uuid::new_v4().to_string(),
204 message,
205 created_unix_seconds: now_unix_seconds(),
206 atoms: state.atoms,
207 };
208
209 let checkpoint_path = self
210 .checkpoints_dir()
211 .join(format!("{}.json", checkpoint.id));
212 self.write_json(&checkpoint_path, &checkpoint)?;
213 Ok(checkpoint)
214 }
215
216 pub fn entropy_report(&self) -> Result<entropy::EntropyReport> {
217 let state = self.load_state()?;
218 Ok(entropy::analyze_workspace(&state))
219 }
220
221 pub fn sanitize_report(&self) -> Result<Vec<sanitize::SanitizationIssue>> {
222 let state = self.load_state()?;
223 Ok(sanitize::scan_atoms(&state.atoms))
224 }
225
226 pub fn validation_report(&self) -> Result<Vec<validation::ValidationIssue>> {
227 let state = self.load_state()?;
228 Ok(validation::scan_atoms(self.root(), &state.atoms))
229 }
230
231 pub fn set_state(&self, state: &WorkspaceState) -> Result<()> {
232 self.ensure_layout()?;
233 self.write_json(&self.state_path(), state)
234 }
235
236 pub fn preview_state_transition(
237 &self,
238 atom_id: &str,
239 target_state: AtomState,
240 ) -> Result<StateTransitionPreview> {
241 self.ensure_layout()?;
242 let state = self.load_state()?;
243 let current_state = state
244 .atoms
245 .iter()
246 .find(|atom| atom.id == atom_id)
247 .map(|atom| atom.state.clone());
248
249 let evaluation = match current_state.clone() {
250 Some(current) => evaluate_state_transition(current, target_state.clone()),
251 None => TransitionEvaluation {
252 allowed: false,
253 code: "atom_not_found",
254 message: "atom id was not found in active lore state",
255 },
256 };
257
258 Ok(StateTransitionPreview {
259 atom_id: atom_id.to_string(),
260 current_state,
261 target_state,
262 allowed: evaluation.allowed,
263 code: evaluation.code.to_string(),
264 message: evaluation.message.to_string(),
265 reason_required: true,
266 })
267 }
268
269 pub fn transition_atom_state(
270 &self,
271 atom_id: &str,
272 target_state: AtomState,
273 reason: impl Into<String>,
274 actor: Option<String>,
275 ) -> Result<LoreAtom> {
276 self.ensure_layout()?;
277 let reason = reason.into();
278 if reason.trim().is_empty() {
279 bail!("state transition requires a non-empty reason");
280 }
281
282 let mut state = self.load_state()?;
283 let atom = state
284 .atoms
285 .iter_mut()
286 .find(|atom| atom.id == atom_id)
287 .ok_or_else(|| anyhow::anyhow!("atom {} not found in active lore state", atom_id))?;
288
289 let previous_state = atom.state.clone();
290 let evaluation = evaluate_state_transition(previous_state.clone(), target_state.clone());
291 if !evaluation.allowed {
292 if evaluation.code == "state_noop" {
293 return Ok(atom.clone());
294 }
295 bail!(
296 "state transition rejected [{}]: {}",
297 evaluation.code,
298 evaluation.message
299 );
300 }
301
302 atom.state = target_state.clone();
303 let updated_atom = atom.clone();
304
305 self.write_json(&self.state_path(), &state)?;
306 self.write_json(&self.active_atom_path(&updated_atom.id), &updated_atom)?;
307
308 if updated_atom.state == AtomState::Accepted {
309 self.write_accepted_atom(&updated_atom, None)?;
310 }
311
312 self.append_state_transition_audit(&StateTransitionAuditEvent {
313 atom_id: updated_atom.id.clone(),
314 previous_state,
315 target_state,
316 reason,
317 actor,
318 transitioned_unix_seconds: now_unix_seconds(),
319 })?;
320
321 Ok(updated_atom)
322 }
323
324 pub fn accept_active_atoms(&self, source_commit: Option<&str>) -> Result<()> {
325 self.ensure_layout()?;
326
327 let mut state = self.load_state()?;
328 for atom in &mut state.atoms {
329 if atom.state != AtomState::Deprecated {
330 atom.state = AtomState::Accepted;
331 self.write_accepted_atom(atom, source_commit)?;
332 }
333 }
334
335 self.write_json(&self.state_path(), &state)?;
336 Ok(())
337 }
338
339 fn ensure_layout(&self) -> Result<()> {
340 fs::create_dir_all(self.lore_dir())?;
341 fs::create_dir_all(self.active_dir())?;
342 fs::create_dir_all(self.checkpoints_dir())?;
343 fs::create_dir_all(self.prism_dir())?;
344 fs::create_dir_all(self.refs_lore_accepted_dir())?;
345 fs::create_dir_all(self.audit_dir())?;
346 Ok(())
347 }
348
349 fn append_state_transition_audit(&self, event: &StateTransitionAuditEvent) -> Result<()> {
350 let path = self.state_transition_audit_path();
351 let mut file = fs::OpenOptions::new()
352 .create(true)
353 .append(true)
354 .open(&path)
355 .with_context(|| format!("failed to open state transition audit log {}", path.display()))?;
356
357 let line = serde_json::to_string(event)?;
358 file.write_all(line.as_bytes())
359 .with_context(|| format!("failed to write state transition audit log {}", path.display()))?;
360 file.write_all(b"\n")
361 .with_context(|| format!("failed to finalize state transition audit log {}", path.display()))?;
362
363 Ok(())
364 }
365
366 fn write_json<T: Serialize>(&self, path: &Path, value: &T) -> Result<()> {
367 let content = serde_json::to_vec_pretty(value)?;
368 let compressed = gzip_compress(&content)
369 .with_context(|| format!("failed to compress {}", path.display()))?;
370 fs::write(path, compressed).with_context(|| format!("failed to write {}", path.display()))?;
371 Ok(())
372 }
373
374 fn read_json<T: DeserializeOwned>(&self, path: &Path) -> Result<T> {
375 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
376 let content = if bytes.starts_with(&[0x1f, 0x8b]) {
377 gzip_decompress_file(path).with_context(|| format!("failed to decompress {}", path.display()))?
378 } else {
379 bytes
380 };
381 let value = serde_json::from_slice(&content)
382 .with_context(|| format!("failed to parse {}", path.display()))?;
383 Ok(value)
384 }
385
386 fn lore_dir(&self) -> PathBuf {
387 self.root.join(".lore")
388 }
389
390 fn state_path(&self) -> PathBuf {
391 self.lore_dir().join("active_intent.json")
392 }
393
394 fn active_dir(&self) -> PathBuf {
395 self.lore_dir().join("active")
396 }
397
398 fn checkpoints_dir(&self) -> PathBuf {
399 self.lore_dir().join("checkpoints")
400 }
401
402 fn prism_dir(&self) -> PathBuf {
403 self.lore_dir().join("prism")
404 }
405
406 fn refs_lore_dir(&self) -> PathBuf {
407 self.lore_dir().join("refs").join("lore")
408 }
409
410 fn refs_lore_accepted_dir(&self) -> PathBuf {
411 self.refs_lore_dir().join("accepted")
412 }
413
414 fn audit_dir(&self) -> PathBuf {
415 self.lore_dir().join("audit")
416 }
417
418 fn state_transition_audit_path(&self) -> PathBuf {
419 self.audit_dir().join("state_transitions.jsonl")
420 }
421
422 fn active_atom_path(&self, atom_id: &str) -> PathBuf {
423 self.active_dir().join(format!("{atom_id}.json"))
424 }
425}
426
427fn now_unix_seconds() -> u64 {
428 SystemTime::now()
429 .duration_since(UNIX_EPOCH)
430 .map(|duration| duration.as_secs())
431 .unwrap_or(0)
432}
433
434#[derive(Clone, Debug)]
435struct TransitionEvaluation {
436 allowed: bool,
437 code: &'static str,
438 message: &'static str,
439}
440
441fn evaluate_state_transition(current: AtomState, target: AtomState) -> TransitionEvaluation {
442 if current == target {
443 return TransitionEvaluation {
444 allowed: false,
445 code: "state_noop",
446 message: "atom is already in the target state",
447 };
448 }
449
450 let allowed = matches!(
451 (current.clone(), target.clone()),
452 (AtomState::Draft, AtomState::Proposed)
453 | (AtomState::Draft, AtomState::Deprecated)
454 | (AtomState::Proposed, AtomState::Accepted)
455 | (AtomState::Proposed, AtomState::Deprecated)
456 | (AtomState::Accepted, AtomState::Deprecated)
457 );
458
459 if allowed {
460 TransitionEvaluation {
461 allowed: true,
462 code: "state_transition_allowed",
463 message: "state transition is allowed",
464 }
465 } else {
466 TransitionEvaluation {
467 allowed: false,
468 code: "state_transition_blocked",
469 message: "requested state transition is not allowed by policy",
470 }
471 }
472}
473
474fn gzip_compress(bytes: &[u8]) -> Result<Vec<u8>> {
475 let mut child = Command::new("gzip")
476 .arg("-c")
477 .stdin(Stdio::piped())
478 .stdout(Stdio::piped())
479 .stderr(Stdio::piped())
480 .spawn()
481 .context("failed to spawn gzip for compression")?;
482
483 if let Some(stdin) = child.stdin.as_mut() {
484 stdin.write_all(bytes).context("failed to feed gzip input")?;
485 }
486
487 let output = child.wait_with_output().context("failed to finish gzip compression")?;
488 if !output.status.success() {
489 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
490 return Err(anyhow::anyhow!("gzip compression failed: {stderr}"));
491 }
492
493 Ok(output.stdout)
494}
495
496fn gzip_decompress_file(path: &Path) -> Result<Vec<u8>> {
497 let child = Command::new("gzip")
498 .arg("-dc")
499 .stdout(Stdio::piped())
500 .stderr(Stdio::piped())
501 .arg(path)
502 .spawn()
503 .context("failed to spawn gzip for decompression")?;
504
505 let output = child.wait_with_output().context("failed to finish gzip decompression")?;
506 if !output.status.success() {
507 return Err(anyhow::anyhow!("gzip decompression failed"));
508 }
509
510 Ok(output.stdout)
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use std::fs;
517
518 #[test]
519 fn checkpoint_contains_recorded_atoms() {
520 let temp_root = std::env::temp_dir().join(format!("git-lore-test-{}", Uuid::new_v4()));
521 fs::create_dir_all(&temp_root).unwrap();
522 let workspace = Workspace::init(&temp_root).unwrap();
523
524 let atom = LoreAtom::new(
525 LoreKind::Decision,
526 AtomState::Proposed,
527 "Use Postgres".to_string(),
528 Some("Spatial queries need PostGIS".to_string()),
529 Some("db".to_string()),
530 Some(PathBuf::from("src/db.rs")),
531 );
532
533 workspace.record_atom(atom.clone()).unwrap();
534 let checkpoint = workspace
535 .write_checkpoint(Some("initial checkpoint".to_string()))
536 .unwrap();
537
538 assert_eq!(checkpoint.atoms.len(), 1);
539 assert_eq!(checkpoint.atoms[0].id, atom.id);
540 assert_eq!(checkpoint.message.as_deref(), Some("initial checkpoint"));
541 }
542
543 #[test]
544 fn accept_active_atoms_promotes_recorded_atoms() {
545 let temp_root = std::env::temp_dir().join(format!("git-lore-accept-test-{}", Uuid::new_v4()));
546 fs::create_dir_all(&temp_root).unwrap();
547 let workspace = Workspace::init(&temp_root).unwrap();
548
549 let atom = LoreAtom::new(
550 LoreKind::Decision,
551 AtomState::Proposed,
552 "Use SQLite".to_string(),
553 None,
554 None,
555 None,
556 );
557
558 workspace.record_atom(atom).unwrap();
559 workspace.accept_active_atoms(None).unwrap();
560
561 let state = workspace.load_state().unwrap();
562 assert_eq!(state.atoms[0].state, AtomState::Accepted);
563 }
564
565 #[test]
566 fn transition_atom_state_updates_state_and_writes_audit() {
567 let temp_root = std::env::temp_dir().join(format!("git-lore-transition-test-{}", Uuid::new_v4()));
568 fs::create_dir_all(&temp_root).unwrap();
569 let workspace = Workspace::init(&temp_root).unwrap();
570
571 let atom = LoreAtom::new(
572 LoreKind::Decision,
573 AtomState::Proposed,
574 "Keep parser deterministic".to_string(),
575 None,
576 Some("parser".to_string()),
577 Some(PathBuf::from("src/parser/mod.rs")),
578 );
579 let atom_id = atom.id.clone();
580 workspace.record_atom(atom).unwrap();
581
582 let transitioned = workspace
583 .transition_atom_state(
584 &atom_id,
585 AtomState::Accepted,
586 "validated in integration test",
587 Some("unit-test".to_string()),
588 )
589 .unwrap();
590
591 assert_eq!(transitioned.state, AtomState::Accepted);
592 let state = workspace.load_state().unwrap();
593 assert_eq!(state.atoms[0].state, AtomState::Accepted);
594
595 let audit_path = temp_root.join(".lore/audit/state_transitions.jsonl");
596 let audit = fs::read_to_string(audit_path).unwrap();
597 assert!(audit.contains(&atom_id));
598 assert!(audit.contains("validated in integration test"));
599 }
600
601 #[test]
602 fn transition_preview_reports_blocked_transition() {
603 let temp_root = std::env::temp_dir().join(format!("git-lore-transition-preview-test-{}", Uuid::new_v4()));
604 fs::create_dir_all(&temp_root).unwrap();
605 let workspace = Workspace::init(&temp_root).unwrap();
606
607 let atom = LoreAtom::new(
608 LoreKind::Decision,
609 AtomState::Accepted,
610 "Keep sync idempotent".to_string(),
611 None,
612 Some("sync".to_string()),
613 Some(PathBuf::from("src/git/mod.rs")),
614 );
615 let atom_id = atom.id.clone();
616 workspace.record_atom(atom).unwrap();
617
618 let preview = workspace
619 .preview_state_transition(&atom_id, AtomState::Proposed)
620 .unwrap();
621
622 assert!(!preview.allowed);
623 assert_eq!(preview.code, "state_transition_blocked");
624 }
625}