1use std::time::UNIX_EPOCH;
18
19use serde::{Deserialize, Serialize};
20
21use crate::adapter::{Clock, Fs};
22use crate::error::SessionError;
23use crate::layout::StorePaths;
24use crate::manifest::{append_jsonl_record, read_jsonl_records};
25use crate::store::{get_object, put_object};
26
27#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum CandidateStatus {
33 Draft,
35 Selected,
37 Rejected,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct CandidateEntry {
49 pub id: String,
51 pub seq: u64,
53 pub page_id: String,
55 pub snapshot_hash: String,
57 pub status: CandidateStatus,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub workspace_role: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub promotion_target: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub cleanup_policy: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub notes: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub timestamp_ms: Option<u128>,
74}
75
76#[derive(Debug, Clone, Copy, Default)]
82pub struct CandidateMeta<'a> {
83 pub workspace_role: Option<&'a str>,
85 pub promotion_target: Option<&'a str>,
87 pub cleanup_policy: Option<&'a str>,
89 pub notes: Option<&'a str>,
91}
92
93#[derive(Debug, Clone, Copy)]
98pub struct NewCandidate<'a> {
99 pub page_id: &'a str,
101 pub snapshot: &'a [u8],
103 pub status: CandidateStatus,
105 pub meta: CandidateMeta<'a>,
107}
108
109pub fn put_scratch(
119 fs: &impl Fs,
120 paths: &StorePaths,
121 clock: &impl Clock,
122 doc_id: &str,
123 candidate: NewCandidate<'_>,
124) -> Result<CandidateEntry, SessionError> {
125 let snapshot_hash = put_object(fs, paths, doc_id, candidate.snapshot)?;
126 let seq = u64::try_from(list_scratch(fs, paths, doc_id)?.len())
127 .map_err(|_| SessionError::new("candidate count exceeds u64"))?;
128 let id = format!("cand{seq}");
129 let timestamp_ms = clock
130 .now()
131 .duration_since(UNIX_EPOCH)
132 .ok()
133 .map(|d| d.as_millis());
134 let entry = CandidateEntry {
135 id,
136 seq,
137 page_id: candidate.page_id.to_owned(),
138 snapshot_hash,
139 status: candidate.status,
140 workspace_role: candidate.meta.workspace_role.map(str::to_owned),
141 promotion_target: candidate.meta.promotion_target.map(str::to_owned),
142 cleanup_policy: candidate.meta.cleanup_policy.map(str::to_owned),
143 notes: candidate.meta.notes.map(str::to_owned),
144 timestamp_ms,
145 };
146 append_jsonl_record(fs, &paths.scratch_index(doc_id), &entry)?;
147 Ok(entry)
148}
149
150pub fn list_scratch(
158 fs: &impl Fs,
159 paths: &StorePaths,
160 doc_id: &str,
161) -> Result<Vec<CandidateEntry>, SessionError> {
162 let raw = read_jsonl_records(fs, &paths.scratch_index(doc_id))?;
163 Ok(dedup_last_write_wins(raw))
164}
165
166fn dedup_last_write_wins(records: Vec<CandidateEntry>) -> Vec<CandidateEntry> {
170 let mut order: Vec<String> = Vec::new();
171 let mut map: std::collections::BTreeMap<String, CandidateEntry> =
172 std::collections::BTreeMap::new();
173 for entry in records {
174 if !map.contains_key(&entry.id) {
175 order.push(entry.id.clone());
176 }
177 map.insert(entry.id.clone(), entry);
178 }
179 order.iter().filter_map(|id| map.get(id).cloned()).collect()
180}
181
182pub fn set_candidate_status(
189 fs: &impl Fs,
190 paths: &StorePaths,
191 clock: &impl Clock,
192 doc_id: &str,
193 cand_id: &str,
194 new_status: CandidateStatus,
195) -> Result<CandidateEntry, SessionError> {
196 let entries = list_scratch(fs, paths, doc_id)?;
197 let existing = entries
198 .iter()
199 .find(|e| e.id == cand_id)
200 .ok_or_else(|| SessionError::new(format!("candidate not found: {cand_id}")))?;
201 let timestamp_ms = clock
202 .now()
203 .duration_since(UNIX_EPOCH)
204 .ok()
205 .map(|d| d.as_millis());
206 let updated = CandidateEntry {
207 status: new_status,
208 timestamp_ms,
209 ..existing.clone()
210 };
211 append_jsonl_record(fs, &paths.scratch_index(doc_id), &updated)?;
212 Ok(updated)
213}
214
215#[derive(Debug, Clone, PartialEq, Serialize)]
219pub struct FinalizeReport {
220 pub deleted: Vec<String>,
223 pub kept: usize,
225}
226
227pub fn finalize_candidates(
242 fs: &impl Fs,
243 paths: &StorePaths,
244 doc_id: &str,
245) -> Result<FinalizeReport, SessionError> {
246 let resolved = list_scratch(fs, paths, doc_id)?;
247
248 let mut to_delete: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
249 for entry in &resolved {
250 match entry.status {
251 CandidateStatus::Rejected => {
252 if entry.cleanup_policy.as_deref() == Some("delete") {
253 to_delete.insert(entry.id.clone());
254 }
255 }
256 CandidateStatus::Draft | CandidateStatus::Selected => {}
257 }
258 }
259
260 if to_delete.is_empty() {
261 return Ok(FinalizeReport {
262 deleted: vec![],
263 kept: resolved.len(),
264 });
265 }
266
267 let raw = read_jsonl_records::<CandidateEntry>(fs, &paths.scratch_index(doc_id))?;
268 let kept_raw: Vec<&CandidateEntry> =
269 raw.iter().filter(|r| !to_delete.contains(&r.id)).collect();
270
271 let mut bytes: Vec<u8> = Vec::new();
272 for entry in &kept_raw {
273 let mut line = serde_json::to_vec(entry)
274 .map_err(|e| SessionError::new(format!("serialize candidate: {e}")))?;
275 line.push(b'\n');
276 bytes.extend_from_slice(&line);
277 }
278
279 let index_path = paths.scratch_index(doc_id);
280 if let Some(parent) = index_path.parent() {
281 fs.create_dir_all(parent)?;
282 }
283 fs.write(&index_path, &bytes)?;
284
285 let kept = resolved.len() - to_delete.len();
286 Ok(FinalizeReport {
287 deleted: to_delete.into_iter().collect(),
288 kept,
289 })
290}
291
292pub fn get_scratch_snapshot(
296 fs: &impl Fs,
297 paths: &StorePaths,
298 doc_id: &str,
299 entry: &CandidateEntry,
300) -> Result<Vec<u8>, SessionError> {
301 get_object(fs, paths, doc_id, &entry.snapshot_hash)
302}
303
304#[cfg(test)]
307mod tests {
308 use std::time::Duration;
309
310 use super::*;
311 use crate::adapter::{FakeClock, MemFs};
312 use crate::layout::StorePaths;
313
314 fn setup() -> (MemFs, StorePaths) {
315 (MemFs::new(), StorePaths::new("/data"))
316 }
317
318 fn clock() -> FakeClock {
319 FakeClock(UNIX_EPOCH + Duration::from_millis(100))
320 }
321
322 #[test]
323 fn put_then_list_scratch_roundtrip() {
324 let (fs, paths) = setup();
325 let clk = clock();
326
327 let meta_full = CandidateMeta {
328 workspace_role: Some("hero"),
329 promotion_target: Some("slot-a"),
330 cleanup_policy: Some("on_select"),
331 notes: Some("first pass"),
332 };
333 let e0 = put_scratch(
334 &fs,
335 &paths,
336 &clk,
337 "doc1",
338 NewCandidate {
339 page_id: "page-a",
340 snapshot: b"zen content A",
341 status: CandidateStatus::Draft,
342 meta: meta_full,
343 },
344 )
345 .unwrap();
346
347 let e1 = put_scratch(
348 &fs,
349 &paths,
350 &clk,
351 "doc1",
352 NewCandidate {
353 page_id: "page-b",
354 snapshot: b"zen content B",
355 status: CandidateStatus::Selected,
356 meta: CandidateMeta::default(),
357 },
358 )
359 .unwrap();
360
361 assert_eq!(e0.seq, 0);
362 assert_eq!(e0.id, "cand0");
363 assert_eq!(e0.page_id, "page-a");
364 assert_eq!(e0.status, CandidateStatus::Draft);
365 assert_eq!(e0.workspace_role, Some("hero".to_owned()));
366 assert_eq!(e0.promotion_target, Some("slot-a".to_owned()));
367 assert_eq!(e0.cleanup_policy, Some("on_select".to_owned()));
368 assert_eq!(e0.notes, Some("first pass".to_owned()));
369
370 assert_eq!(e1.seq, 1);
371 assert_eq!(e1.id, "cand1");
372 assert_eq!(e1.page_id, "page-b");
373 assert_eq!(e1.status, CandidateStatus::Selected);
374 assert_eq!(e1.workspace_role, None);
375
376 let entries = list_scratch(&fs, &paths, "doc1").unwrap();
377 assert_eq!(entries.len(), 2);
378 assert_eq!(entries[0], e0);
379 assert_eq!(entries[1], e1);
380 }
381
382 #[test]
383 fn snapshot_bytes_recovered_intact() {
384 let (fs, paths) = setup();
385 let clk = clock();
386 let zen_bytes = b"node layout { width 100 }";
387
388 let entry = put_scratch(
389 &fs,
390 &paths,
391 &clk,
392 "doc1",
393 NewCandidate {
394 page_id: "page-x",
395 snapshot: zen_bytes,
396 status: CandidateStatus::Draft,
397 meta: CandidateMeta::default(),
398 },
399 )
400 .unwrap();
401
402 let recovered = get_scratch_snapshot(&fs, &paths, "doc1", &entry).unwrap();
403 assert_eq!(recovered.as_slice(), zen_bytes.as_slice());
404 }
405
406 #[test]
407 fn lean_candidate_omits_optionals() {
408 let (fs, paths) = setup();
409 let clk = clock();
410
411 put_scratch(
412 &fs,
413 &paths,
414 &clk,
415 "doc1",
416 NewCandidate {
417 page_id: "page-lean",
418 snapshot: b"lean",
419 status: CandidateStatus::Draft,
420 meta: CandidateMeta::default(),
421 },
422 )
423 .unwrap();
424
425 let raw = fs.read(&paths.scratch_index("doc1")).unwrap();
426 let line = std::str::from_utf8(&raw).unwrap();
427
428 assert!(
429 !line.contains("workspace_role"),
430 "workspace_role must be absent in lean form"
431 );
432 assert!(
433 !line.contains("promotion_target"),
434 "promotion_target must be absent in lean form"
435 );
436 assert!(
437 !line.contains("cleanup_policy"),
438 "cleanup_policy must be absent in lean form"
439 );
440 assert!(
441 !line.contains("\"notes\""),
442 "notes must be absent in lean form"
443 );
444 }
445
446 #[test]
447 fn status_serializes_snake_case() {
448 let (fs, paths) = setup();
449 let clk = clock();
450
451 put_scratch(
452 &fs,
453 &paths,
454 &clk,
455 "doc1",
456 NewCandidate {
457 page_id: "page-sel",
458 snapshot: b"sel",
459 status: CandidateStatus::Selected,
460 meta: CandidateMeta::default(),
461 },
462 )
463 .unwrap();
464
465 let raw = fs.read(&paths.scratch_index("doc1")).unwrap();
466 let line = std::str::from_utf8(&raw).unwrap();
467 assert!(
468 line.contains("\"selected\""),
469 "Selected status must serialize as \"selected\""
470 );
471 }
472
473 #[test]
474 fn list_scratch_absent_is_empty() {
475 let (fs, paths) = setup();
476 let entries = list_scratch(&fs, &paths, "no-such-doc").unwrap();
477 assert!(entries.is_empty());
478 }
479
480 #[test]
481 fn set_status_draft_to_selected() {
482 let (fs, paths) = setup();
483 let clk = clock();
484
485 put_scratch(
486 &fs,
487 &paths,
488 &clk,
489 "doc1",
490 NewCandidate {
491 page_id: "page-a",
492 snapshot: b"zen A",
493 status: CandidateStatus::Draft,
494 meta: CandidateMeta::default(),
495 },
496 )
497 .unwrap();
498
499 let updated = set_candidate_status(
500 &fs,
501 &paths,
502 &clk,
503 "doc1",
504 "cand0",
505 CandidateStatus::Selected,
506 )
507 .unwrap();
508 assert_eq!(updated.status, CandidateStatus::Selected);
509 assert_eq!(updated.id, "cand0");
510
511 let entries = list_scratch(&fs, &paths, "doc1").unwrap();
512 assert_eq!(entries.len(), 1, "dedup must yield exactly one entry");
513 assert_eq!(entries[0].status, CandidateStatus::Selected);
514 }
515
516 #[test]
517 fn set_status_draft_to_rejected() {
518 let (fs, paths) = setup();
519 let clk = clock();
520
521 put_scratch(
522 &fs,
523 &paths,
524 &clk,
525 "doc1",
526 NewCandidate {
527 page_id: "page-b",
528 snapshot: b"zen B",
529 status: CandidateStatus::Draft,
530 meta: CandidateMeta::default(),
531 },
532 )
533 .unwrap();
534
535 set_candidate_status(
536 &fs,
537 &paths,
538 &clk,
539 "doc1",
540 "cand0",
541 CandidateStatus::Rejected,
542 )
543 .unwrap();
544
545 let entries = list_scratch(&fs, &paths, "doc1").unwrap();
546 assert_eq!(entries.len(), 1);
547 assert_eq!(entries[0].status, CandidateStatus::Rejected);
548 }
549
550 #[test]
551 fn list_scratch_dedup_keeps_latest_and_order() {
552 let (fs, paths) = setup();
553 let clk = clock();
554
555 put_scratch(
556 &fs,
557 &paths,
558 &clk,
559 "doc1",
560 NewCandidate {
561 page_id: "page-a",
562 snapshot: b"zen A",
563 status: CandidateStatus::Draft,
564 meta: CandidateMeta::default(),
565 },
566 )
567 .unwrap();
568 put_scratch(
569 &fs,
570 &paths,
571 &clk,
572 "doc1",
573 NewCandidate {
574 page_id: "page-b",
575 snapshot: b"zen B",
576 status: CandidateStatus::Draft,
577 meta: CandidateMeta::default(),
578 },
579 )
580 .unwrap();
581 set_candidate_status(
582 &fs,
583 &paths,
584 &clk,
585 "doc1",
586 "cand0",
587 CandidateStatus::Selected,
588 )
589 .unwrap();
590
591 let entries = list_scratch(&fs, &paths, "doc1").unwrap();
592 assert_eq!(entries.len(), 2, "must have exactly 2 distinct candidates");
593 assert_eq!(entries[0].id, "cand0");
594 assert_eq!(entries[0].status, CandidateStatus::Selected);
595 assert_eq!(entries[1].id, "cand1");
596 assert_eq!(entries[1].status, CandidateStatus::Draft);
597 }
598
599 #[test]
600 fn set_status_unknown_candidate_errors() {
601 let (fs, paths) = setup();
602 let clk = clock();
603
604 let result = set_candidate_status(
605 &fs,
606 &paths,
607 &clk,
608 "doc1",
609 "cand99",
610 CandidateStatus::Selected,
611 );
612 assert!(result.is_err());
613 let err = result.unwrap_err();
614 assert!(
615 err.message.contains("cand99"),
616 "error message should include the missing id"
617 );
618 }
619
620 #[test]
621 fn finalize_deletes_rejected_delete_policy() {
622 let (fs, paths) = setup();
623 let clk = clock();
624
625 let e0 = put_scratch(
627 &fs,
628 &paths,
629 &clk,
630 "doc1",
631 NewCandidate {
632 page_id: "page-a",
633 snapshot: b"zen A",
634 status: CandidateStatus::Rejected,
635 meta: CandidateMeta {
636 cleanup_policy: Some("delete"),
637 ..CandidateMeta::default()
638 },
639 },
640 )
641 .unwrap();
642
643 put_scratch(
645 &fs,
646 &paths,
647 &clk,
648 "doc1",
649 NewCandidate {
650 page_id: "page-b",
651 snapshot: b"zen B",
652 status: CandidateStatus::Draft,
653 meta: CandidateMeta::default(),
654 },
655 )
656 .unwrap();
657
658 put_scratch(
660 &fs,
661 &paths,
662 &clk,
663 "doc1",
664 NewCandidate {
665 page_id: "page-c",
666 snapshot: b"zen C",
667 status: CandidateStatus::Selected,
668 meta: CandidateMeta::default(),
669 },
670 )
671 .unwrap();
672
673 let report = finalize_candidates(&fs, &paths, "doc1").unwrap();
674 assert_eq!(report.deleted, vec![e0.id.clone()]);
675 assert_eq!(report.kept, 2);
676
677 let remaining = list_scratch(&fs, &paths, "doc1").unwrap();
678 assert_eq!(remaining.len(), 2);
679 assert!(
680 remaining.iter().all(|e| e.id != e0.id),
681 "deleted candidate must not appear in list"
682 );
683 }
684
685 #[test]
686 fn finalize_keeps_archived_and_selected() {
687 let (fs, paths) = setup();
688 let clk = clock();
689
690 put_scratch(
692 &fs,
693 &paths,
694 &clk,
695 "doc1",
696 NewCandidate {
697 page_id: "page-a",
698 snapshot: b"zen A",
699 status: CandidateStatus::Rejected,
700 meta: CandidateMeta {
701 cleanup_policy: Some("archive"),
702 ..CandidateMeta::default()
703 },
704 },
705 )
706 .unwrap();
707
708 put_scratch(
710 &fs,
711 &paths,
712 &clk,
713 "doc1",
714 NewCandidate {
715 page_id: "page-b",
716 snapshot: b"zen B",
717 status: CandidateStatus::Selected,
718 meta: CandidateMeta::default(),
719 },
720 )
721 .unwrap();
722
723 let report = finalize_candidates(&fs, &paths, "doc1").unwrap();
724 assert!(report.deleted.is_empty(), "nothing should be deleted");
725 assert_eq!(report.kept, 2);
726
727 let remaining = list_scratch(&fs, &paths, "doc1").unwrap();
728 assert_eq!(remaining.len(), 2);
729 }
730
731 #[test]
732 fn finalize_noop_when_nothing_to_delete() {
733 let (fs, paths) = setup();
734 let clk = clock();
735
736 put_scratch(
737 &fs,
738 &paths,
739 &clk,
740 "doc1",
741 NewCandidate {
742 page_id: "page-a",
743 snapshot: b"zen A",
744 status: CandidateStatus::Draft,
745 meta: CandidateMeta::default(),
746 },
747 )
748 .unwrap();
749 put_scratch(
750 &fs,
751 &paths,
752 &clk,
753 "doc1",
754 NewCandidate {
755 page_id: "page-b",
756 snapshot: b"zen B",
757 status: CandidateStatus::Draft,
758 meta: CandidateMeta::default(),
759 },
760 )
761 .unwrap();
762
763 let bytes_before = fs.read(&paths.scratch_index("doc1")).unwrap();
764 let report = finalize_candidates(&fs, &paths, "doc1").unwrap();
765 assert!(report.deleted.is_empty());
766 assert_eq!(report.kept, 2);
767 let bytes_after = fs.read(&paths.scratch_index("doc1")).unwrap();
769 assert_eq!(bytes_before, bytes_after, "file must be unchanged on noop");
770 }
771
772 #[test]
773 fn finalize_preserves_other_candidates_history() {
774 let (fs, paths) = setup();
775 let clk = clock();
776
777 put_scratch(
779 &fs,
780 &paths,
781 &clk,
782 "doc1",
783 NewCandidate {
784 page_id: "page-a",
785 snapshot: b"zen A",
786 status: CandidateStatus::Draft,
787 meta: CandidateMeta::default(),
788 },
789 )
790 .unwrap();
791 set_candidate_status(
792 &fs,
793 &paths,
794 &clk,
795 "doc1",
796 "cand0",
797 CandidateStatus::Selected,
798 )
799 .unwrap();
800
801 put_scratch(
803 &fs,
804 &paths,
805 &clk,
806 "doc1",
807 NewCandidate {
808 page_id: "page-b",
809 snapshot: b"zen B",
810 status: CandidateStatus::Rejected,
811 meta: CandidateMeta {
812 cleanup_policy: Some("delete"),
813 ..CandidateMeta::default()
814 },
815 },
816 )
817 .unwrap();
818
819 let report = finalize_candidates(&fs, &paths, "doc1").unwrap();
820 assert_eq!(report.deleted, vec!["cand1".to_owned()]);
821 assert_eq!(report.kept, 1);
822
823 let remaining = list_scratch(&fs, &paths, "doc1").unwrap();
824 assert_eq!(remaining.len(), 1);
825 assert_eq!(remaining[0].id, "cand0");
826 assert_eq!(
827 remaining[0].status,
828 CandidateStatus::Selected,
829 "cand0 history lines survived; resolved status must be Selected"
830 );
831 }
832
833 #[test]
834 fn put_after_status_change_gets_correct_next_seq() {
835 let (fs, paths) = setup();
836 let clk = clock();
837
838 put_scratch(
839 &fs,
840 &paths,
841 &clk,
842 "doc1",
843 NewCandidate {
844 page_id: "page-a",
845 snapshot: b"zen A",
846 status: CandidateStatus::Draft,
847 meta: CandidateMeta::default(),
848 },
849 )
850 .unwrap();
851 set_candidate_status(
852 &fs,
853 &paths,
854 &clk,
855 "doc1",
856 "cand0",
857 CandidateStatus::Selected,
858 )
859 .unwrap();
860
861 let e1 = put_scratch(
862 &fs,
863 &paths,
864 &clk,
865 "doc1",
866 NewCandidate {
867 page_id: "page-b",
868 snapshot: b"zen B",
869 status: CandidateStatus::Draft,
870 meta: CandidateMeta::default(),
871 },
872 )
873 .unwrap();
874
875 assert_eq!(e1.seq, 1, "seq must be 1 (one distinct candidate existed)");
876 assert_eq!(e1.id, "cand1");
877 }
878}