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(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}