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