1use serde::{Deserialize, Serialize};
52use std::collections::HashMap;
53use std::path::{Path, PathBuf};
54
55use super::Editor;
56
57#[derive(Serialize, Deserialize, Debug, Clone)]
59pub(crate) struct PersistedWindow {
60 pub(crate) id: u64,
61 pub(crate) label: String,
62 pub(crate) root: PathBuf,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub(crate) project_path: Option<PathBuf>,
73 #[serde(default, skip_serializing_if = "is_false")]
79 pub(crate) shared_worktree: bool,
80 #[serde(default)]
84 pub(crate) plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
85}
86
87fn is_false(b: &bool) -> bool {
88 !b
89}
90
91#[derive(Serialize, Deserialize, Debug, Clone)]
93pub(crate) struct PersistedWindows {
94 #[serde(default = "default_version")]
99 pub(crate) version: u32,
100 pub(crate) active: u64,
104 pub(crate) next_id: u64,
108 pub(crate) windows: Vec<PersistedWindow>,
109}
110
111fn default_version() -> u32 {
112 1
113}
114
115const CURRENT_VERSION: u32 = 2;
116
117pub(crate) fn read_persisted_windows_env(
134 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
135 data_dir: &Path,
136 _working_dir: &Path,
137) -> Option<PersistedWindows> {
138 let global_p = global_windows_path(data_dir);
142 if !filesystem.exists(&global_p) {
143 migrate_legacy_windows(filesystem, data_dir);
144 }
145 migrate_windows_json_into_workspaces(filesystem, data_dir);
146
147 let windows = discover_sessions(filesystem, data_dir);
151 if windows.is_empty() {
152 return None;
153 }
154 let next_id = windows.iter().map(|w| w.id).max().unwrap_or(0) + 1;
155 Some(PersistedWindows {
164 version: CURRENT_VERSION,
165 active: 0,
166 next_id,
167 windows,
168 })
169}
170
171fn workspaces_dir(data_dir: &Path) -> PathBuf {
172 data_dir.join("workspaces")
173}
174
175fn workspace_file_for(data_dir: &Path, root: &Path) -> PathBuf {
179 let filename = format!(
180 "{}.json",
181 crate::workspace::encode_path_for_filename(&canonical_key(root))
182 );
183 workspaces_dir(data_dir).join(filename)
184}
185
186fn basename_label(root: &Path) -> String {
187 root.file_name()
188 .and_then(|s| s.to_str())
189 .map(|s| s.to_string())
190 .unwrap_or_else(|| root.to_string_lossy().into_owned())
191}
192
193fn discover_sessions(
198 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
199 data_dir: &Path,
200) -> Vec<PersistedWindow> {
201 type SessionState = HashMap<String, HashMap<String, serde_json::Value>>;
202 let dir = workspaces_dir(data_dir);
203 let entries = match filesystem.read_dir(&dir) {
204 Ok(e) => e,
205 Err(_) => return Vec::new(),
206 };
207 let mut found: Vec<(PathBuf, String, SessionState)> = Vec::new();
208 for entry in entries {
209 let p = &entry.path;
210 if !entry.name.ends_with(".json") {
213 continue;
214 }
215 let Ok(bytes) = filesystem.read_file(p) else {
216 continue;
217 };
218 let Ok(val) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
219 continue;
220 };
221 let Some(root) = val.get("working_dir").and_then(|v| v.as_str()) else {
222 continue;
223 };
224 let root = PathBuf::from(root);
225 match filesystem.is_dir(&root) {
233 Ok(true) => {}
234 Ok(false) => {
235 let _ = filesystem.remove_file(p).ok();
236 continue;
237 }
238 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
239 let _ = filesystem.remove_file(p).ok();
240 continue;
241 }
242 Err(_) => continue,
243 }
244 let label = val
245 .get("label")
246 .and_then(|v| v.as_str())
247 .map(|s| s.to_string())
248 .unwrap_or_else(|| basename_label(&root));
249 let plugin_state: SessionState = val
250 .get("session_plugin_state")
251 .and_then(|v| serde_json::from_value(v.clone()).ok())
252 .unwrap_or_default();
253 found.push((root, label, plugin_state));
254 }
255 found.sort_by(|a, b| canonical_key(&a.0).cmp(&canonical_key(&b.0)));
256 found
257 .into_iter()
258 .enumerate()
259 .map(|(i, (root, label, plugin_state))| {
260 let (project_path, shared_worktree) = read_orch_session_meta(&plugin_state);
261 PersistedWindow {
262 id: (i as u64) + 1,
263 label,
264 root,
265 project_path,
266 shared_worktree,
267 plugin_state,
268 }
269 })
270 .collect()
271}
272
273fn migrate_windows_json_into_workspaces(
280 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
281 data_dir: &Path,
282) {
283 let global_p = global_windows_path(data_dir);
284 if !filesystem.exists(&global_p) {
285 return;
286 }
287 let Ok(bytes) = filesystem.read_file(&global_p) else {
288 return;
289 };
290 let Ok(env) = serde_json::from_slice::<PersistedWindows>(&bytes) else {
291 return; };
293 for w in &env.windows {
294 let ws_path = workspace_file_for(data_dir, &w.root);
295 if !filesystem.exists(&ws_path) {
296 continue;
297 }
298 let Ok(wbytes) = filesystem.read_file(&ws_path) else {
299 continue;
300 };
301 let Ok(mut val) = serde_json::from_slice::<serde_json::Value>(&wbytes) else {
302 continue;
303 };
304 if let Some(obj) = val.as_object_mut() {
305 obj.entry("label")
306 .or_insert_with(|| serde_json::Value::String(w.label.clone()));
307 if !obj.contains_key("session_plugin_state") && !w.plugin_state.is_empty() {
308 if let Ok(ps) = serde_json::to_value(&w.plugin_state) {
309 obj.insert("session_plugin_state".into(), ps);
310 }
311 }
312 }
313 if let Ok(out) = serde_json::to_vec_pretty(&val) {
314 let _ = filesystem.write_file(&ws_path, &out).ok();
316 }
317 }
318 let bak = global_p.with_extension("json.retired.bak");
320 if filesystem.rename(&global_p, &bak).is_err() {
321 let _ = filesystem.remove_file(&global_p).ok();
323 }
324}
325
326pub(crate) fn pick_active_window_for_cwd<'a>(
354 env: Option<&'a PersistedWindows>,
355 cwd: &Path,
356) -> Option<&'a PersistedWindow> {
357 let env = env?;
358 if let Some(w) = env
359 .windows
360 .iter()
361 .find(|w| w.id == env.active && window_matches_cwd(w, cwd))
362 {
363 return Some(w);
364 }
365 env.windows
366 .iter()
367 .filter(|w| window_matches_cwd(w, cwd))
368 .max_by_key(|w| w.id)
369}
370
371fn window_matches_cwd(w: &PersistedWindow, cwd: &Path) -> bool {
372 paths_equal(&w.root, cwd)
373}
374
375fn paths_equal(a: &Path, b: &Path) -> bool {
376 canonical_key(a) == canonical_key(b)
377}
378
379pub(crate) fn canonical_key(path: &Path) -> PathBuf {
386 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
387}
388
389fn migrate_legacy_windows(
403 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
404 data_dir: &Path,
405) {
406 let orch_root = data_dir.join("orchestrator");
407 if !filesystem.exists(&orch_root) {
408 return;
409 }
410 let entries = match filesystem.read_dir(&orch_root) {
411 Ok(es) => es,
412 Err(_) => return,
413 };
414 let mut merged_windows: Vec<PersistedWindow> = Vec::new();
415 let mut merged_active: u64 = 1;
416 let mut merged_next_id: u64 = 2;
417 let mut used_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
418 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
419
420 for entry in entries {
421 let dir = entry.path;
422 if !filesystem.is_dir(&dir).unwrap_or(false) {
423 continue;
424 }
425 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
429 Some(n) => n.to_string(),
430 None => continue,
431 };
432 if dir_name == "state" {
433 continue;
434 }
435 let legacy_p = dir.join("windows.json");
436 if !filesystem.exists(&legacy_p) {
437 continue;
438 }
439 let bytes = match filesystem.read_file(&legacy_p) {
440 Ok(b) => b,
441 Err(_) => continue,
442 };
443 let env = match serde_json::from_slice::<PersistedWindows>(&bytes) {
444 Ok(e) => e,
445 Err(_) => continue,
446 };
447 let project_path = crate::workspace::decode_filename_to_path(&dir_name)
448 .unwrap_or_else(|| PathBuf::from(dir_name.clone()));
449
450 let mut local_renum: HashMap<u64, u64> = HashMap::new();
451 for mut w in env.windows.into_iter() {
452 if w.project_path.is_none() {
456 w.project_path = Some(project_path.clone());
457 }
458 if used_ids.contains(&w.id) {
459 let new_id = merged_next_id;
460 local_renum.insert(w.id, new_id);
461 merged_next_id = merged_next_id.saturating_add(1);
462 used_ids.insert(new_id);
463 w.id = new_id;
464 } else {
465 used_ids.insert(w.id);
466 merged_next_id = merged_next_id.max(w.id.saturating_add(1));
467 }
468 merged_windows.push(w);
469 }
470 let active_id = local_renum.get(&env.active).copied().unwrap_or(env.active);
475 merged_active = active_id;
476 legacy_to_rename.push(legacy_p);
477 }
478
479 if merged_windows.is_empty() {
480 return;
481 }
482 merged_windows.sort_by_key(|w| w.id);
483 let envelope = PersistedWindows {
484 version: CURRENT_VERSION,
485 active: merged_active,
486 next_id: merged_next_id,
487 windows: merged_windows,
488 };
489 let global_p = global_windows_path(data_dir);
490 if let Err(e) = filesystem.create_dir_all(&orch_root) {
491 tracing::warn!("orchestrator migration: failed to create {orch_root:?}: {e}");
492 return;
493 }
494 let bytes = match serde_json::to_vec_pretty(&envelope) {
495 Ok(b) => b,
496 Err(e) => {
497 tracing::warn!("orchestrator migration: failed to serialise envelope: {e}");
498 return;
499 }
500 };
501 if let Err(e) = filesystem.write_file(&global_p, &bytes) {
502 tracing::warn!("orchestrator migration: failed to write {global_p:?}: {e}");
503 return;
504 }
505 for legacy_p in legacy_to_rename {
506 let backup = legacy_p.with_extension("json.migrated.bak");
507 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
508 tracing::warn!(
509 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
510 );
511 }
512 }
513 tracing::info!(
514 "orchestrator persistence: migrated {} sessions from legacy per-cwd layout into {:?}",
515 envelope.windows.len(),
516 global_p
517 );
518}
519
520pub(crate) fn read_persisted_plugin_state(
533 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
534 data_dir: &Path,
535 _working_dir: &Path,
536) -> HashMap<String, HashMap<String, serde_json::Value>> {
537 let mut out: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
538 let state_dir = global_state_dir(data_dir);
539 if !filesystem.exists(&state_dir) {
540 migrate_legacy_plugin_state(filesystem, data_dir);
541 }
542 if !filesystem.exists(&state_dir) {
543 return out;
544 }
545 let entries = match filesystem.read_dir(&state_dir) {
546 Ok(es) => es,
547 Err(e) => {
548 tracing::warn!("orchestrator persistence: failed to read {state_dir:?}: {e}");
549 return out;
550 }
551 };
552 for entry in entries {
553 let path = entry.path;
554 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
555 continue;
556 };
557 if !plugin_name_is_safe(stem) {
558 continue;
559 }
560 if path.extension().and_then(|e| e.to_str()) != Some("json") {
561 continue;
562 }
563 match filesystem.read_file(&path) {
564 Ok(bytes) => {
565 match serde_json::from_slice::<HashMap<String, serde_json::Value>>(&bytes) {
566 Ok(map) if !map.is_empty() => {
567 out.insert(stem.to_owned(), map);
568 }
569 Ok(_) => {}
570 Err(e) => {
571 tracing::warn!("orchestrator persistence: failed to parse {path:?}: {e}");
572 }
573 }
574 }
575 Err(e) => {
576 tracing::warn!("orchestrator persistence: failed to read {path:?}: {e}");
577 }
578 }
579 }
580 out
581}
582
583fn orchestrator_dir(data_dir: &Path) -> PathBuf {
588 data_dir.join("orchestrator")
589}
590
591fn global_windows_path(data_dir: &Path) -> PathBuf {
592 orchestrator_dir(data_dir).join("windows.json")
593}
594
595fn global_state_dir(data_dir: &Path) -> PathBuf {
596 orchestrator_dir(data_dir).join("state")
597}
598
599fn global_plugin_state_path(data_dir: &Path, plugin: &str) -> PathBuf {
600 global_state_dir(data_dir).join(format!("{plugin}.json"))
606}
607
608fn plugin_name_is_safe(name: &str) -> bool {
609 !name.is_empty()
610 && name
611 .chars()
612 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
613 && !name.starts_with('.')
614}
615
616fn migrate_legacy_plugin_state(
623 filesystem: &(dyn crate::model::filesystem::FileSystem + Send + Sync),
624 data_dir: &Path,
625) {
626 let orch_root = data_dir.join("orchestrator");
627 if !filesystem.exists(&orch_root) {
628 return;
629 }
630 let cwd_entries = match filesystem.read_dir(&orch_root) {
631 Ok(es) => es,
632 Err(_) => return,
633 };
634 let mut merged: HashMap<String, HashMap<String, serde_json::Value>> = HashMap::new();
635 let mut legacy_to_rename: Vec<PathBuf> = Vec::new();
636 for cwd_entry in cwd_entries {
637 let dir = cwd_entry.path;
638 if !filesystem.is_dir(&dir).unwrap_or(false) {
639 continue;
640 }
641 let dir_name = match dir.file_name().and_then(|s| s.to_str()) {
642 Some(n) => n.to_string(),
643 None => continue,
644 };
645 if dir_name == "state" {
646 continue;
647 }
648 let state_dir = dir.join("state");
649 if !filesystem.exists(&state_dir) {
650 continue;
651 }
652 let plugin_entries = match filesystem.read_dir(&state_dir) {
653 Ok(es) => es,
654 Err(_) => continue,
655 };
656 for pe in plugin_entries {
657 let p = pe.path;
658 let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
659 continue;
660 };
661 if !plugin_name_is_safe(stem) {
662 continue;
663 }
664 if p.extension().and_then(|e| e.to_str()) != Some("json") {
665 continue;
666 }
667 let bytes = match filesystem.read_file(&p) {
668 Ok(b) => b,
669 Err(_) => continue,
670 };
671 let map: HashMap<String, serde_json::Value> = match serde_json::from_slice(&bytes) {
672 Ok(m) => m,
673 Err(_) => continue,
674 };
675 let slot = merged.entry(stem.to_owned()).or_default();
676 for (k, v) in map {
677 slot.insert(k, v);
678 }
679 legacy_to_rename.push(p);
680 }
681 }
682 if merged.is_empty() {
683 return;
684 }
685 let target_state_dir = global_state_dir(data_dir);
686 if let Err(e) = filesystem.create_dir_all(&target_state_dir) {
687 tracing::warn!("orchestrator migration: failed to create {target_state_dir:?}: {e}");
688 return;
689 }
690 for (plugin, map) in &merged {
691 let path = global_plugin_state_path(data_dir, plugin);
692 let bytes = match serde_json::to_vec_pretty(map) {
693 Ok(b) => b,
694 Err(e) => {
695 tracing::warn!("orchestrator migration: failed to serialise plugin {plugin}: {e}");
696 continue;
697 }
698 };
699 if let Err(e) = filesystem.write_file(&path, &bytes) {
700 tracing::warn!("orchestrator migration: failed to write {path:?}: {e}");
701 }
702 }
703 for legacy_p in legacy_to_rename {
704 let backup = legacy_p.with_extension("json.migrated.bak");
705 if let Err(e) = filesystem.rename(&legacy_p, &backup) {
706 tracing::warn!(
707 "orchestrator migration: failed to rename {legacy_p:?} → {backup:?}: {e}"
708 );
709 }
710 }
711 tracing::info!(
712 "orchestrator persistence: migrated plugin state for {} plugins",
713 merged.len()
714 );
715}
716
717impl Editor {
718 pub fn save_orchestrator_state(&self) {
722 let data_dir = self.dir_context.data_dir.clone();
723 let orch_dir = orchestrator_dir(&data_dir);
724 if let Err(e) = self.authority.filesystem.create_dir_all(&orch_dir) {
725 tracing::warn!("orchestrator persistence: failed to create {orch_dir:?}: {e}");
726 return;
727 }
728
729 let state_dir = global_state_dir(&data_dir);
741 if !self.plugin_global_state.is_empty() {
742 if let Err(e) = self.authority.filesystem.create_dir_all(&state_dir) {
743 tracing::warn!("orchestrator persistence: failed to create {state_dir:?}: {e}");
744 return;
745 }
746 }
747 for (plugin, map) in &self.plugin_global_state {
748 if !plugin_name_is_safe(plugin) {
749 tracing::warn!(
750 "orchestrator persistence: skipping plugin with unsafe name: {plugin:?}"
751 );
752 continue;
753 }
754 if map.is_empty() {
755 continue;
756 }
757 match serde_json::to_vec_pretty(map) {
758 Ok(bytes) => {
759 let path = global_plugin_state_path(&data_dir, plugin);
760 let tmp = path.with_extension("json.tmp");
761 if let Err(e) = self.authority.filesystem.write_file(&tmp, &bytes) {
762 tracing::warn!("orchestrator persistence: failed to write {tmp:?}: {e}");
763 continue;
764 }
765 if let Err(e) = self.authority.filesystem.rename(&tmp, &path) {
766 tracing::warn!(
767 "orchestrator persistence: failed to rename {tmp:?} → {path:?}: {e}"
768 );
769 }
770 }
771 Err(e) => {
772 tracing::warn!(
773 "orchestrator persistence: failed to serialise plugin {plugin}: {e}"
774 );
775 }
776 }
777 }
778 }
779}
780
781fn read_orch_session_meta(
787 plugin_state: &HashMap<String, HashMap<String, serde_json::Value>>,
788) -> (Option<PathBuf>, bool) {
789 let slot = plugin_state.get("orchestrator");
790 let project_path = slot
791 .and_then(|m| m.get("project_path"))
792 .and_then(|v| v.as_str())
793 .map(PathBuf::from);
794 let shared_worktree = slot
795 .and_then(|m| m.get("shared_worktree"))
796 .and_then(|v| v.as_bool())
797 .unwrap_or(false);
798 (project_path, shared_worktree)
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804
805 #[test]
806 fn paths_live_under_data_dir_not_working_dir() {
807 let data_dir = Path::new("/tmp/fresh-data");
810 let working_dir = Path::new("/home/user/project");
811
812 let wp = global_windows_path(data_dir);
813 let sd = global_state_dir(data_dir);
814 let psp = global_plugin_state_path(data_dir, "orchestrator");
815
816 assert!(
817 wp.starts_with(data_dir),
818 "windows_path must live under data_dir, got {wp:?}"
819 );
820 assert!(
821 sd.starts_with(data_dir),
822 "state_dir must live under data_dir, got {sd:?}"
823 );
824 assert!(
825 psp.starts_with(data_dir),
826 "plugin_state_path must live under data_dir, got {psp:?}"
827 );
828
829 for p in [&wp, &sd, &psp] {
830 assert!(
831 !p.starts_with(working_dir),
832 "orchestrator path must not be inside the working tree: {p:?}"
833 );
834 for component in p.components() {
835 if let std::path::Component::Normal(c) = component {
836 assert_ne!(
837 c, ".fresh",
838 "orchestrator path must not contain a `.fresh` component: {p:?}"
839 );
840 }
841 }
842 }
843 }
844
845 fn make_window(id: u64, root: &str, project_path: Option<&str>) -> PersistedWindow {
846 PersistedWindow {
847 id,
848 label: String::new(),
849 root: PathBuf::from(root),
850 project_path: project_path.map(PathBuf::from),
851 shared_worktree: false,
852 plugin_state: HashMap::new(),
853 }
854 }
855
856 fn env_with(active: u64, windows: Vec<PersistedWindow>) -> PersistedWindows {
857 PersistedWindows {
858 version: CURRENT_VERSION,
859 active,
860 next_id: windows.iter().map(|w| w.id).max().unwrap_or(0) + 1,
861 windows,
862 }
863 }
864
865 #[test]
866 fn pick_active_never_crosses_projects() {
867 let env = env_with(
871 2,
872 vec![
873 make_window(1, "/repoA", Some("/repoA")),
874 make_window(2, "/repoA", Some("/repoA")),
875 make_window(3, "/repoB", Some("/repoB")),
876 ],
877 );
878 let picked = pick_active_window_for_cwd(Some(&env), Path::new("/repoB"))
879 .expect("a /repoB session exists");
880 assert_eq!(
881 picked.id, 3,
882 "must pick the /repoB session, not env.active=2"
883 );
884 }
885
886 #[test]
887 fn pick_active_reopens_last_used_for_cwd() {
888 let env = env_with(
891 2,
892 vec![
893 make_window(2, "/repoA", Some("/repoA")),
894 make_window(5, "/repoA", Some("/repoA")),
895 ],
896 );
897 let picked =
898 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
899 assert_eq!(
900 picked.id, 2,
901 "env.active is the last-used session for the cwd"
902 );
903 }
904
905 #[test]
906 fn pick_active_falls_back_to_most_recent_session_for_cwd() {
907 let env = env_with(
911 9,
912 vec![
913 make_window(2, "/repoA", Some("/repoA")),
914 make_window(7, "/repoA", Some("/repoA")),
915 make_window(9, "/repoB", Some("/repoB")),
916 ],
917 );
918 let picked =
919 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
920 assert_eq!(picked.id, 7, "fall back to the most recent /repoA session");
921 }
922
923 #[test]
924 fn pick_active_returns_none_when_no_window_matches_cwd() {
925 let env = env_with(
927 1,
928 vec![
929 make_window(1, "/repoA", Some("/repoA")),
930 make_window(2, "/repoB", Some("/repoB")),
931 ],
932 );
933 assert!(pick_active_window_for_cwd(Some(&env), Path::new("/repoC")).is_none());
934 }
935
936 #[test]
937 fn pick_active_falls_back_to_root_when_project_path_missing() {
938 let env = env_with(
940 2,
941 vec![
942 make_window(1, "/repoA", None),
943 make_window(2, "/repoB", None),
944 ],
945 );
946 let picked =
947 pick_active_window_for_cwd(Some(&env), Path::new("/repoA")).expect("matching window");
948 assert_eq!(picked.id, 1);
949 }
950
951 #[test]
952 fn global_paths_are_independent_of_working_dir() {
953 let data_dir = Path::new("/tmp/fresh-data");
958 let a = global_windows_path(data_dir);
959 let b = global_windows_path(data_dir);
960 assert_eq!(a, b);
961 assert_eq!(a, data_dir.join("orchestrator").join("windows.json"));
962 }
963
964 #[test]
965 fn discover_gcs_missing_dirs_and_yields_one_session_per_existing_dir() {
966 use crate::model::filesystem::StdFileSystem;
967 let data = tempfile::tempdir().unwrap();
968 let data_dir = data.path();
969 let ws_dir = workspaces_dir(data_dir);
970 std::fs::create_dir_all(&ws_dir).unwrap();
971
972 let live = tempfile::tempdir().unwrap();
974 let live_root = live.path().canonicalize().unwrap();
975 let live_file = ws_dir.join("live.json");
976 std::fs::write(
977 &live_file,
978 serde_json::to_vec(&serde_json::json!({
979 "working_dir": live_root, "label": "live-session",
980 }))
981 .unwrap(),
982 )
983 .unwrap();
984
985 let dead_file = ws_dir.join("dead.json");
987 std::fs::write(
988 &dead_file,
989 serde_json::to_vec(&serde_json::json!({
990 "working_dir": "/no/such/dir/anywhere", "label": "dead",
991 }))
992 .unwrap(),
993 )
994 .unwrap();
995
996 let fs = StdFileSystem;
997 let sessions = discover_sessions(&fs, data_dir);
998
999 assert_eq!(sessions.len(), 1, "only the existing dir yields a session");
1000 assert_eq!(sessions[0].root, live_root);
1001 assert_eq!(sessions[0].label, "live-session");
1002 assert!(!dead_file.exists(), "the dead dir's cache file was GC'd");
1003 assert!(live_file.exists(), "the live cache file is kept");
1004 }
1005
1006 #[test]
1007 fn migrate_folds_windows_json_into_workspace_files_and_retires_it() {
1008 use crate::model::filesystem::StdFileSystem;
1009 let data = tempfile::tempdir().unwrap();
1010 let data_dir = data.path();
1011 let proj = tempfile::tempdir().unwrap();
1012 let proj_root = proj.path().canonicalize().unwrap();
1013
1014 let ws_path = workspace_file_for(data_dir, &proj_root);
1016 std::fs::create_dir_all(ws_path.parent().unwrap()).unwrap();
1017 std::fs::write(
1018 &ws_path,
1019 serde_json::to_vec(&serde_json::json!({ "working_dir": proj_root })).unwrap(),
1020 )
1021 .unwrap();
1022
1023 let global_p = global_windows_path(data_dir);
1025 std::fs::create_dir_all(global_p.parent().unwrap()).unwrap();
1026 std::fs::write(
1027 &global_p,
1028 serde_json::to_vec(&serde_json::json!({
1029 "version": 2, "active": 1, "next_id": 2,
1030 "windows": [ { "id": 1, "label": "from-windows-json", "root": proj_root } ],
1031 }))
1032 .unwrap(),
1033 )
1034 .unwrap();
1035
1036 let fs = StdFileSystem;
1037 migrate_windows_json_into_workspaces(&fs, data_dir);
1038
1039 assert!(!global_p.exists(), "windows.json is retired");
1040 assert!(
1041 global_p.with_extension("json.retired.bak").exists(),
1042 "a .retired.bak is kept"
1043 );
1044 let val: serde_json::Value =
1045 serde_json::from_slice(&std::fs::read(&ws_path).unwrap()).unwrap();
1046 assert_eq!(
1047 val.get("label").and_then(|v| v.as_str()),
1048 Some("from-windows-json"),
1049 "the label was folded into the per-dir workspace file"
1050 );
1051 }
1052}