1use std::{
5 collections::HashSet,
6 path::{Path, PathBuf},
7};
8
9use chrono::{DateTime, Utc};
10use objects::{
11 lock::RepoLock,
12 object::StateId,
13 store::{HeddleError, ObjectStore, Result},
14};
15
16use crate::{
17 thread_model::{
18 EphemeralMarker, ThreadConfidenceSummary, ThreadFreshness, ThreadImpactCategory,
19 ThreadIntegrationPolicy, ThreadMode, ThreadRecord, ThreadRuntimeOverlay, ThreadState,
20 ThreadVerificationSummary, ThreadView,
21 },
22 thread_record_store::FilesystemThreadRecordStore,
23};
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct Thread {
27 pub id: String,
28 pub thread: String,
29 #[serde(default)]
30 pub target_thread: Option<String>,
31 #[serde(default)]
32 pub parent_thread: Option<String>,
33 pub mode: ThreadMode,
34 pub state: ThreadState,
35 pub base_state: String,
36 pub base_root: String,
37 #[serde(default)]
38 pub current_state: Option<String>,
39 #[serde(default)]
40 pub merged_state: Option<String>,
41 #[serde(default)]
42 pub task: Option<String>,
43 #[serde(default)]
44 pub execution_path: PathBuf,
45 #[serde(default)]
46 pub materialized_path: Option<PathBuf>,
47 #[serde(default)]
48 pub changed_paths: Vec<String>,
49 #[serde(default)]
50 pub impact_categories: Vec<ThreadImpactCategory>,
51 #[serde(default)]
52 pub heavy_impact_paths: Vec<String>,
53 #[serde(default)]
54 pub promotion_suggested: bool,
55 #[serde(default = "default_freshness")]
56 pub freshness: ThreadFreshness,
57 #[serde(default)]
58 pub verification_summary: ThreadVerificationSummary,
59 #[serde(default)]
60 pub confidence_summary: ThreadConfidenceSummary,
61 #[serde(default)]
62 pub integration_policy_result: ThreadIntegrationPolicy,
63 pub created_at: DateTime<Utc>,
64 pub updated_at: DateTime<Utc>,
65 #[serde(default)]
68 pub ephemeral: Option<EphemeralMarker>,
69 #[serde(default)]
72 pub auto: bool,
73 #[serde(default)]
78 pub shared_target_dir: Option<PathBuf>,
79}
80
81#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
82struct ThreadWorkspaceState {
83 #[serde(default)]
84 execution_path: PathBuf,
85 #[serde(default)]
86 materialized_path: Option<PathBuf>,
87}
88
89impl Thread {
90 pub fn to_record(&self) -> ThreadRecord {
91 ThreadRecord {
92 id: self.id.clone(),
93 thread: self.thread.clone(),
94 target_thread: self.target_thread.clone(),
95 parent_thread: self.parent_thread.clone(),
96 mode: self.mode.clone(),
97 state: self.state.clone(),
98 base_state: self.base_state.clone(),
99 base_root: self.base_root.clone(),
100 current_state: self.current_state.clone(),
101 merged_state: self.merged_state.clone(),
102 task: self.task.clone(),
103 changed_paths: self.changed_paths.clone(),
104 impact_categories: self.impact_categories.clone(),
105 heavy_impact_paths: self.heavy_impact_paths.clone(),
106 promotion_suggested: self.promotion_suggested,
107 freshness: self.freshness.clone(),
108 verification_summary: self.verification_summary.clone(),
109 confidence_summary: self.confidence_summary.clone(),
110 integration_policy_result: self.integration_policy_result.clone(),
111 created_at: self.created_at,
112 updated_at: self.updated_at,
113 ephemeral: self.ephemeral.clone(),
114 auto: self.auto,
115 shared_target_dir: self.shared_target_dir.clone(),
116 }
117 }
118
119 pub fn from_record(record: ThreadRecord) -> Self {
120 Self {
121 id: record.id.clone(),
122 thread: record.thread,
123 target_thread: record.target_thread,
124 parent_thread: record.parent_thread,
125 mode: record.mode,
126 state: record.state,
127 base_state: record.base_state,
128 base_root: record.base_root,
129 current_state: record.current_state,
130 merged_state: record.merged_state,
131 task: record.task,
132 execution_path: PathBuf::new(),
133 materialized_path: None,
134 changed_paths: record.changed_paths,
135 impact_categories: record.impact_categories,
136 heavy_impact_paths: record.heavy_impact_paths,
137 promotion_suggested: record.promotion_suggested,
138 freshness: record.freshness,
139 verification_summary: record.verification_summary,
140 confidence_summary: record.confidence_summary,
141 integration_policy_result: record.integration_policy_result,
142 created_at: record.created_at,
143 updated_at: record.updated_at,
144 ephemeral: record.ephemeral,
145 auto: record.auto,
146 shared_target_dir: record.shared_target_dir,
147 }
148 }
149
150 pub fn to_view(&self, runtime: ThreadRuntimeOverlay, is_current: bool) -> ThreadView {
151 ThreadView::from_record(self.to_record(), runtime, is_current)
152 }
153
154 fn workspace_state(&self) -> ThreadWorkspaceState {
155 ThreadWorkspaceState {
156 execution_path: self.execution_path.clone(),
157 materialized_path: self.materialized_path.clone(),
158 }
159 }
160}
161
162impl ThreadWorkspaceState {
163 fn apply_to_thread(&self, thread: &mut Thread) {
164 thread.execution_path = self.execution_path.clone();
165 thread.materialized_path = self.materialized_path.clone();
166 }
167}
168
169pub type SyncedThreadMetadata = ThreadRecord;
170
171impl SyncedThreadMetadata {
172 pub fn from_record(
173 repo: &crate::Repository,
174 record: &ThreadRecord,
175 current_state_override: Option<StateId>,
176 ) -> Result<Self> {
177 let mut record = record.clone();
178 let resolve_full = |spec: &str| -> Result<String> {
179 Ok(repo
180 .resolve_state(spec)?
181 .map(|id| id.to_string_full())
182 .unwrap_or_else(|| spec.to_string()))
183 };
184 record.base_state = resolve_full(&record.base_state)?;
185 record.current_state = match current_state_override {
186 Some(id) => Some(id.to_string_full()),
187 None => record
188 .current_state
189 .as_deref()
190 .map(resolve_full)
191 .transpose()?,
192 };
193 record.merged_state = record
194 .merged_state
195 .as_deref()
196 .map(resolve_full)
197 .transpose()?;
198 record.base_root = repo
199 .resolve_state(&record.base_state)?
200 .and_then(|id| repo.store().get_state(&id).ok().flatten())
201 .map(|state| state.tree.to_hex())
202 .unwrap_or_else(|| record.base_root.clone());
203 Ok(record)
204 }
205
206 pub fn from_thread(
207 repo: &crate::Repository,
208 thread: &Thread,
209 current_state_override: Option<StateId>,
210 ) -> Result<Self> {
211 Self::from_record(repo, &thread.to_record(), current_state_override)
212 }
213
214 pub fn current_state_id(&self, repo: &crate::Repository) -> Result<Option<StateId>> {
215 match self.current_state.as_deref() {
216 Some(state) => Ok(repo.resolve_state(state)?),
217 None => Ok(None),
218 }
219 }
220}
221
222fn default_freshness() -> ThreadFreshness {
223 ThreadFreshness::Unknown
224}
225
226#[derive(Debug, Clone)]
227pub struct ThreadManager {
228 record_store: FilesystemThreadRecordStore,
229 workspace_store: FilesystemThreadRecordStore,
230 lock_path: PathBuf,
231}
232
233impl ThreadManager {
234 pub fn new(heddle_dir: &Path) -> Self {
235 Self {
236 record_store: FilesystemThreadRecordStore::new(heddle_dir.join("thread_records")),
237 workspace_store: FilesystemThreadRecordStore::new(heddle_dir.join("thread_workspaces")),
238 lock_path: heddle_dir.join("thread_records").join(".lock"),
239 }
240 }
241
242 fn lock_path(&self) -> PathBuf {
243 self.lock_path.clone()
244 }
245
246 fn write_lock(&self) -> Result<objects::lock::WriteLockGuard> {
247 RepoLock::at(self.lock_path())
248 .write()
249 .map_err(|err| HeddleError::Config(format!("failed to acquire thread lock: {err}")))
250 }
251
252 fn save_record_file(&self, record: &ThreadRecord) -> Result<()> {
253 self.record_store.save_value(&record.id, record)
254 }
255
256 fn save_workspace_file(&self, thread_id: &str, workspace: &ThreadWorkspaceState) -> Result<()> {
257 self.workspace_store.save_value(thread_id, workspace)
258 }
259
260 fn load_record_file(&self, thread_id: &str) -> Result<Option<ThreadRecord>> {
261 self.record_store.load_value(thread_id)
262 }
263
264 fn load_workspace_file(&self, thread_id: &str) -> Result<Option<ThreadWorkspaceState>> {
265 self.workspace_store.load_value(thread_id)
266 }
267
268 fn list_record_files(&self) -> Result<Vec<ThreadRecord>> {
269 self.record_store.list_values()
270 }
271
272 fn hydrate_thread_from_record(&self, record: ThreadRecord) -> Result<Thread> {
273 let mut thread = Thread::from_record(record.clone());
274 if let Some(workspace) = self.load_workspace_file(&record.id)? {
275 workspace.apply_to_thread(&mut thread);
276 }
277 Ok(thread)
278 }
279
280 pub fn save(&self, thread: &Thread) -> Result<()> {
281 let _lock = self.write_lock()?;
282 self.save_record_file(&thread.to_record())?;
283 objects::fault_inject::maybe_fail_at("thread_manager_save_before_workspace")?;
284 self.save_workspace_file(&thread.id, &thread.workspace_state())
285 }
286
287 pub fn load(&self, thread_id: &str) -> Result<Option<Thread>> {
288 self.load_record_file(thread_id)?
289 .map(|record| self.hydrate_thread_from_record(record))
290 .transpose()
291 }
292
293 pub fn list(&self) -> Result<Vec<Thread>> {
294 let mut threads: Vec<Thread> = self
295 .list_record_files()?
296 .into_iter()
297 .map(|record| self.hydrate_thread_from_record(record))
298 .collect::<Result<Vec<_>>>()?;
299 threads.sort_by(|a, b| a.id.cmp(&b.id));
300 Ok(threads)
301 }
302
303 pub fn find_by_thread(&self, thread: &str) -> Result<Option<Thread>> {
304 Ok(self
305 .list()?
306 .into_iter()
307 .filter(|thread_entry| thread_entry.thread == thread)
308 .max_by_key(|thread_entry| thread_entry.updated_at))
309 }
310
311 pub fn find_by_execution_root(&self, root: &Path) -> Result<Option<Thread>> {
312 let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
313 Ok(self.list()?.into_iter().find(|thread| {
314 thread
315 .execution_path
316 .canonicalize()
317 .unwrap_or_else(|_| thread.execution_path.clone())
318 == canonical
319 || thread
320 .materialized_path
321 .as_ref()
322 .map(|path| path.canonicalize().unwrap_or_else(|_| path.clone()) == canonical)
323 .unwrap_or(false)
324 }))
325 }
326
327 pub fn delete(&self, thread_id: &str) -> Result<()> {
328 let _lock = self.write_lock()?;
329 self.record_store.delete_value(thread_id)?;
330 self.workspace_store.delete_value(thread_id)
331 }
332
333 pub fn converge_records(&self, name: &str, desired: &[Thread]) -> Result<()> {
348 let _lock = self.write_lock()?;
356 let keep: HashSet<&str> = desired.iter().map(|t| t.id.as_str()).collect();
357 for record in self.list_record_files()? {
358 if record.thread == name && !keep.contains(record.id.as_str()) {
359 self.record_store.delete_value(&record.id)?;
360 self.workspace_store.delete_value(&record.id)?;
361 }
362 }
363 for thread in desired {
364 self.save_record_file(&thread.to_record())?;
365 self.save_workspace_file(&thread.id, &thread.workspace_state())?;
366 }
367 Ok(())
368 }
369
370 pub fn snapshot_records(&self, name: &str) -> Result<Vec<Thread>> {
376 let _lock = self.write_lock()?;
380 self.list_record_files()?
381 .into_iter()
382 .filter(|record| record.thread == name)
383 .map(|record| self.hydrate_thread_from_record(record))
384 .collect()
385 }
386
387 pub fn load_record(&self, record_id: &str) -> Result<Option<ThreadRecord>> {
388 self.load_record_file(record_id)
389 }
390
391 pub fn save_record(&self, record: &ThreadRecord) -> Result<()> {
392 let _lock = self.write_lock()?;
393 self.save_record_file(record)
394 }
395
396 pub fn list_records(&self) -> Result<Vec<ThreadRecord>> {
397 let mut records: Vec<ThreadRecord> = self.list_record_files()?;
398 records.sort_by(|a, b| a.id.cmp(&b.id));
399 Ok(records)
400 }
401
402 pub fn find_record_by_thread(&self, thread: &str) -> Result<Option<ThreadRecord>> {
403 let mut records = self
404 .list_records()?
405 .into_iter()
406 .filter(|record| record.thread == thread)
407 .collect::<Vec<_>>();
408 records.sort_by(|a, b| {
409 a.updated_at
410 .cmp(&b.updated_at)
411 .then_with(|| a.id.cmp(&b.id))
412 });
413 Ok(records.pop())
414 }
415
416 pub fn find_synced_record_by_thread(
417 &self,
418 repo: &crate::Repository,
419 thread: &str,
420 current_state_override: Option<StateId>,
421 ) -> Result<Option<SyncedThreadMetadata>> {
422 self.find_record_by_thread(thread)?
423 .map(|record| SyncedThreadMetadata::from_record(repo, &record, current_state_override))
424 .transpose()
425 }
426
427 pub fn delete_record(&self, record_id: &str) -> Result<()> {
428 let _lock = self.write_lock()?;
429 self.record_store.delete_value(record_id)
430 }
431
432 pub fn snapshot_thread_record(&self, thread_name: &str) -> Result<Option<Vec<u8>>> {
446 let Some(thread) = self.find_by_thread(thread_name)? else {
447 return Ok(None);
448 };
449 self.encode_thread_record_snapshot(&thread).map(Some)
450 }
451
452 pub fn encode_thread_record_snapshot(&self, thread: &Thread) -> Result<Vec<u8>> {
455 rmp_serde::to_vec_named(thread).map_err(|e| {
456 HeddleError::Serialization(format!(
457 "encode thread record snapshot for '{}': {}",
458 thread.thread, e
459 ))
460 })
461 }
462
463 pub fn decode_thread_record_snapshot(&self, bytes: &[u8]) -> Result<Thread> {
477 rmp_serde::from_slice(bytes).map_err(|e| {
478 HeddleError::Serialization(format!("decode thread record snapshot: {}", e))
479 })
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use chrono::Utc;
486 use tempfile::TempDir;
487
488 use super::*;
489
490 fn sample_thread() -> Thread {
491 Thread {
492 id: "thread-1".to_string(),
493 thread: "feature/thread-1".to_string(),
494 target_thread: Some("main".to_string()),
495 parent_thread: None,
496 mode: ThreadMode::Solid,
497 state: ThreadState::Active,
498 base_state: "abc123".to_string(),
499 base_root: "def456".to_string(),
500 current_state: Some("abc123".to_string()),
501 merged_state: None,
502 task: Some("implement thing".to_string()),
503 execution_path: PathBuf::from("/tmp/work"),
504 materialized_path: Some(PathBuf::from("/tmp/materialized")),
505 changed_paths: vec!["src/lib.rs".to_string()],
506 impact_categories: vec![],
507 heavy_impact_paths: vec![],
508 promotion_suggested: false,
509 freshness: ThreadFreshness::Current,
510 verification_summary: ThreadVerificationSummary::default(),
511 confidence_summary: ThreadConfidenceSummary::default(),
512 integration_policy_result: ThreadIntegrationPolicy::default(),
513 created_at: Utc::now(),
514 updated_at: Utc::now(),
515 ephemeral: None,
516 auto: false,
517 shared_target_dir: None,
518 }
519 }
520
521 #[test]
522 fn thread_manager_round_trips_canonical_record_and_workspace() {
523 let temp = TempDir::new().unwrap();
524 let manager = ThreadManager::new(temp.path());
525 let thread = sample_thread();
526
527 manager.save(&thread).unwrap();
528 let record = manager.load_record(&thread.id).unwrap().unwrap();
529 let hydrated = manager.load(&thread.id).unwrap().unwrap();
530
531 assert_eq!(record.id, thread.id);
532 assert_eq!(record.thread, thread.thread);
533 assert_eq!(record.base_state, thread.base_state);
534 assert_eq!(record.task, thread.task);
535 assert_eq!(hydrated.execution_path, thread.execution_path);
536 assert_eq!(hydrated.materialized_path, thread.materialized_path);
537 }
538
539 #[test]
545 fn thread_manager_round_trips_auto_flag() {
546 let temp = TempDir::new().unwrap();
547 let manager = ThreadManager::new(temp.path());
548 let mut thread = sample_thread();
549 thread.id = "thread-auto".to_string();
550 thread.thread = "feature/thread-auto".to_string();
551 thread.auto = true;
552
553 manager.save(&thread).unwrap();
554 let record = manager.load_record(&thread.id).unwrap().unwrap();
555 let hydrated = manager.load(&thread.id).unwrap().unwrap();
556
557 assert!(
558 record.auto,
559 "auto flag must persist on the canonical record"
560 );
561 assert!(
562 hydrated.auto,
563 "auto flag must round-trip back into the hydrated Thread"
564 );
565 }
566
567 #[test]
575 fn converge_records_drops_leaked_newer_id_record() {
576 let temp = TempDir::new().unwrap();
577 let manager = ThreadManager::new(temp.path());
578 let name = "feature/converge";
579
580 let mut prev = sample_thread();
581 prev.id = "rec-prev".to_string();
582 prev.thread = name.to_string();
583 prev.updated_at = Utc::now();
584 manager.save(&prev).unwrap();
585
586 let mut leaked = sample_thread();
589 leaked.id = "rec-leaked".to_string();
590 leaked.thread = name.to_string();
591 leaked.updated_at = prev.updated_at + chrono::Duration::seconds(10);
592 manager.save(&leaked).unwrap();
593 assert_eq!(
594 manager.find_by_thread(name).unwrap().unwrap().id,
595 "rec-leaked",
596 "precondition: newer leaked record shadows prev"
597 );
598
599 manager
600 .converge_records(name, std::slice::from_ref(&prev))
601 .unwrap();
602
603 assert_eq!(
604 manager.find_by_thread(name).unwrap().unwrap().id,
605 "rec-prev",
606 "converge returns find_by_thread to the target, not the leak"
607 );
608 let under_name: Vec<_> = manager
609 .list()
610 .unwrap()
611 .into_iter()
612 .filter(|t| t.thread == name)
613 .collect();
614 assert_eq!(
615 under_name.len(),
616 1,
617 "exactly the target remains filed under the name"
618 );
619 }
620
621 #[test]
629 fn converge_records_converges_multiple_same_name_records() {
630 let temp = TempDir::new().unwrap();
631 let manager = ThreadManager::new(temp.path());
632 let name = "feature/multi";
633
634 let mut target = sample_thread();
635 target.id = "rec-target".to_string();
636 target.thread = name.to_string();
637 target.updated_at = Utc::now();
638 manager.save(&target).unwrap();
639
640 let mut older = sample_thread();
641 older.id = "rec-older".to_string();
642 older.thread = name.to_string();
643 older.updated_at = target.updated_at - chrono::Duration::seconds(30);
644 manager.save(&older).unwrap();
645
646 let mut newer = sample_thread();
647 newer.id = "rec-newer".to_string();
648 newer.thread = name.to_string();
649 newer.updated_at = target.updated_at + chrono::Duration::seconds(30);
650 manager.save(&newer).unwrap();
651 assert_eq!(
652 manager.find_by_thread(name).unwrap().unwrap().id,
653 "rec-newer",
654 "precondition: the newer same-name record shadows the target"
655 );
656
657 manager
658 .converge_records(name, std::slice::from_ref(&target))
659 .unwrap();
660
661 let under_name: Vec<_> = manager
662 .list()
663 .unwrap()
664 .into_iter()
665 .filter(|t| t.thread == name)
666 .collect();
667 assert_eq!(
668 under_name.len(),
669 1,
670 "exactly the target remains under the name"
671 );
672 assert_eq!(under_name[0].id, "rec-target");
673 assert_eq!(
674 manager.find_by_thread(name).unwrap().unwrap().id,
675 "rec-target",
676 "converge returns find_by_thread to the target"
677 );
678 for dropped in ["rec-older", "rec-newer"] {
680 assert!(
681 manager.load_record_file(dropped).unwrap().is_none(),
682 "{dropped} record file deleted"
683 );
684 assert!(
685 manager.load_workspace_file(dropped).unwrap().is_none(),
686 "{dropped} workspace file deleted"
687 );
688 }
689 assert!(manager.load_record_file("rec-target").unwrap().is_some());
691 assert!(manager.load_workspace_file("rec-target").unwrap().is_some());
692 }
693
694 #[test]
699 fn converge_records_converges_to_a_two_record_set() {
700 let temp = TempDir::new().unwrap();
701 let manager = ThreadManager::new(temp.path());
702 let name = "feature/pair";
703
704 let mut keep_a = sample_thread();
705 keep_a.id = "keep-a".to_string();
706 keep_a.thread = name.to_string();
707 keep_a.updated_at = Utc::now();
708 manager.save(&keep_a).unwrap();
709
710 let mut keep_b = sample_thread();
711 keep_b.id = "keep-b".to_string();
712 keep_b.thread = name.to_string();
713 keep_b.updated_at = keep_a.updated_at - chrono::Duration::seconds(10);
714 manager.save(&keep_b).unwrap();
715
716 let mut drop_c = sample_thread();
717 drop_c.id = "drop-c".to_string();
718 drop_c.thread = name.to_string();
719 drop_c.updated_at = keep_a.updated_at + chrono::Duration::seconds(10);
720 manager.save(&drop_c).unwrap();
721 assert_eq!(
722 manager
723 .list()
724 .unwrap()
725 .iter()
726 .filter(|t| t.thread == name)
727 .count(),
728 3,
729 "precondition: three records under the name"
730 );
731
732 manager
733 .converge_records(name, &[keep_a.clone(), keep_b.clone()])
734 .unwrap();
735
736 let ids: HashSet<String> = manager
737 .list()
738 .unwrap()
739 .into_iter()
740 .filter(|t| t.thread == name)
741 .map(|t| t.id)
742 .collect();
743 assert_eq!(
744 ids,
745 HashSet::from(["keep-a".to_string(), "keep-b".to_string()]),
746 "exactly the desired pair remains under the name"
747 );
748 assert!(
749 manager.load_record_file("drop-c").unwrap().is_none(),
750 "the non-desired record is dropped"
751 );
752 assert!(
753 manager.load_workspace_file("drop-c").unwrap().is_none(),
754 "the non-desired record's workspace half is dropped"
755 );
756 }
757
758 #[test]
761 fn converge_records_empty_empties_only_the_named_thread() {
762 let temp = TempDir::new().unwrap();
763 let manager = ThreadManager::new(temp.path());
764
765 let mut a1 = sample_thread();
766 a1.id = "a1".to_string();
767 a1.thread = "feature/a".to_string();
768 manager.save(&a1).unwrap();
769 let mut a2 = sample_thread();
770 a2.id = "a2".to_string();
771 a2.thread = "feature/a".to_string();
772 a2.updated_at = a1.updated_at + chrono::Duration::seconds(5);
773 manager.save(&a2).unwrap();
774 let mut other = sample_thread();
775 other.id = "b1".to_string();
776 other.thread = "feature/b".to_string();
777 manager.save(&other).unwrap();
778
779 manager.converge_records("feature/a", &[]).unwrap();
780
781 assert!(
782 manager.find_by_thread("feature/a").unwrap().is_none(),
783 "all records for the named thread are deleted"
784 );
785 assert_eq!(
786 manager.find_by_thread("feature/b").unwrap().unwrap().id,
787 "b1",
788 "records for other threads are untouched"
789 );
790 }
791
792 #[test]
795 fn converge_records_target_already_sole_is_noop() {
796 let temp = TempDir::new().unwrap();
797 let manager = ThreadManager::new(temp.path());
798 let name = "feature/sole";
799
800 let mut rec = sample_thread();
801 rec.id = "rec-sole".to_string();
802 rec.thread = name.to_string();
803 manager.save(&rec).unwrap();
804
805 manager
806 .converge_records(name, std::slice::from_ref(&rec))
807 .unwrap();
808
809 assert_eq!(
810 manager.find_by_thread(name).unwrap().unwrap().id,
811 "rec-sole"
812 );
813 assert_eq!(
814 manager
815 .list()
816 .unwrap()
817 .into_iter()
818 .filter(|t| t.thread == name)
819 .count(),
820 1
821 );
822 }
823
824 #[test]
828 fn snapshot_records_returns_full_hydrated_same_name_set() {
829 let temp = TempDir::new().unwrap();
830 let manager = ThreadManager::new(temp.path());
831 let name = "feature/snap";
832
833 let mut a = sample_thread();
834 a.id = "snap-a".to_string();
835 a.thread = name.to_string();
836 a.materialized_path = Some(PathBuf::from("/work/snap-a"));
837 manager.save(&a).unwrap();
838 let mut b = sample_thread();
839 b.id = "snap-b".to_string();
840 b.thread = name.to_string();
841 manager.save(&b).unwrap();
842 let mut other = sample_thread();
844 other.id = "snap-other".to_string();
845 other.thread = "feature/other".to_string();
846 manager.save(&other).unwrap();
847
848 let snap = manager.snapshot_records(name).unwrap();
849 let ids: HashSet<String> = snap.iter().map(|t| t.id.clone()).collect();
850 assert_eq!(
851 ids,
852 HashSet::from(["snap-a".to_string(), "snap-b".to_string()]),
853 "exactly the same-name set is snapshotted, other names excluded"
854 );
855 let snap_a = snap.iter().find(|t| t.id == "snap-a").unwrap();
856 assert_eq!(
857 snap_a.materialized_path,
858 Some(PathBuf::from("/work/snap-a")),
859 "the workspace half is hydrated into the snapshot"
860 );
861 }
862
863 #[test]
864 fn thread_record_reader_rejects_minimal_legacy_shape_after_migration_gate() {
865 let raw = r#"
866id = "legacy-minimal"
867thread = "legacy/minimal"
868mode = "solid"
869state = "active"
870base_state = "abc123"
871base_root = "def456"
872created_at = "2024-01-01T00:00:00Z"
873updated_at = "2024-01-01T00:00:01Z"
874"#;
875
876 let err = toml::from_str::<ThreadRecord>(raw)
877 .expect_err("legacy records must go through 0002 before live reads");
878 assert!(
879 err.to_string().contains("missing field"),
880 "strict reader should reject missing current fields, got: {err}",
881 );
882 }
883}