Skip to main content

git_lore/lore/
mod.rs

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}