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, Default)]
87pub struct AtomEditRequest {
88    pub kind: Option<LoreKind>,
89    pub title: Option<String>,
90    pub body: Option<Option<String>>,
91    pub scope: Option<Option<String>>,
92    pub path: Option<Option<PathBuf>>,
93    pub validation_script: Option<Option<String>>,
94    pub trace_commit_sha: Option<Option<String>>,
95}
96
97#[derive(Clone, Debug, Serialize, Deserialize)]
98pub struct AtomEditAuditEvent {
99    pub atom_id: String,
100    pub reason: String,
101    pub actor: Option<String>,
102    pub changed_fields: Vec<String>,
103    pub source_commit: Option<String>,
104    pub edited_unix_seconds: u64,
105}
106
107#[derive(Clone, Debug)]
108pub struct AtomEditResult {
109    pub atom: LoreAtom,
110    pub changed_fields: Vec<String>,
111    pub source_commit: Option<String>,
112}
113
114#[derive(Clone, Debug)]
115pub struct Workspace {
116    root: PathBuf,
117}
118
119impl LoreAtom {
120    pub fn new(
121        kind: LoreKind,
122        state: AtomState,
123        title: String,
124        body: Option<String>,
125        scope: Option<String>,
126        path: Option<PathBuf>,
127    ) -> Self {
128        Self {
129            id: Uuid::new_v4().to_string(),
130            kind,
131            state,
132            title,
133            body,
134            scope,
135            path,
136            validation_script: None,
137            created_unix_seconds: now_unix_seconds(),
138        }
139    }
140
141    pub fn with_validation_script(mut self, validation_script: Option<String>) -> Self {
142        self.validation_script = validation_script;
143        self
144    }
145}
146
147impl WorkspaceState {
148    pub fn empty() -> Self {
149        Self {
150            version: 1,
151            atoms: Vec::new(),
152        }
153    }
154}
155
156impl Workspace {
157    pub fn init(path: impl AsRef<Path>) -> Result<Self> {
158        let root = path
159            .as_ref()
160            .canonicalize()
161            .unwrap_or_else(|_| path.as_ref().to_path_buf());
162        let workspace = Self { root };
163        workspace.ensure_layout()?;
164        Ok(workspace)
165    }
166
167    pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
168        let mut current = path.as_ref();
169
170        loop {
171            let candidate = current.join(".lore");
172            if candidate.exists() {
173                return Ok(Self {
174                    root: current.to_path_buf(),
175                });
176            }
177
178            match current.parent() {
179                Some(parent) => current = parent,
180                None => bail!(
181                    "could not find a .lore workspace starting from {}",
182                    path.as_ref().display()
183                ),
184            }
185        }
186    }
187
188    pub fn root(&self) -> &Path {
189        &self.root
190    }
191
192    pub fn load_state(&self) -> Result<WorkspaceState> {
193        let state_path = self.state_path();
194        if !state_path.exists() {
195            return Ok(WorkspaceState::empty());
196        }
197
198        self.read_json(&state_path)
199    }
200
201    pub fn record_atom(&self, atom: LoreAtom) -> Result<()> {
202        self.ensure_layout()?;
203
204        if atom.kind != LoreKind::Signal {
205            let has_path = atom
206                .path
207                .as_ref()
208                .map(|path| !path.as_os_str().is_empty())
209                .unwrap_or(false);
210            let has_scope = atom
211                .scope
212                .as_deref()
213                .map(str::trim)
214                .map(|scope| !scope.is_empty())
215                .unwrap_or(false);
216
217            if !has_path && !has_scope {
218                bail!(
219                    "non-signal atoms require at least one anchor; provide --path or --scope"
220                );
221            }
222        }
223
224        if let Some(script) = atom.validation_script.as_deref() {
225            validation::validate_script(script)?;
226        }
227
228        if let Some(issue) = sanitize::scan_atoms(std::slice::from_ref(&atom)).into_iter().next() {
229            return Err(anyhow::anyhow!(
230                "sensitive content detected in atom {} field {}: {}",
231                issue.atom_id,
232                issue.field,
233                issue.reason
234            ));
235        }
236
237        let mut state = self.load_state()?;
238        let atom_path = self.active_atom_path(&atom.id);
239
240        state.atoms.push(atom.clone());
241        self.write_json(&self.state_path(), &state)?;
242        self.write_json(&atom_path, &atom)?;
243        Ok(())
244    }
245
246    pub fn write_checkpoint(&self, message: Option<String>) -> Result<Checkpoint> {
247        self.ensure_layout()?;
248
249        let state = self.load_state()?;
250        let checkpoint = Checkpoint {
251            id: Uuid::new_v4().to_string(),
252            message,
253            created_unix_seconds: now_unix_seconds(),
254            atoms: state.atoms,
255        };
256
257        let checkpoint_path = self
258            .checkpoints_dir()
259            .join(format!("{}.json", checkpoint.id));
260        self.write_json(&checkpoint_path, &checkpoint)?;
261        Ok(checkpoint)
262    }
263
264    pub fn entropy_report(&self) -> Result<entropy::EntropyReport> {
265        let state = self.load_state()?;
266        Ok(entropy::analyze_workspace(&state))
267    }
268
269    pub fn sanitize_report(&self) -> Result<Vec<sanitize::SanitizationIssue>> {
270        let state = self.load_state()?;
271        Ok(sanitize::scan_atoms(&state.atoms))
272    }
273
274    pub fn validation_report(&self) -> Result<Vec<validation::ValidationIssue>> {
275        let state = self.load_state()?;
276        Ok(validation::scan_atoms(self.root(), &state.atoms))
277    }
278
279    pub fn set_state(&self, state: &WorkspaceState) -> Result<()> {
280        self.ensure_layout()?;
281        self.write_json(&self.state_path(), state)
282    }
283
284    pub fn preview_state_transition(
285        &self,
286        atom_id: &str,
287        target_state: AtomState,
288    ) -> Result<StateTransitionPreview> {
289        self.ensure_layout()?;
290        let state = self.load_state()?;
291        let current_state = state
292            .atoms
293            .iter()
294            .find(|atom| atom.id == atom_id)
295            .map(|atom| atom.state.clone());
296
297        let evaluation = match current_state.clone() {
298            Some(current) => evaluate_state_transition(current, target_state.clone()),
299            None => TransitionEvaluation {
300                allowed: false,
301                code: "atom_not_found",
302                message: "atom id was not found in active lore state",
303            },
304        };
305
306        Ok(StateTransitionPreview {
307            atom_id: atom_id.to_string(),
308            current_state,
309            target_state,
310            allowed: evaluation.allowed,
311            code: evaluation.code.to_string(),
312            message: evaluation.message.to_string(),
313            reason_required: true,
314        })
315    }
316
317    pub fn transition_atom_state(
318        &self,
319        atom_id: &str,
320        target_state: AtomState,
321        reason: impl Into<String>,
322        actor: Option<String>,
323    ) -> Result<LoreAtom> {
324        self.ensure_layout()?;
325        let reason = reason.into();
326        if reason.trim().is_empty() {
327            bail!("state transition requires a non-empty reason");
328        }
329
330        let mut state = self.load_state()?;
331        let atom = state
332            .atoms
333            .iter_mut()
334            .find(|atom| atom.id == atom_id)
335            .ok_or_else(|| anyhow::anyhow!("atom {} not found in active lore state", atom_id))?;
336
337        let previous_state = atom.state.clone();
338        let evaluation = evaluate_state_transition(previous_state.clone(), target_state.clone());
339        if !evaluation.allowed {
340            if evaluation.code == "state_noop" {
341                return Ok(atom.clone());
342            }
343            bail!(
344                "state transition rejected [{}]: {}",
345                evaluation.code,
346                evaluation.message
347            );
348        }
349
350        atom.state = target_state.clone();
351        let updated_atom = atom.clone();
352
353        self.write_json(&self.state_path(), &state)?;
354        self.write_json(&self.active_atom_path(&updated_atom.id), &updated_atom)?;
355
356        if updated_atom.state == AtomState::Accepted {
357            self.write_accepted_atom(&updated_atom, None)?;
358        }
359
360        self.append_state_transition_audit(&StateTransitionAuditEvent {
361            atom_id: updated_atom.id.clone(),
362            previous_state,
363            target_state,
364            reason,
365            actor,
366            transitioned_unix_seconds: now_unix_seconds(),
367        })?;
368
369        Ok(updated_atom)
370    }
371
372    pub fn edit_atom(
373        &self,
374        atom_id: &str,
375        edit: AtomEditRequest,
376        reason: impl Into<String>,
377        actor: Option<String>,
378    ) -> Result<AtomEditResult> {
379        self.ensure_layout()?;
380        let reason = reason.into();
381        if reason.trim().is_empty() {
382            bail!("atom edit requires a non-empty reason");
383        }
384
385        let mut state = self.load_state()?;
386        let atom = state
387            .atoms
388            .iter_mut()
389            .find(|atom| atom.id == atom_id)
390            .ok_or_else(|| anyhow::anyhow!("atom {} not found in active lore state", atom_id))?;
391
392        if edit.trace_commit_sha.is_some() && atom.state != AtomState::Accepted {
393            bail!("trace commit can only be edited for accepted atoms");
394        }
395
396        let previous_atom = atom.clone();
397        if let Some(kind) = edit.kind {
398            atom.kind = kind;
399        }
400
401        if let Some(title) = edit.title {
402            atom.title = title;
403        }
404
405        if let Some(body) = edit.body {
406            atom.body = body;
407        }
408
409        if let Some(scope) = edit.scope {
410            atom.scope = scope;
411        }
412
413        if let Some(path) = edit.path {
414            atom.path = path;
415        }
416
417        if let Some(validation_script) = edit.validation_script {
418            atom.validation_script = validation_script;
419        }
420
421        if atom.kind != LoreKind::Signal {
422            let has_path = atom
423                .path
424                .as_ref()
425                .map(|path| !path.as_os_str().is_empty())
426                .unwrap_or(false);
427            let has_scope = atom
428                .scope
429                .as_deref()
430                .map(str::trim)
431                .map(|scope| !scope.is_empty())
432                .unwrap_or(false);
433
434            if !has_path && !has_scope {
435                bail!(
436                    "non-signal atoms require at least one anchor; provide --atom-path or --scope"
437                );
438            }
439        }
440
441        if let Some(script) = atom.validation_script.as_deref() {
442            validation::validate_script(script)?;
443        }
444
445        if let Some(issue) = sanitize::scan_atoms(std::slice::from_ref(&*atom)).into_iter().next() {
446            return Err(anyhow::anyhow!(
447                "sensitive content detected in atom {} field {}: {}",
448                issue.atom_id,
449                issue.field,
450                issue.reason
451            ));
452        }
453
454        let mut changed_fields = Vec::new();
455        if previous_atom.kind != atom.kind {
456            changed_fields.push("kind".to_string());
457        }
458        if previous_atom.title != atom.title {
459            changed_fields.push("title".to_string());
460        }
461        if previous_atom.body != atom.body {
462            changed_fields.push("body".to_string());
463        }
464        if previous_atom.scope != atom.scope {
465            changed_fields.push("scope".to_string());
466        }
467        if previous_atom.path != atom.path {
468            changed_fields.push("path".to_string());
469        }
470        if previous_atom.validation_script != atom.validation_script {
471            changed_fields.push("validation_script".to_string());
472        }
473
474        let current_source_commit = if atom.state == AtomState::Accepted {
475            self.load_accepted_atoms()?
476                .into_iter()
477                .find(|record| record.atom.id == atom.id)
478                .and_then(|record| record.source_commit)
479        } else {
480            None
481        };
482
483        let next_source_commit = match edit.trace_commit_sha {
484            Some(value) => value,
485            None => current_source_commit.clone(),
486        };
487
488        if next_source_commit != current_source_commit {
489            changed_fields.push("trace.source_commit".to_string());
490        }
491
492        if changed_fields.is_empty() {
493            return Ok(AtomEditResult {
494                atom: atom.clone(),
495                changed_fields,
496                source_commit: current_source_commit,
497            });
498        }
499
500        let updated_atom = atom.clone();
501        self.write_json(&self.state_path(), &state)?;
502        self.write_json(&self.active_atom_path(&updated_atom.id), &updated_atom)?;
503
504        if updated_atom.state == AtomState::Accepted {
505            self.write_accepted_atom(&updated_atom, next_source_commit.as_deref())?;
506        }
507
508        self.append_atom_edit_audit(&AtomEditAuditEvent {
509            atom_id: updated_atom.id.clone(),
510            reason,
511            actor,
512            changed_fields: changed_fields.clone(),
513            source_commit: next_source_commit.clone(),
514            edited_unix_seconds: now_unix_seconds(),
515        })?;
516
517        Ok(AtomEditResult {
518            atom: updated_atom,
519            changed_fields,
520            source_commit: next_source_commit,
521        })
522    }
523
524    pub fn accept_active_atoms(&self, source_commit: Option<&str>) -> Result<()> {
525        self.ensure_layout()?;
526
527        let mut state = self.load_state()?;
528        for atom in &mut state.atoms {
529            if atom.state != AtomState::Deprecated {
530                atom.state = AtomState::Accepted;
531                self.write_accepted_atom(atom, source_commit)?;
532            }
533        }
534
535        self.write_json(&self.state_path(), &state)?;
536        Ok(())
537    }
538
539    fn ensure_layout(&self) -> Result<()> {
540        fs::create_dir_all(self.lore_dir())?;
541        fs::create_dir_all(self.active_dir())?;
542        fs::create_dir_all(self.checkpoints_dir())?;
543        fs::create_dir_all(self.prism_dir())?;
544        fs::create_dir_all(self.refs_lore_accepted_dir())?;
545        fs::create_dir_all(self.audit_dir())?;
546        Ok(())
547    }
548
549    fn append_state_transition_audit(&self, event: &StateTransitionAuditEvent) -> Result<()> {
550        let path = self.state_transition_audit_path();
551        let mut file = fs::OpenOptions::new()
552            .create(true)
553            .append(true)
554            .open(&path)
555            .with_context(|| format!("failed to open state transition audit log {}", path.display()))?;
556
557        let line = serde_json::to_string(event)?;
558        file.write_all(line.as_bytes())
559            .with_context(|| format!("failed to write state transition audit log {}", path.display()))?;
560        file.write_all(b"\n")
561            .with_context(|| format!("failed to finalize state transition audit log {}", path.display()))?;
562
563        Ok(())
564    }
565
566    fn append_atom_edit_audit(&self, event: &AtomEditAuditEvent) -> Result<()> {
567        let path = self.atom_edit_audit_path();
568        let mut file = fs::OpenOptions::new()
569            .create(true)
570            .append(true)
571            .open(&path)
572            .with_context(|| format!("failed to open atom edit audit log {}", path.display()))?;
573
574        let line = serde_json::to_string(event)?;
575        file.write_all(line.as_bytes())
576            .with_context(|| format!("failed to write atom edit audit log {}", path.display()))?;
577        file.write_all(b"\n")
578            .with_context(|| format!("failed to finalize atom edit audit log {}", path.display()))?;
579
580        Ok(())
581    }
582
583    fn write_json<T: Serialize>(&self, path: &Path, value: &T) -> Result<()> {
584        let content = serde_json::to_vec_pretty(value)?;
585        let compressed = gzip_compress(&content)
586            .with_context(|| format!("failed to compress {}", path.display()))?;
587        fs::write(path, compressed).with_context(|| format!("failed to write {}", path.display()))?;
588        Ok(())
589    }
590
591    fn read_json<T: DeserializeOwned>(&self, path: &Path) -> Result<T> {
592        let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
593        let content = if bytes.starts_with(&[0x1f, 0x8b]) {
594            gzip_decompress_file(path).with_context(|| format!("failed to decompress {}", path.display()))?
595        } else {
596            bytes
597        };
598        let value = serde_json::from_slice(&content)
599            .with_context(|| format!("failed to parse {}", path.display()))?;
600        Ok(value)
601    }
602
603    fn lore_dir(&self) -> PathBuf {
604        self.root.join(".lore")
605    }
606
607    fn state_path(&self) -> PathBuf {
608        self.lore_dir().join("active_intent.json")
609    }
610
611    fn active_dir(&self) -> PathBuf {
612        self.lore_dir().join("active")
613    }
614
615    fn checkpoints_dir(&self) -> PathBuf {
616        self.lore_dir().join("checkpoints")
617    }
618
619    fn prism_dir(&self) -> PathBuf {
620        self.lore_dir().join("prism")
621    }
622
623    fn refs_lore_dir(&self) -> PathBuf {
624        self.lore_dir().join("refs").join("lore")
625    }
626
627    fn refs_lore_accepted_dir(&self) -> PathBuf {
628        self.refs_lore_dir().join("accepted")
629    }
630
631    fn audit_dir(&self) -> PathBuf {
632        self.lore_dir().join("audit")
633    }
634
635    fn state_transition_audit_path(&self) -> PathBuf {
636        self.audit_dir().join("state_transitions.jsonl")
637    }
638
639    fn atom_edit_audit_path(&self) -> PathBuf {
640        self.audit_dir().join("atom_edits.jsonl")
641    }
642
643    fn active_atom_path(&self, atom_id: &str) -> PathBuf {
644        self.active_dir().join(format!("{atom_id}.json"))
645    }
646}
647
648fn now_unix_seconds() -> u64 {
649    SystemTime::now()
650        .duration_since(UNIX_EPOCH)
651        .map(|duration| duration.as_secs())
652        .unwrap_or(0)
653}
654
655#[derive(Clone, Debug)]
656struct TransitionEvaluation {
657    allowed: bool,
658    code: &'static str,
659    message: &'static str,
660}
661
662fn evaluate_state_transition(current: AtomState, target: AtomState) -> TransitionEvaluation {
663    if current == target {
664        return TransitionEvaluation {
665            allowed: false,
666            code: "state_noop",
667            message: "atom is already in the target state",
668        };
669    }
670
671    let allowed = matches!(
672        (current.clone(), target.clone()),
673        (AtomState::Draft, AtomState::Proposed)
674            | (AtomState::Draft, AtomState::Deprecated)
675            | (AtomState::Proposed, AtomState::Accepted)
676            | (AtomState::Proposed, AtomState::Deprecated)
677            | (AtomState::Accepted, AtomState::Deprecated)
678    );
679
680    if allowed {
681        TransitionEvaluation {
682            allowed: true,
683            code: "state_transition_allowed",
684            message: "state transition is allowed",
685        }
686    } else {
687        TransitionEvaluation {
688            allowed: false,
689            code: "state_transition_blocked",
690            message: "requested state transition is not allowed by policy",
691        }
692    }
693}
694
695fn gzip_compress(bytes: &[u8]) -> Result<Vec<u8>> {
696    let mut child = Command::new("gzip")
697        .arg("-c")
698        .stdin(Stdio::piped())
699        .stdout(Stdio::piped())
700        .stderr(Stdio::piped())
701        .spawn()
702        .context("failed to spawn gzip for compression")?;
703
704    if let Some(stdin) = child.stdin.as_mut() {
705        stdin.write_all(bytes).context("failed to feed gzip input")?;
706    }
707
708    let output = child.wait_with_output().context("failed to finish gzip compression")?;
709    if !output.status.success() {
710        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
711        return Err(anyhow::anyhow!("gzip compression failed: {stderr}"));
712    }
713
714    Ok(output.stdout)
715}
716
717fn gzip_decompress_file(path: &Path) -> Result<Vec<u8>> {
718    let child = Command::new("gzip")
719        .arg("-dc")
720        .stdout(Stdio::piped())
721        .stderr(Stdio::piped())
722        .arg(path)
723        .spawn()
724        .context("failed to spawn gzip for decompression")?;
725
726    let output = child.wait_with_output().context("failed to finish gzip decompression")?;
727    if !output.status.success() {
728        return Err(anyhow::anyhow!("gzip decompression failed"));
729    }
730
731    Ok(output.stdout)
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use std::fs;
738
739    #[test]
740    fn checkpoint_contains_recorded_atoms() {
741        let temp_root = std::env::temp_dir().join(format!("git-lore-test-{}", Uuid::new_v4()));
742        fs::create_dir_all(&temp_root).unwrap();
743        let workspace = Workspace::init(&temp_root).unwrap();
744
745        let atom = LoreAtom::new(
746            LoreKind::Decision,
747            AtomState::Proposed,
748            "Use Postgres".to_string(),
749            Some("Spatial queries need PostGIS".to_string()),
750            Some("db".to_string()),
751            Some(PathBuf::from("src/db.rs")),
752        );
753
754        workspace.record_atom(atom.clone()).unwrap();
755        let checkpoint = workspace
756            .write_checkpoint(Some("initial checkpoint".to_string()))
757            .unwrap();
758
759        assert_eq!(checkpoint.atoms.len(), 1);
760        assert_eq!(checkpoint.atoms[0].id, atom.id);
761        assert_eq!(checkpoint.message.as_deref(), Some("initial checkpoint"));
762    }
763
764    #[test]
765    fn accept_active_atoms_promotes_recorded_atoms() {
766        let temp_root = std::env::temp_dir().join(format!("git-lore-accept-test-{}", Uuid::new_v4()));
767        fs::create_dir_all(&temp_root).unwrap();
768        let workspace = Workspace::init(&temp_root).unwrap();
769
770        let atom = LoreAtom::new(
771            LoreKind::Decision,
772            AtomState::Proposed,
773            "Use SQLite".to_string(),
774            None,
775            Some("db".to_string()),
776            None,
777        );
778
779        workspace.record_atom(atom).unwrap();
780        workspace.accept_active_atoms(None).unwrap();
781
782        let state = workspace.load_state().unwrap();
783        assert_eq!(state.atoms[0].state, AtomState::Accepted);
784    }
785
786    #[test]
787    fn transition_atom_state_updates_state_and_writes_audit() {
788        let temp_root = std::env::temp_dir().join(format!("git-lore-transition-test-{}", Uuid::new_v4()));
789        fs::create_dir_all(&temp_root).unwrap();
790        let workspace = Workspace::init(&temp_root).unwrap();
791
792        let atom = LoreAtom::new(
793            LoreKind::Decision,
794            AtomState::Proposed,
795            "Keep parser deterministic".to_string(),
796            None,
797            Some("parser".to_string()),
798            Some(PathBuf::from("src/parser/mod.rs")),
799        );
800        let atom_id = atom.id.clone();
801        workspace.record_atom(atom).unwrap();
802
803        let transitioned = workspace
804            .transition_atom_state(
805                &atom_id,
806                AtomState::Accepted,
807                "validated in integration test",
808                Some("unit-test".to_string()),
809            )
810            .unwrap();
811
812        assert_eq!(transitioned.state, AtomState::Accepted);
813        let state = workspace.load_state().unwrap();
814        assert_eq!(state.atoms[0].state, AtomState::Accepted);
815
816        let audit_path = temp_root.join(".lore/audit/state_transitions.jsonl");
817        let audit = fs::read_to_string(audit_path).unwrap();
818        assert!(audit.contains(&atom_id));
819        assert!(audit.contains("validated in integration test"));
820    }
821
822    #[test]
823    fn transition_preview_reports_blocked_transition() {
824        let temp_root = std::env::temp_dir().join(format!("git-lore-transition-preview-test-{}", Uuid::new_v4()));
825        fs::create_dir_all(&temp_root).unwrap();
826        let workspace = Workspace::init(&temp_root).unwrap();
827
828        let atom = LoreAtom::new(
829            LoreKind::Decision,
830            AtomState::Accepted,
831            "Keep sync idempotent".to_string(),
832            None,
833            Some("sync".to_string()),
834            Some(PathBuf::from("src/git/mod.rs")),
835        );
836        let atom_id = atom.id.clone();
837        workspace.record_atom(atom).unwrap();
838
839        let preview = workspace
840            .preview_state_transition(&atom_id, AtomState::Proposed)
841            .unwrap();
842
843        assert!(!preview.allowed);
844        assert_eq!(preview.code, "state_transition_blocked");
845    }
846
847    #[test]
848    fn record_atom_rejects_non_signal_without_path_or_scope() {
849        let temp_root = std::env::temp_dir().join(format!("git-lore-anchor-test-{}", Uuid::new_v4()));
850        fs::create_dir_all(&temp_root).unwrap();
851        let workspace = Workspace::init(&temp_root).unwrap();
852
853        let atom = LoreAtom::new(
854            LoreKind::Decision,
855            AtomState::Proposed,
856            "Anchor required".to_string(),
857            None,
858            None,
859            None,
860        );
861
862        let error = workspace.record_atom(atom).unwrap_err();
863        assert!(error
864            .to_string()
865            .contains("provide --path or --scope"));
866    }
867
868    #[test]
869    fn edit_atom_updates_in_place_and_writes_audit() {
870        let temp_root = std::env::temp_dir().join(format!("git-lore-edit-test-{}", Uuid::new_v4()));
871        fs::create_dir_all(&temp_root).unwrap();
872        let workspace = Workspace::init(&temp_root).unwrap();
873
874        let atom = LoreAtom::new(
875            LoreKind::Decision,
876            AtomState::Proposed,
877            "Use parser v1".to_string(),
878            Some("initial rationale".to_string()),
879            Some("parser".to_string()),
880            Some(PathBuf::from("src/parser/mod.rs")),
881        );
882        let atom_id = atom.id.clone();
883        workspace.record_atom(atom).unwrap();
884
885        let result = workspace
886            .edit_atom(
887                &atom_id,
888                AtomEditRequest {
889                    title: Some("Use parser v2".to_string()),
890                    body: Some(Some("updated rationale".to_string())),
891                    ..Default::default()
892                },
893                "clarify decision",
894                Some("unit-test".to_string()),
895            )
896            .unwrap();
897
898        assert_eq!(result.atom.id, atom_id);
899        assert_eq!(result.atom.title, "Use parser v2");
900        assert_eq!(result.atom.body.as_deref(), Some("updated rationale"));
901        assert!(result.changed_fields.contains(&"title".to_string()));
902        assert!(result.changed_fields.contains(&"body".to_string()));
903
904        let audit_path = temp_root.join(".lore/audit/atom_edits.jsonl");
905        let audit = fs::read_to_string(audit_path).unwrap();
906        assert!(audit.contains("clarify decision"));
907        assert!(audit.contains(&atom_id));
908    }
909
910    #[test]
911    fn edit_atom_can_update_accepted_trace_commit() {
912        let temp_root = std::env::temp_dir().join(format!("git-lore-edit-trace-test-{}", Uuid::new_v4()));
913        fs::create_dir_all(&temp_root).unwrap();
914        let workspace = Workspace::init(&temp_root).unwrap();
915
916        let atom = LoreAtom::new(
917            LoreKind::Decision,
918            AtomState::Proposed,
919            "Keep deterministic sync".to_string(),
920            None,
921            Some("sync".to_string()),
922            Some(PathBuf::from("src/git/mod.rs")),
923        );
924        let atom_id = atom.id.clone();
925        workspace.record_atom(atom).unwrap();
926        workspace
927            .transition_atom_state(
928                &atom_id,
929                AtomState::Accepted,
930                "accepted for release",
931                Some("unit-test".to_string()),
932            )
933            .unwrap();
934
935        let result = workspace
936            .edit_atom(
937                &atom_id,
938                AtomEditRequest {
939                    trace_commit_sha: Some(Some("abc123".to_string())),
940                    ..Default::default()
941                },
942                "close commit trace",
943                Some("unit-test".to_string()),
944            )
945            .unwrap();
946
947        assert_eq!(result.source_commit.as_deref(), Some("abc123"));
948        assert!(result
949            .changed_fields
950            .contains(&"trace.source_commit".to_string()));
951
952        let accepted = workspace.load_accepted_atoms().unwrap();
953        let record = accepted
954            .iter()
955            .find(|record| record.atom.id == atom_id)
956            .unwrap();
957        assert_eq!(record.source_commit.as_deref(), Some("abc123"));
958    }
959}