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}