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