1use std::fmt;
28use std::fs;
29use std::io::ErrorKind;
30use std::path::{Path, PathBuf};
31use std::time::{Duration, SystemTime, UNIX_EPOCH};
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35
36use crate::LibrarianError;
37
38pub const DRAFT_SCHEMA_VERSION: u32 = 2;
40const PROCESSING_CLAIM_EXT: &str = "claim";
41
42#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Draft {
49 id: DraftId,
50 metadata: DraftMetadata,
51 submitted_at: SystemTime,
52 raw_text: String,
53}
54
55impl Draft {
56 #[must_use]
61 pub fn new(raw_text: String, source: &DraftSource, submitted_at: SystemTime) -> Self {
62 let metadata = DraftMetadata::from_source(source, submitted_at);
63 Self::with_metadata(raw_text, metadata)
64 }
65
66 #[must_use]
69 pub fn with_metadata(raw_text: String, metadata: DraftMetadata) -> Self {
70 let id = DraftId::from_raw_text_and_metadata(&raw_text, &metadata);
71 let submitted_at = metadata.submitted_at;
72 Self {
73 id,
74 metadata,
75 submitted_at,
76 raw_text,
77 }
78 }
79
80 #[must_use]
82 pub fn id(&self) -> DraftId {
83 self.id
84 }
85
86 #[must_use]
88 pub fn metadata(&self) -> &DraftMetadata {
89 &self.metadata
90 }
91
92 #[must_use]
94 pub fn submitted_at(&self) -> SystemTime {
95 self.submitted_at
96 }
97
98 #[must_use]
100 pub fn prose(&self) -> &str {
101 &self.raw_text
102 }
103
104 #[must_use]
107 pub fn raw_text(&self) -> &str {
108 &self.raw_text
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub struct DraftId([u8; 8]);
117
118impl DraftId {
119 #[must_use]
121 pub fn from_prose(prose: &str) -> Self {
122 let digest = Sha256::digest(prose.as_bytes());
123 let mut bytes = [0u8; 8];
124 bytes.copy_from_slice(&digest[..8]);
125 Self(bytes)
126 }
127
128 #[must_use]
130 pub fn from_raw_text_and_metadata(raw_text: &str, metadata: &DraftMetadata) -> Self {
131 let mut hasher = Sha256::new();
132 hasher.update(raw_text.as_bytes());
133 hasher.update([0]);
134 hasher.update(metadata.source_surface.as_str().as_bytes());
135 hasher.update([0]);
136 update_optional(&mut hasher, metadata.source_agent.as_deref());
137 update_optional(&mut hasher, metadata.source_project.as_deref());
138 update_optional(&mut hasher, metadata.operator.as_deref());
139 update_optional(&mut hasher, metadata.provenance_uri.as_deref());
140 let digest = hasher.finalize();
141 let mut bytes = [0u8; 8];
142 bytes.copy_from_slice(&digest[..8]);
143 Self(bytes)
144 }
145
146 #[must_use]
148 pub fn as_bytes(&self) -> [u8; 8] {
149 self.0
150 }
151
152 #[must_use]
154 pub fn to_hex(&self) -> String {
155 let mut out = String::with_capacity(16);
156 for byte in self.0 {
157 use std::fmt::Write as _;
158 write!(&mut out, "{byte:02x}").ok();
161 }
162 out
163 }
164}
165
166fn update_optional(hasher: &mut Sha256, value: Option<&str>) {
167 if let Some(v) = value {
168 hasher.update(v.as_bytes());
169 }
170 hasher.update([0]);
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct DraftMetadata {
176 pub source_surface: DraftSourceSurface,
178 pub source_agent: Option<String>,
180 pub source_project: Option<String>,
182 pub operator: Option<String>,
184 pub provenance_uri: Option<String>,
186 pub context_tags: Vec<String>,
188 pub submitted_at: SystemTime,
190}
191
192impl DraftMetadata {
193 #[must_use]
195 pub fn new(source_surface: DraftSourceSurface, submitted_at: SystemTime) -> Self {
196 Self {
197 source_surface,
198 source_agent: None,
199 source_project: None,
200 operator: None,
201 provenance_uri: None,
202 context_tags: Vec::new(),
203 submitted_at,
204 }
205 }
206
207 #[must_use]
209 pub fn from_source(source: &DraftSource, submitted_at: SystemTime) -> Self {
210 match source {
211 DraftSource::Directory { path } => {
212 let mut metadata = Self::new(DraftSourceSurface::Directory, submitted_at);
213 metadata.provenance_uri = Some(path_to_file_uri(path));
214 metadata
215 }
216 DraftSource::AutoMemorySweep { file } => {
217 let mut metadata = Self::new(DraftSourceSurface::ClaudeMemory, submitted_at);
218 metadata.source_agent = Some("claude".to_string());
219 metadata.provenance_uri = Some(path_to_file_uri(file));
220 metadata
221 }
222 DraftSource::CodexMemorySweep { file } => {
223 let mut metadata = Self::new(DraftSourceSurface::CodexMemory, submitted_at);
224 metadata.source_agent = Some("codex".to_string());
225 metadata.provenance_uri = Some(path_to_file_uri(file));
226 metadata
227 }
228 DraftSource::McpSubmit { workspace } => {
229 let mut metadata = Self::new(DraftSourceSurface::Mcp, submitted_at);
230 metadata.source_project = Some(workspace.clone());
231 metadata
232 }
233 DraftSource::RepoHandoff { file } => {
234 let mut metadata = Self::new(DraftSourceSurface::RepoHandoff, submitted_at);
235 metadata.provenance_uri = Some(path_to_file_uri(file));
236 metadata
237 }
238 DraftSource::CliSubmit => Self::new(DraftSourceSurface::Cli, submitted_at),
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum DraftSourceSurface {
247 Directory,
249 ClaudeMemory,
251 CodexMemory,
253 Mcp,
255 Cli,
257 RepoHandoff,
259 AgentExport,
261 ConsensusQuorum,
263 CopilotSessionStore,
265}
266
267impl DraftSourceSurface {
268 #[must_use]
270 pub fn parse(value: &str) -> Option<Self> {
271 match value {
272 "directory" => Some(Self::Directory),
273 "claude_memory" | "claude-memory" => Some(Self::ClaudeMemory),
274 "codex_memory" | "codex-memory" => Some(Self::CodexMemory),
275 "mcp" => Some(Self::Mcp),
276 "cli" => Some(Self::Cli),
277 "repo_handoff" | "repo-handoff" => Some(Self::RepoHandoff),
278 "agent_export" | "agent-export" => Some(Self::AgentExport),
279 "consensus_quorum" | "consensus-quorum" => Some(Self::ConsensusQuorum),
280 "copilot_session_store" | "copilot-session-store" => Some(Self::CopilotSessionStore),
281 _ => None,
282 }
283 }
284
285 #[must_use]
287 pub fn as_str(self) -> &'static str {
288 match self {
289 Self::Directory => "directory",
290 Self::ClaudeMemory => "claude_memory",
291 Self::CodexMemory => "codex_memory",
292 Self::Mcp => "mcp",
293 Self::Cli => "cli",
294 Self::RepoHandoff => "repo_handoff",
295 Self::AgentExport => "agent_export",
296 Self::ConsensusQuorum => "consensus_quorum",
297 Self::CopilotSessionStore => "copilot_session_store",
298 }
299 }
300}
301
302impl fmt::Display for DraftId {
303 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 f.write_str(&self.to_hex())
305 }
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
312#[non_exhaustive]
313pub enum DraftSource {
314 Directory {
316 path: PathBuf,
318 },
319 AutoMemorySweep {
321 file: PathBuf,
323 },
324 CodexMemorySweep {
326 file: PathBuf,
328 },
329 McpSubmit {
332 workspace: String,
334 },
335 RepoHandoff {
337 file: PathBuf,
339 },
340 CliSubmit,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
349pub enum DraftState {
350 Pending,
352 Processing,
354 Accepted,
356 Skipped,
358 Failed,
360 Quarantined,
362}
363
364impl DraftState {
365 #[must_use]
368 pub fn dir_name(self) -> &'static str {
369 match self {
370 Self::Pending => "pending",
371 Self::Processing => "processing",
372 Self::Accepted => "accepted",
373 Self::Skipped => "skipped",
374 Self::Failed => "failed",
375 Self::Quarantined => "quarantined",
376 }
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct DraftTransition {
383 pub id: DraftId,
385 pub from: DraftState,
387 pub to: DraftState,
389 pub source_path: PathBuf,
391 pub target_path: PathBuf,
393}
394
395#[derive(Debug, Clone)]
397pub struct DraftStore {
398 root: PathBuf,
399}
400
401impl DraftStore {
402 #[must_use]
404 pub fn new(root: impl AsRef<Path>) -> Self {
405 Self {
406 root: root.as_ref().to_path_buf(),
407 }
408 }
409
410 #[must_use]
412 pub fn root(&self) -> &Path {
413 &self.root
414 }
415
416 #[must_use]
418 pub fn state_dir(&self, state: DraftState) -> PathBuf {
419 self.root.join(state.dir_name())
420 }
421
422 #[must_use]
424 pub fn path_for(&self, state: DraftState, id: DraftId) -> PathBuf {
425 self.state_dir(state).join(format!("{id}.json"))
426 }
427
428 pub fn ensure_dirs(&self) -> Result<(), LibrarianError> {
435 for state in DraftState::ALL {
436 fs::create_dir_all(self.state_dir(state))?;
437 }
438 Ok(())
439 }
440
441 pub fn submit(&self, draft: &Draft) -> Result<PathBuf, LibrarianError> {
452 self.ensure_dirs()?;
453 let target = self.path_for(DraftState::Pending, draft.id());
454 if target.exists() {
455 return Ok(target);
456 }
457
458 let tmp = target.with_file_name(format!(".{id}.json.tmp", id = draft.id()));
459 let bytes = serde_json::to_vec_pretty(&DraftFileV2::from_draft(draft))?;
460 fs::write(&tmp, bytes)?;
461 if target.exists() {
462 return Ok(target);
463 }
464 fs::rename(&tmp, &target)?;
465 Ok(target)
466 }
467
468 pub fn load(&self, state: DraftState, id: DraftId) -> Result<Draft, LibrarianError> {
478 Self::load_path(&self.path_for(state, id))
479 }
480
481 pub fn list(&self, state: DraftState) -> Result<Vec<Draft>, LibrarianError> {
489 self.ensure_dirs()?;
490 let mut paths = Vec::new();
491 for entry in fs::read_dir(self.state_dir(state))? {
492 let path = entry?.path();
493 if path.extension().and_then(|s| s.to_str()) == Some("json") {
494 paths.push(path);
495 }
496 }
497 paths.sort();
498
499 let mut drafts = Vec::with_capacity(paths.len());
500 for path in paths {
501 drafts.push(Self::load_path(&path)?);
502 }
503 Ok(drafts)
504 }
505
506 pub fn transition(
527 &self,
528 id: DraftId,
529 from: DraftState,
530 to: DraftState,
531 ) -> Result<DraftTransition, LibrarianError> {
532 self.ensure_dirs()?;
533 if !is_valid_transition(from, to) {
534 return Err(LibrarianError::InvalidDraftTransition { from, to });
535 }
536
537 let source_path = self.path_for(from, id);
538 if !source_path.exists() {
539 return Err(LibrarianError::DraftNotFound { state: from, id });
540 }
541
542 let draft = Self::load_path(&source_path)?;
543 if draft.id() != id {
544 return Err(LibrarianError::DraftIdMismatch {
545 declared: id.to_hex(),
546 computed: draft.id().to_hex(),
547 });
548 }
549
550 let target_path = self.path_for(to, id);
551 if target_path.exists() {
552 return Err(LibrarianError::DraftAlreadyExists { state: to, id });
553 }
554
555 if to == DraftState::Processing {
556 self.write_processing_claim_marker(id)?;
557 }
558
559 if let Err(err) = fs::rename(&source_path, &target_path) {
560 if to == DraftState::Processing {
561 self.remove_processing_claim_marker(id)?;
562 }
563 return Err(err.into());
564 }
565
566 if from == DraftState::Processing {
567 self.remove_processing_claim_marker(id)?;
568 }
569 Ok(DraftTransition {
570 id,
571 from,
572 to,
573 source_path,
574 target_path,
575 })
576 }
577
578 pub fn recover_stale_processing(
591 &self,
592 stale_before: SystemTime,
593 ) -> Result<Vec<DraftTransition>, LibrarianError> {
594 self.ensure_dirs()?;
595 let mut stale = Vec::new();
596 for entry in fs::read_dir(self.state_dir(DraftState::Processing))? {
597 let path = entry?.path();
598 if path.extension().and_then(|s| s.to_str()) != Some("json") {
599 continue;
600 }
601 let draft = Self::load_path(&path)?;
602 let modified = self.processing_claim_modified_at(draft.id(), &path)?;
603 if modified <= stale_before {
604 stale.push((path, draft.id()));
605 }
606 }
607 stale.sort_by(|left, right| left.0.cmp(&right.0));
608
609 let mut recovered = Vec::with_capacity(stale.len());
610 for (_, id) in stale {
611 recovered.push(self.transition(id, DraftState::Processing, DraftState::Pending)?);
612 }
613 Ok(recovered)
614 }
615
616 fn load_path(path: &Path) -> Result<Draft, LibrarianError> {
617 let text = fs::read_to_string(path)?;
618 let file: DraftFileV2 = serde_json::from_str(&text)?;
619 file.into_draft()
620 }
621
622 fn processing_claim_path(&self, id: DraftId) -> PathBuf {
623 self.state_dir(DraftState::Processing).join(format!(
624 "{}.{}",
625 id.to_hex(),
626 PROCESSING_CLAIM_EXT
627 ))
628 }
629
630 fn write_processing_claim_marker(&self, id: DraftId) -> Result<(), LibrarianError> {
631 fs::write(self.processing_claim_path(id), b"claimed\n")?;
632 Ok(())
633 }
634
635 fn remove_processing_claim_marker(&self, id: DraftId) -> Result<(), LibrarianError> {
636 match fs::remove_file(self.processing_claim_path(id)) {
637 Ok(()) => Ok(()),
638 Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
639 Err(err) => Err(err.into()),
640 }
641 }
642
643 fn processing_claim_modified_at(
644 &self,
645 id: DraftId,
646 draft_path: &Path,
647 ) -> Result<SystemTime, LibrarianError> {
648 match fs::metadata(self.processing_claim_path(id)) {
649 Ok(metadata) => Ok(metadata.modified()?),
650 Err(err) if err.kind() == ErrorKind::NotFound => {
651 Ok(fs::metadata(draft_path)?.modified()?)
652 }
653 Err(err) => Err(err.into()),
654 }
655 }
656}
657
658fn is_valid_transition(from: DraftState, to: DraftState) -> bool {
659 matches!(
660 (from, to),
661 (DraftState::Pending, DraftState::Processing)
662 | (
663 DraftState::Processing,
664 DraftState::Pending
665 | DraftState::Accepted
666 | DraftState::Skipped
667 | DraftState::Failed
668 | DraftState::Quarantined,
669 )
670 )
671}
672
673impl DraftState {
674 pub const ALL: [Self; 6] = [
676 Self::Pending,
677 Self::Processing,
678 Self::Accepted,
679 Self::Skipped,
680 Self::Failed,
681 Self::Quarantined,
682 ];
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize)]
686struct DraftFileV2 {
687 schema_version: u32,
688 id: String,
689 source_surface: DraftSourceSurface,
690 source_agent: Option<String>,
691 source_project: Option<String>,
692 operator: Option<String>,
693 provenance_uri: Option<String>,
694 context_tags: Vec<String>,
695 submitted_at_unix_ms: u64,
696 raw_text: String,
697}
698
699impl DraftFileV2 {
700 fn from_draft(draft: &Draft) -> Self {
701 let metadata = draft.metadata();
702 Self {
703 schema_version: DRAFT_SCHEMA_VERSION,
704 id: draft.id().to_hex(),
705 source_surface: metadata.source_surface,
706 source_agent: metadata.source_agent.clone(),
707 source_project: metadata.source_project.clone(),
708 operator: metadata.operator.clone(),
709 provenance_uri: metadata.provenance_uri.clone(),
710 context_tags: metadata.context_tags.clone(),
711 submitted_at_unix_ms: system_time_to_unix_ms(metadata.submitted_at),
712 raw_text: draft.raw_text().to_string(),
713 }
714 }
715
716 fn into_draft(self) -> Result<Draft, LibrarianError> {
717 if self.schema_version != DRAFT_SCHEMA_VERSION {
718 return Err(LibrarianError::UnsupportedDraftSchema {
719 version: self.schema_version,
720 });
721 }
722 let metadata = DraftMetadata {
723 source_surface: self.source_surface,
724 source_agent: self.source_agent,
725 source_project: self.source_project,
726 operator: self.operator,
727 provenance_uri: self.provenance_uri,
728 context_tags: self.context_tags,
729 submitted_at: unix_ms_to_system_time(self.submitted_at_unix_ms),
730 };
731 let draft = Draft::with_metadata(self.raw_text, metadata);
732 let computed = draft.id().to_hex();
733 if self.id != computed {
734 return Err(LibrarianError::DraftIdMismatch {
735 declared: self.id,
736 computed,
737 });
738 }
739 Ok(draft)
740 }
741}
742
743fn system_time_to_unix_ms(time: SystemTime) -> u64 {
744 match time.duration_since(UNIX_EPOCH) {
745 Ok(duration) => u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
746 Err(_) => 0,
747 }
748}
749
750fn unix_ms_to_system_time(ms: u64) -> SystemTime {
751 UNIX_EPOCH + Duration::from_millis(ms)
752}
753
754fn path_to_file_uri(path: &Path) -> String {
755 format!("file://{}", path.display())
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761
762 #[test]
763 fn draft_id_is_content_addressed() {
764 let id_a = DraftId::from_prose("hello world");
765 let id_b = DraftId::from_prose("hello world");
766 let id_c = DraftId::from_prose("hello world!");
767 assert_eq!(id_a, id_b, "identical prose must produce identical IDs");
768 assert_ne!(id_a, id_c, "different prose must produce different IDs");
769 }
770
771 #[test]
772 fn draft_id_hex_is_16_chars() {
773 let id = DraftId::from_prose("anything");
774 let hex = id.to_hex();
775 assert_eq!(hex.len(), 16);
776 assert!(hex
777 .chars()
778 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
779 }
780
781 #[test]
782 fn draft_id_display_matches_hex() {
783 let id = DraftId::from_prose("anything");
784 assert_eq!(format!("{id}"), id.to_hex());
785 }
786
787 #[test]
788 fn draft_state_dir_names_are_distinct() {
789 let names: std::collections::HashSet<_> =
790 DraftState::ALL.iter().map(|s| s.dir_name()).collect();
791 assert_eq!(
792 names.len(),
793 DraftState::ALL.len(),
794 "every state must have a distinct dir name"
795 );
796 }
797
798 #[test]
799 fn draft_constructor_derives_id_from_text_and_metadata() {
800 let prose = "Alain is the owner of Mimir.";
801 let draft = Draft::new(
802 prose.to_string(),
803 &DraftSource::CliSubmit,
804 SystemTime::UNIX_EPOCH,
805 );
806 assert_eq!(
807 draft.id(),
808 DraftId::from_raw_text_and_metadata(prose, draft.metadata())
809 );
810 assert_eq!(draft.prose(), prose);
811 assert_eq!(draft.metadata().source_surface, DraftSourceSurface::Cli);
812 }
813
814 #[test]
815 fn draft_metadata_carries_scope_model_fields() {
816 let mut metadata =
817 DraftMetadata::new(DraftSourceSurface::CodexMemory, SystemTime::UNIX_EPOCH);
818 metadata.source_agent = Some("codex".to_string());
819 metadata.source_project = Some("buildepicshit/Mimir".to_string());
820 metadata.operator = Some("AlainDor".to_string());
821 metadata.provenance_uri =
822 Some("file:///home/hasnobeef/.codex/memories/mimir.md".to_string());
823 metadata.context_tags = vec!["mimir".to_string(), "scope-model".to_string()];
824
825 let draft = Draft::with_metadata(
826 "remember the governed scope invariant".to_string(),
827 metadata,
828 );
829
830 assert_eq!(
831 draft.metadata().source_surface,
832 DraftSourceSurface::CodexMemory
833 );
834 assert_eq!(draft.metadata().source_agent.as_deref(), Some("codex"));
835 assert_eq!(
836 draft.metadata().source_project.as_deref(),
837 Some("buildepicshit/Mimir")
838 );
839 assert_eq!(draft.metadata().operator.as_deref(), Some("AlainDor"));
840 assert_eq!(draft.metadata().context_tags.len(), 2);
841 }
842
843 #[test]
844 fn draft_id_distinguishes_same_text_from_different_provenance() {
845 let raw = "Use governed promotion for ecosystem memory.";
846 let mut claude =
847 DraftMetadata::new(DraftSourceSurface::ClaudeMemory, SystemTime::UNIX_EPOCH);
848 claude.provenance_uri =
849 Some("file:///home/hasnobeef/.claude/projects/mimir/memory/a.md".into());
850 let mut codex = DraftMetadata::new(DraftSourceSurface::CodexMemory, SystemTime::UNIX_EPOCH);
851 codex.provenance_uri = Some("file:///home/hasnobeef/.codex/memories/mimir.md".into());
852
853 let claude_draft = Draft::with_metadata(raw.to_string(), claude);
854 let codex_draft = Draft::with_metadata(raw.to_string(), codex);
855
856 assert_ne!(
857 claude_draft.id(),
858 codex_draft.id(),
859 "same text from different provenance must not collapse into one draft"
860 );
861 }
862
863 #[test]
864 fn draft_state_dir_names_cover_scope_model_lifecycle() {
865 let states = [
866 DraftState::Pending,
867 DraftState::Processing,
868 DraftState::Accepted,
869 DraftState::Skipped,
870 DraftState::Failed,
871 DraftState::Quarantined,
872 ];
873 let names: std::collections::HashSet<_> = states.iter().map(|s| s.dir_name()).collect();
874 assert_eq!(names.len(), states.len());
875 assert_eq!(DraftState::Accepted.dir_name(), "accepted");
876 assert_eq!(DraftState::Quarantined.dir_name(), "quarantined");
877 }
878
879 #[test]
880 fn draft_store_submits_v2_json_to_pending() -> Result<(), Box<dyn std::error::Error>> {
881 let tmp = tempfile::tempdir()?;
882 let store = DraftStore::new(tmp.path());
883 let mut metadata = DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH);
884 metadata.operator = Some("AlainDor".to_string());
885 metadata.provenance_uri = Some("cli://mimir-librarian/submit".to_string());
886 let draft =
887 Draft::with_metadata("Mimir should govern memory scopes.".to_string(), metadata);
888
889 let path = store.submit(&draft)?;
890 assert_eq!(path, store.path_for(DraftState::Pending, draft.id()));
891 assert!(path.exists());
892
893 let saved = std::fs::read_to_string(&path)?;
894 assert!(saved.contains("\"schema_version\": 2"));
895 assert!(saved.contains("\"source_surface\": \"cli\""));
896 assert!(saved.contains("\"operator\": \"AlainDor\""));
897
898 let loaded = store.load(DraftState::Pending, draft.id())?;
899 assert_eq!(loaded.id(), draft.id());
900 assert_eq!(loaded.raw_text(), draft.raw_text());
901 assert_eq!(loaded.metadata().source_surface, DraftSourceSurface::Cli);
902 Ok(())
903 }
904
905 #[test]
906 fn draft_store_submit_is_idempotent() -> Result<(), Box<dyn std::error::Error>> {
907 let tmp = tempfile::tempdir()?;
908 let store = DraftStore::new(tmp.path());
909 let draft = Draft::with_metadata(
910 "Repeated sweeps should not duplicate a draft.".to_string(),
911 DraftMetadata::new(DraftSourceSurface::ClaudeMemory, SystemTime::UNIX_EPOCH),
912 );
913
914 let first = store.submit(&draft)?;
915 let second = store.submit(&draft)?;
916
917 assert_eq!(first, second);
918 assert_eq!(store.list(DraftState::Pending)?.len(), 1);
919 Ok(())
920 }
921
922 #[test]
923 fn draft_store_moves_pending_to_processing_then_terminal(
924 ) -> Result<(), Box<dyn std::error::Error>> {
925 let tmp = tempfile::tempdir()?;
926 let store = DraftStore::new(tmp.path());
927 let draft = Draft::with_metadata(
928 "Draft lifecycle movement should be atomic and visible.".to_string(),
929 DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH),
930 );
931 store.submit(&draft)?;
932
933 let claimed = store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
934 assert_eq!(claimed.id, draft.id());
935 assert_eq!(claimed.from, DraftState::Pending);
936 assert_eq!(claimed.to, DraftState::Processing);
937 assert!(!store.path_for(DraftState::Pending, draft.id()).exists());
938 assert!(store.path_for(DraftState::Processing, draft.id()).exists());
939 assert!(store.processing_claim_path(draft.id()).exists());
940 assert_eq!(store.list(DraftState::Pending)?.len(), 0);
941 assert_eq!(store.list(DraftState::Processing)?.len(), 1);
942
943 let accepted =
944 store.transition(draft.id(), DraftState::Processing, DraftState::Accepted)?;
945 assert_eq!(accepted.to, DraftState::Accepted);
946 assert!(!store.path_for(DraftState::Processing, draft.id()).exists());
947 assert!(store.path_for(DraftState::Accepted, draft.id()).exists());
948 assert!(!store.processing_claim_path(draft.id()).exists());
949 let loaded = store.load(DraftState::Accepted, draft.id())?;
950 assert_eq!(loaded.raw_text(), draft.raw_text());
951 assert_eq!(store.list(DraftState::Processing)?.len(), 0);
952 assert_eq!(store.list(DraftState::Accepted)?.len(), 1);
953 Ok(())
954 }
955
956 #[test]
957 fn draft_store_rejects_invalid_lifecycle_transition() -> Result<(), Box<dyn std::error::Error>>
958 {
959 let tmp = tempfile::tempdir()?;
960 let store = DraftStore::new(tmp.path());
961 let draft = Draft::with_metadata(
962 "Terminal states should only be reached from processing.".to_string(),
963 DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH),
964 );
965 store.submit(&draft)?;
966
967 let err = match store.transition(draft.id(), DraftState::Pending, DraftState::Accepted) {
968 Err(err) => err,
969 Ok(transition) => {
970 return Err(
971 format!("pending -> accepted must be rejected, got {transition:?}").into(),
972 );
973 }
974 };
975 assert!(matches!(
976 err,
977 LibrarianError::InvalidDraftTransition {
978 from: DraftState::Pending,
979 to: DraftState::Accepted
980 }
981 ));
982 assert!(store.path_for(DraftState::Pending, draft.id()).exists());
983 assert!(!store.path_for(DraftState::Accepted, draft.id()).exists());
984 Ok(())
985 }
986
987 #[test]
988 fn draft_store_allows_every_processing_terminal_state() -> Result<(), Box<dyn std::error::Error>>
989 {
990 let tmp = tempfile::tempdir()?;
991 let store = DraftStore::new(tmp.path());
992 for terminal in [
993 DraftState::Accepted,
994 DraftState::Skipped,
995 DraftState::Failed,
996 DraftState::Quarantined,
997 ] {
998 let draft = Draft::with_metadata(
999 format!("Draft should finish as {}.", terminal.dir_name()),
1000 DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH),
1001 );
1002 store.submit(&draft)?;
1003 store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
1004
1005 let finished = store.transition(draft.id(), DraftState::Processing, terminal)?;
1006 assert_eq!(finished.to, terminal);
1007 assert!(store.path_for(terminal, draft.id()).exists());
1008 assert_eq!(store.load(terminal, draft.id())?.id(), draft.id());
1009 }
1010 assert_eq!(store.list(DraftState::Processing)?.len(), 0);
1011 Ok(())
1012 }
1013
1014 #[test]
1015 fn draft_store_rejects_transition_when_target_exists() -> Result<(), Box<dyn std::error::Error>>
1016 {
1017 let tmp = tempfile::tempdir()?;
1018 let store = DraftStore::new(tmp.path());
1019 let draft = Draft::with_metadata(
1020 "Draft transition should never overwrite a target file.".to_string(),
1021 DraftMetadata::new(DraftSourceSurface::Cli, SystemTime::UNIX_EPOCH),
1022 );
1023 store.submit(&draft)?;
1024 store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
1025 std::fs::copy(
1026 store.path_for(DraftState::Processing, draft.id()),
1027 store.path_for(DraftState::Accepted, draft.id()),
1028 )?;
1029
1030 let err = match store.transition(draft.id(), DraftState::Processing, DraftState::Accepted) {
1031 Err(err) => err,
1032 Ok(transition) => {
1033 return Err(format!(
1034 "transition must not overwrite an existing terminal draft, got {transition:?}"
1035 )
1036 .into());
1037 }
1038 };
1039 assert!(matches!(
1040 err,
1041 LibrarianError::DraftAlreadyExists {
1042 state: DraftState::Accepted,
1043 id: existing
1044 } if existing == draft.id()
1045 ));
1046 assert!(store.path_for(DraftState::Processing, draft.id()).exists());
1047 Ok(())
1048 }
1049
1050 #[test]
1051 fn draft_store_reports_missing_transition_source() -> Result<(), Box<dyn std::error::Error>> {
1052 let tmp = tempfile::tempdir()?;
1053 let store = DraftStore::new(tmp.path());
1054 let id = DraftId::from_prose("missing");
1055
1056 let err = match store.transition(id, DraftState::Pending, DraftState::Processing) {
1057 Err(err) => err,
1058 Ok(transition) => {
1059 return Err(format!(
1060 "missing pending draft should be an explicit error, got {transition:?}"
1061 )
1062 .into());
1063 }
1064 };
1065 assert!(matches!(
1066 err,
1067 LibrarianError::DraftNotFound {
1068 state: DraftState::Pending,
1069 id: missing
1070 } if missing == id
1071 ));
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn draft_store_recovers_stale_processing_back_to_pending(
1077 ) -> Result<(), Box<dyn std::error::Error>> {
1078 let tmp = tempfile::tempdir()?;
1079 let store = DraftStore::new(tmp.path());
1080 let draft = Draft::with_metadata(
1081 "Crash recovery should return stale processing drafts to pending.".to_string(),
1082 DraftMetadata::new(DraftSourceSurface::CodexMemory, SystemTime::UNIX_EPOCH),
1083 );
1084 store.submit(&draft)?;
1085 store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
1086
1087 let cutoff = SystemTime::now() + Duration::from_secs(60);
1088 let recovered = store.recover_stale_processing(cutoff)?;
1089 assert_eq!(recovered.len(), 1);
1090 assert_eq!(recovered[0].id, draft.id());
1091 assert_eq!(recovered[0].from, DraftState::Processing);
1092 assert_eq!(recovered[0].to, DraftState::Pending);
1093 assert_eq!(store.list(DraftState::Processing)?.len(), 0);
1094 assert_eq!(store.list(DraftState::Pending)?.len(), 1);
1095
1096 let recovered_again = store.recover_stale_processing(cutoff)?;
1097 assert!(
1098 recovered_again.is_empty(),
1099 "recovery should be idempotent once no drafts remain in processing"
1100 );
1101 Ok(())
1102 }
1103
1104 #[test]
1105 fn draft_store_keeps_fresh_processing_drafts_in_place() -> Result<(), Box<dyn std::error::Error>>
1106 {
1107 let tmp = tempfile::tempdir()?;
1108 let store = DraftStore::new(tmp.path());
1109 let draft = Draft::with_metadata(
1110 "Fresh in-flight work should not be recovered early.".to_string(),
1111 DraftMetadata::new(DraftSourceSurface::ClaudeMemory, SystemTime::UNIX_EPOCH),
1112 );
1113 store.submit(&draft)?;
1114 store.transition(draft.id(), DraftState::Pending, DraftState::Processing)?;
1115
1116 let recovered = store.recover_stale_processing(SystemTime::UNIX_EPOCH)?;
1117 assert!(recovered.is_empty());
1118 assert_eq!(store.list(DraftState::Processing)?.len(), 1);
1119 assert_eq!(store.list(DraftState::Pending)?.len(), 0);
1120 Ok(())
1121 }
1122
1123 #[test]
1124 fn source_surface_parse_accepts_cli_spellings() {
1125 assert_eq!(
1126 DraftSourceSurface::parse("codex-memory"),
1127 Some(DraftSourceSurface::CodexMemory)
1128 );
1129 assert_eq!(
1130 DraftSourceSurface::parse("codex_memory"),
1131 Some(DraftSourceSurface::CodexMemory)
1132 );
1133 assert_eq!(
1134 DraftSourceSurface::parse("consensus-quorum"),
1135 Some(DraftSourceSurface::ConsensusQuorum)
1136 );
1137 assert_eq!(
1138 DraftSourceSurface::parse("consensus_quorum"),
1139 Some(DraftSourceSurface::ConsensusQuorum)
1140 );
1141 assert_eq!(DraftSourceSurface::parse("unknown"), None);
1142 }
1143}