Skip to main content

stack_profile/
profile_store.rs

1use std::path::{Path, PathBuf};
2
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5
6use crate::{ProfileData, ProfileError};
7
8const CS_CONFIG_PATH_ENV: &str = "CS_CONFIG_PATH";
9const DEFAULT_DIR_NAME: &str = ".cipherstash";
10const WORKSPACES_DIR: &str = "workspaces";
11const CURRENT_WORKSPACE_FILE: &str = "current_workspace";
12
13/// A directory-scoped JSON file store for profile data.
14///
15/// `ProfileStore` represents a profile directory (typically `~/.cipherstash/`).
16/// Individual files are addressed by name when calling [`save`](Self::save),
17/// [`load`](Self::load), and other operations.
18///
19/// # Example
20///
21/// ```no_run
22/// use stack_profile::ProfileStore;
23/// use serde::{Serialize, Deserialize};
24///
25/// #[derive(Serialize, Deserialize)]
26/// struct MyConfig {
27///     name: String,
28/// }
29///
30/// # fn main() -> Result<(), stack_profile::ProfileError> {
31/// let store = ProfileStore::resolve(None)?;
32/// store.save("my-config.json", &MyConfig { name: "example".into() })?;
33/// let config: MyConfig = store.load("my-config.json")?;
34/// # Ok(())
35/// # }
36/// ```
37#[derive(Debug)]
38pub struct ProfileStore {
39    dir: PathBuf,
40}
41
42impl ProfileStore {
43    /// Create a profile store rooted at the given directory.
44    pub fn new(dir: impl Into<PathBuf>) -> Self {
45        Self { dir: dir.into() }
46    }
47
48    /// Resolve the profile directory.
49    ///
50    /// Resolution order:
51    /// 1. `explicit` path, if provided
52    /// 2. `CS_CONFIG_PATH` environment variable, if set
53    /// 3. `~/.cipherstash` (the default)
54    pub fn resolve(explicit: Option<PathBuf>) -> Result<Self, ProfileError> {
55        if let Some(path) = explicit {
56            return Ok(Self::new(path));
57        }
58        if let Ok(path) = std::env::var(CS_CONFIG_PATH_ENV) {
59            if !path.trim().is_empty() {
60                return Ok(Self::new(path));
61            }
62        }
63        let home = dirs::home_dir().ok_or(ProfileError::HomeDirNotFound)?;
64        Ok(Self::new(home.join(DEFAULT_DIR_NAME)))
65    }
66
67    /// Return the directory path.
68    pub fn dir(&self) -> &Path {
69        &self.dir
70    }
71
72    /// Save a value as pretty-printed JSON to a file in the store directory.
73    ///
74    /// Creates the directory and any parents if they don't exist.
75    pub fn save<T: Serialize>(&self, filename: &str, value: &T) -> Result<(), ProfileError> {
76        self.write(filename, value, None)
77    }
78
79    /// Save a value as pretty-printed JSON with restricted Unix file permissions.
80    ///
81    /// On non-Unix platforms the mode is ignored and this behaves like [`save`](Self::save).
82    pub fn save_with_mode<T: Serialize>(
83        &self,
84        filename: &str,
85        value: &T,
86        _mode: u32,
87    ) -> Result<(), ProfileError> {
88        #[cfg(unix)]
89        return self.write(filename, value, Some(_mode));
90        #[cfg(not(unix))]
91        self.write(filename, value, None)
92    }
93
94    /// Validate that a filename is a plain, non-empty filename (no path separators or `..`).
95    fn validate_filename(filename: &str) -> Result<(), ProfileError> {
96        let path = Path::new(filename);
97        if filename.is_empty()
98            || path.is_absolute()
99            || filename.contains(std::path::MAIN_SEPARATOR)
100            || filename.contains('/')
101            || path
102                .components()
103                .any(|c| matches!(c, std::path::Component::ParentDir))
104        {
105            return Err(ProfileError::InvalidFilename(filename.to_string()));
106        }
107        Ok(())
108    }
109
110    /// Validate that a workspace ID is a 16-character base32 string (A-Z, 2-7).
111    ///
112    /// This prevents path traversal without depending on `cts_common::WorkspaceId`.
113    fn validate_workspace_id(id: &str) -> Result<(), ProfileError> {
114        let valid = id.len() == 16
115            && id
116                .bytes()
117                .all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b));
118        if valid {
119            Ok(())
120        } else {
121            Err(ProfileError::InvalidWorkspaceId(id.to_string()))
122        }
123    }
124
125    // ---- Workspace management ----
126
127    /// Set the current workspace.
128    ///
129    /// Writes the workspace ID to the `current_workspace` file in the profile
130    /// directory. The workspace must already have a directory under `workspaces/`
131    /// (created during login). Use [`init_workspace`](Self::init_workspace) to
132    /// create a new workspace directory.
133    ///
134    /// Returns [`ProfileError::WorkspaceNotFound`] if the workspace directory
135    /// does not exist.
136    pub fn set_current_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
137        Self::validate_workspace_id(workspace_id)?;
138        let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
139        if !ws_dir.is_dir() {
140            return Err(ProfileError::WorkspaceNotFound(workspace_id.to_string()));
141        }
142        std::fs::create_dir_all(&self.dir)?;
143        let path = self.dir.join(CURRENT_WORKSPACE_FILE);
144        std::fs::write(&path, workspace_id)?;
145        Ok(())
146    }
147
148    /// Create a workspace directory and set it as the current workspace.
149    ///
150    /// Unlike [`set_current_workspace`](Self::set_current_workspace), this
151    /// creates the workspace directory if it does not exist. Used during login
152    /// to initialize a new workspace.
153    pub fn init_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
154        Self::validate_workspace_id(workspace_id)?;
155        // create_dir_all creates self.dir and workspaces/ as ancestors.
156        let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
157        std::fs::create_dir_all(&ws_dir)?;
158        let path = self.dir.join(CURRENT_WORKSPACE_FILE);
159        std::fs::write(&path, workspace_id)?;
160        Ok(())
161    }
162
163    /// Return the current workspace ID.
164    ///
165    /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set.
166    pub fn current_workspace(&self) -> Result<String, ProfileError> {
167        let path = self.dir.join(CURRENT_WORKSPACE_FILE);
168        match std::fs::read_to_string(&path) {
169            Ok(contents) => {
170                let id = contents.trim().to_string();
171                Self::validate_workspace_id(&id)?;
172                Ok(id)
173            }
174            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
175                Err(ProfileError::NoCurrentWorkspace)
176            }
177            Err(e) => Err(ProfileError::Io(e)),
178        }
179    }
180
181    /// Remove the current workspace selection.
182    pub fn clear_current_workspace(&self) -> Result<(), ProfileError> {
183        let path = self.dir.join(CURRENT_WORKSPACE_FILE);
184        match std::fs::remove_file(&path) {
185            Ok(()) => Ok(()),
186            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
187            Err(e) => Err(ProfileError::Io(e)),
188        }
189    }
190
191    /// List workspace IDs that have profile data on disk.
192    ///
193    /// Returns a sorted list of workspace IDs that have subdirectories in
194    /// the `workspaces/` directory.
195    pub fn list_workspaces(&self) -> Result<Vec<String>, ProfileError> {
196        let ws_dir = self.dir.join(WORKSPACES_DIR);
197        match std::fs::read_dir(&ws_dir) {
198            Ok(entries) => {
199                let mut ids = Vec::new();
200                for entry in entries {
201                    let entry = entry?;
202                    if entry.file_type()?.is_dir() {
203                        if let Some(name) = entry.file_name().to_str() {
204                            if Self::validate_workspace_id(name).is_ok() {
205                                ids.push(name.to_string());
206                            }
207                        }
208                    }
209                }
210                ids.sort();
211                Ok(ids)
212            }
213            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
214            Err(e) => Err(ProfileError::Io(e)),
215        }
216    }
217
218    /// Return a [`ProfileStore`] scoped to a specific workspace directory.
219    ///
220    /// The returned store is rooted at `workspaces/<workspace_id>/` within this
221    /// store's directory. All `save`/`load`/`save_profile`/`load_profile` calls
222    /// on the returned store operate inside that workspace directory.
223    pub fn workspace_store(&self, workspace_id: &str) -> Result<ProfileStore, ProfileError> {
224        Self::validate_workspace_id(workspace_id)?;
225        Ok(ProfileStore::new(
226            self.dir.join(WORKSPACES_DIR).join(workspace_id),
227        ))
228    }
229
230    /// Return a [`ProfileStore`] scoped to the current workspace.
231    ///
232    /// Shortcut for `store.workspace_store(&store.current_workspace()?)`.
233    /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set.
234    pub fn current_workspace_store(&self) -> Result<ProfileStore, ProfileError> {
235        let id = self.current_workspace()?;
236        self.workspace_store(&id)
237    }
238
239    /// Move legacy flat-file profiles into a workspace directory.
240    ///
241    /// Moves `auth.json` and `secretkey.json` from the profile root into
242    /// `workspaces/<workspace_id>/` and sets `workspace_id` as the current
243    /// workspace. Files that already exist in the target are not overwritten.
244    /// Missing source files are silently skipped.
245    pub fn migrate_to_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> {
246        Self::validate_workspace_id(workspace_id)?;
247        let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id);
248        std::fs::create_dir_all(&ws_dir)?;
249
250        for filename in &["auth.json", "secretkey.json"] {
251            let src = self.dir.join(filename);
252            let dst = ws_dir.join(filename);
253            if src.exists() && !dst.exists() {
254                std::fs::rename(&src, &dst)?;
255            }
256        }
257
258        self.set_current_workspace(workspace_id)?;
259        Ok(())
260    }
261
262    // ---- Internal write helpers ----
263
264    fn write<T: Serialize>(
265        &self,
266        filename: &str,
267        value: &T,
268        _mode: Option<u32>,
269    ) -> Result<(), ProfileError> {
270        Self::validate_filename(filename)?;
271        std::fs::create_dir_all(&self.dir)?;
272        let path = self.dir.join(filename);
273        let json = serde_json::to_string_pretty(value)?;
274        Self::write_to_path(&path, &json, _mode)
275    }
276
277    /// Write JSON content to an absolute path, optionally setting Unix file permissions.
278    fn write_to_path(path: &Path, json: &str, _mode: Option<u32>) -> Result<(), ProfileError> {
279        #[cfg(unix)]
280        if let Some(mode) = _mode {
281            use std::fs::OpenOptions;
282            use std::io::Write;
283            use std::os::unix::fs::OpenOptionsExt;
284
285            let mut file = OpenOptions::new()
286                .write(true)
287                .create(true)
288                .truncate(true)
289                .mode(mode)
290                .open(path)?;
291            file.write_all(json.as_bytes())?;
292
293            // Ensure permissions are set even if the file already existed,
294            // since OpenOptions::mode() only applies on creation.
295            use std::os::unix::fs::PermissionsExt;
296            std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?;
297
298            return Ok(());
299        }
300
301        std::fs::write(path, json)?;
302        Ok(())
303    }
304
305    /// Load a value from a JSON file in the store directory.
306    ///
307    /// Returns [`ProfileError::NotFound`] if the file does not exist.
308    pub fn load<T: DeserializeOwned>(&self, filename: &str) -> Result<T, ProfileError> {
309        Self::validate_filename(filename)?;
310        let path = self.dir.join(filename);
311        match std::fs::read_to_string(&path) {
312            Ok(contents) => {
313                let value: T = serde_json::from_str(&contents)?;
314                Ok(value)
315            }
316            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
317                Err(ProfileError::NotFound { path })
318            }
319            Err(e) => Err(ProfileError::Io(e)),
320        }
321    }
322
323    /// Remove a file from the store directory.
324    ///
325    /// Does nothing if the file does not already exist.
326    pub fn clear(&self, filename: &str) -> Result<(), ProfileError> {
327        Self::validate_filename(filename)?;
328        let path = self.dir.join(filename);
329        match std::fs::remove_file(&path) {
330            Ok(()) => Ok(()),
331            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
332            Err(e) => Err(ProfileError::Io(e)),
333        }
334    }
335
336    /// Check whether a file exists in the store directory.
337    pub fn exists(&self, filename: &str) -> bool {
338        Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists()
339    }
340
341    /// Save a [`ProfileData`] value using its declared filename and mode.
342    pub fn save_profile<T: ProfileData>(&self, value: &T) -> Result<(), ProfileError> {
343        self.write(T::FILENAME, value, T::MODE)
344    }
345
346    /// Load a [`ProfileData`] value from its declared filename.
347    pub fn load_profile<T: ProfileData>(&self) -> Result<T, ProfileError> {
348        self.load(T::FILENAME)
349    }
350
351    /// Remove the file for a [`ProfileData`] type.
352    pub fn clear_profile<T: ProfileData>(&self) -> Result<(), ProfileError> {
353        self.clear(T::FILENAME)
354    }
355
356    /// Check whether the file for a [`ProfileData`] type exists.
357    pub fn exists_profile<T: ProfileData>(&self) -> bool {
358        self.exists(T::FILENAME)
359    }
360}
361
362/// Returns a profile store at `~/.cipherstash`.
363///
364/// # Panics
365///
366/// Panics if the home directory cannot be determined.
367impl Default for ProfileStore {
368    #[allow(clippy::expect_used)]
369    fn default() -> Self {
370        let home = dirs::home_dir().expect("could not determine home directory");
371        Self::new(home.join(DEFAULT_DIR_NAME))
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use serde::{Deserialize, Serialize};
379
380    #[derive(Debug, PartialEq, Serialize, Deserialize)]
381    struct TestData {
382        name: String,
383        value: u32,
384    }
385
386    #[test]
387    fn round_trip_save_and_load() {
388        let dir = tempfile::tempdir().unwrap();
389        let store = ProfileStore::new(dir.path());
390
391        let data = TestData {
392            name: "hello".into(),
393            value: 42,
394        };
395        store.save("data.json", &data).unwrap();
396
397        let loaded: TestData = store.load("data.json").unwrap();
398        assert_eq!(loaded, data);
399    }
400
401    #[test]
402    fn load_returns_not_found_for_missing_file() {
403        let dir = tempfile::tempdir().unwrap();
404        let store = ProfileStore::new(dir.path());
405
406        let err = store.load::<TestData>("missing.json").unwrap_err();
407        assert!(matches!(err, ProfileError::NotFound { .. }));
408    }
409
410    #[test]
411    fn clear_removes_existing_file() {
412        let dir = tempfile::tempdir().unwrap();
413        let store = ProfileStore::new(dir.path());
414
415        store
416            .save(
417                "data.json",
418                &TestData {
419                    name: "x".into(),
420                    value: 1,
421                },
422            )
423            .unwrap();
424        assert!(store.exists("data.json"));
425
426        store.clear("data.json").unwrap();
427        assert!(!store.exists("data.json"));
428    }
429
430    #[test]
431    fn clear_succeeds_for_missing_file() {
432        let dir = tempfile::tempdir().unwrap();
433        let store = ProfileStore::new(dir.path());
434        store.clear("missing.json").unwrap();
435    }
436
437    #[test]
438    fn save_creates_directory() {
439        let dir = tempfile::tempdir().unwrap();
440        let store = ProfileStore::new(dir.path().join("nested").join("dir"));
441
442        store
443            .save(
444                "data.json",
445                &TestData {
446                    name: "nested".into(),
447                    value: 99,
448                },
449            )
450            .unwrap();
451
452        let loaded: TestData = store.load("data.json").unwrap();
453        assert_eq!(loaded.name, "nested");
454    }
455
456    #[test]
457    fn exists_returns_false_for_missing_file() {
458        let dir = tempfile::tempdir().unwrap();
459        let store = ProfileStore::new(dir.path());
460        assert!(!store.exists("missing.json"));
461    }
462
463    #[test]
464    fn default_is_home_dot_cipherstash() {
465        let store = ProfileStore::default();
466        let home = dirs::home_dir().unwrap();
467        assert_eq!(store.dir(), home.join(".cipherstash"));
468    }
469
470    #[test]
471    fn resolve_explicit_overrides_all() {
472        let store = ProfileStore::resolve(Some("/tmp/custom".into())).unwrap();
473        assert_eq!(store.dir(), std::path::Path::new("/tmp/custom"));
474    }
475
476    mod filename_validation {
477        use super::*;
478
479        #[test]
480        fn rejects_empty_string() {
481            let dir = tempfile::tempdir().unwrap();
482            let store = ProfileStore::new(dir.path());
483
484            let err = store
485                .save(
486                    "",
487                    &TestData {
488                        name: "x".into(),
489                        value: 1,
490                    },
491                )
492                .unwrap_err();
493            assert!(matches!(err, ProfileError::InvalidFilename(_)));
494        }
495
496        #[test]
497        fn rejects_absolute_path() {
498            let dir = tempfile::tempdir().unwrap();
499            let store = ProfileStore::new(dir.path());
500
501            let err = store
502                .save(
503                    "/etc/passwd",
504                    &TestData {
505                        name: "x".into(),
506                        value: 1,
507                    },
508                )
509                .unwrap_err();
510            assert!(matches!(err, ProfileError::InvalidFilename(_)));
511        }
512
513        #[test]
514        fn rejects_parent_traversal() {
515            let dir = tempfile::tempdir().unwrap();
516            let store = ProfileStore::new(dir.path());
517
518            let err = store
519                .save(
520                    "../escape.json",
521                    &TestData {
522                        name: "x".into(),
523                        value: 1,
524                    },
525                )
526                .unwrap_err();
527            assert!(matches!(err, ProfileError::InvalidFilename(_)));
528        }
529
530        #[test]
531        fn rejects_path_with_separator() {
532            let dir = tempfile::tempdir().unwrap();
533            let store = ProfileStore::new(dir.path());
534
535            let err = store
536                .save(
537                    "sub/file.json",
538                    &TestData {
539                        name: "x".into(),
540                        value: 1,
541                    },
542                )
543                .unwrap_err();
544            assert!(matches!(err, ProfileError::InvalidFilename(_)));
545        }
546
547        #[test]
548        fn rejects_on_load() {
549            let dir = tempfile::tempdir().unwrap();
550            let store = ProfileStore::new(dir.path());
551
552            let err = store.load::<TestData>("../escape.json").unwrap_err();
553            assert!(matches!(err, ProfileError::InvalidFilename(_)));
554        }
555
556        #[test]
557        fn rejects_on_clear() {
558            let dir = tempfile::tempdir().unwrap();
559            let store = ProfileStore::new(dir.path());
560
561            let err = store.clear("../escape.json").unwrap_err();
562            assert!(matches!(err, ProfileError::InvalidFilename(_)));
563        }
564
565        #[test]
566        fn exists_returns_false_for_invalid_filename() {
567            let dir = tempfile::tempdir().unwrap();
568            let store = ProfileStore::new(dir.path());
569
570            assert!(!store.exists("../escape.json"));
571        }
572
573        #[test]
574        fn accepts_plain_filename() {
575            let dir = tempfile::tempdir().unwrap();
576            let store = ProfileStore::new(dir.path());
577
578            store
579                .save(
580                    "valid.json",
581                    &TestData {
582                        name: "ok".into(),
583                        value: 1,
584                    },
585                )
586                .unwrap();
587            let loaded: TestData = store.load("valid.json").unwrap();
588            assert_eq!(loaded.name, "ok");
589        }
590    }
591
592    #[cfg(unix)]
593    #[test]
594    fn save_with_mode_sets_permissions() {
595        use std::os::unix::fs::PermissionsExt;
596
597        let dir = tempfile::tempdir().unwrap();
598        let store = ProfileStore::new(dir.path());
599
600        store
601            .save_with_mode(
602                "secret.json",
603                &TestData {
604                    name: "secret".into(),
605                    value: 1,
606                },
607                0o600,
608            )
609            .unwrap();
610
611        let meta = std::fs::metadata(dir.path().join("secret.json")).unwrap();
612        let mode = meta.permissions().mode() & 0o777;
613        assert_eq!(mode, 0o600);
614    }
615
616    #[cfg(unix)]
617    #[test]
618    fn save_with_mode_tightens_existing_permissions() {
619        use std::os::unix::fs::PermissionsExt;
620
621        let dir = tempfile::tempdir().unwrap();
622        let store = ProfileStore::new(dir.path());
623        let path = dir.path().join("secret.json");
624
625        // Create file with broad permissions first
626        store
627            .save(
628                "secret.json",
629                &TestData {
630                    name: "v1".into(),
631                    value: 1,
632                },
633            )
634            .unwrap();
635        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
636
637        // Overwrite with restricted mode
638        store
639            .save_with_mode(
640                "secret.json",
641                &TestData {
642                    name: "v2".into(),
643                    value: 2,
644                },
645                0o600,
646            )
647            .unwrap();
648
649        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
650        assert_eq!(
651            mode, 0o600,
652            "permissions should be tightened on existing file"
653        );
654    }
655
656    mod workspace {
657        use super::*;
658        use crate::ProfileData;
659
660        const WS_A: &str = "AAAAAAAAAAAAAAAA";
661        const WS_B: &str = "BBBBBBBBBBBBBBBB";
662
663        #[derive(Debug, PartialEq, Serialize, Deserialize)]
664        struct WsData {
665            name: String,
666        }
667
668        impl ProfileData for WsData {
669            const FILENAME: &'static str = "ws-data.json";
670        }
671
672        mod given_no_workspace_set {
673            use super::*;
674
675            #[test]
676            fn current_workspace_returns_no_current_workspace() {
677                let dir = tempfile::tempdir().unwrap();
678                let store = ProfileStore::new(dir.path());
679
680                let err = store.current_workspace().unwrap_err();
681                assert!(
682                    matches!(err, ProfileError::NoCurrentWorkspace),
683                    "expected NoCurrentWorkspace, got: {err:?}"
684                );
685            }
686
687            #[test]
688            fn current_workspace_store_returns_no_current_workspace() {
689                let dir = tempfile::tempdir().unwrap();
690                let store = ProfileStore::new(dir.path());
691
692                let err = store.current_workspace_store().unwrap_err();
693                assert!(
694                    matches!(err, ProfileError::NoCurrentWorkspace),
695                    "expected NoCurrentWorkspace, got: {err:?}"
696                );
697            }
698
699            #[test]
700            fn clear_current_workspace_succeeds() {
701                let dir = tempfile::tempdir().unwrap();
702                let store = ProfileStore::new(dir.path());
703                store.clear_current_workspace().unwrap();
704            }
705
706            #[test]
707            fn set_current_workspace_returns_workspace_not_found() {
708                let dir = tempfile::tempdir().unwrap();
709                let store = ProfileStore::new(dir.path());
710
711                let err = store.set_current_workspace(WS_A).unwrap_err();
712                assert!(
713                    matches!(err, ProfileError::WorkspaceNotFound(_)),
714                    "expected WorkspaceNotFound, got: {err:?}"
715                );
716            }
717
718            #[test]
719            fn init_workspace_creates_dir_and_sets_current() {
720                let dir = tempfile::tempdir().unwrap();
721                let store = ProfileStore::new(dir.path());
722
723                store.init_workspace(WS_A).unwrap();
724                assert_eq!(
725                    store.current_workspace().unwrap(),
726                    WS_A,
727                    "init_workspace should set the current workspace"
728                );
729                assert!(
730                    dir.path().join("workspaces").join(WS_A).is_dir(),
731                    "init_workspace should create the workspace directory"
732                );
733            }
734        }
735
736        mod given_workspace_set {
737            use super::*;
738
739            fn scenario() -> (tempfile::TempDir, ProfileStore) {
740                let dir = tempfile::tempdir().unwrap();
741                let store = ProfileStore::new(dir.path());
742                store.init_workspace(WS_A).unwrap();
743                (dir, store)
744            }
745
746            #[test]
747            fn returns_workspace_id() {
748                let (_dir, store) = scenario();
749                assert_eq!(
750                    store.current_workspace().unwrap(),
751                    WS_A,
752                    "should return the workspace that was set"
753                );
754            }
755
756            #[test]
757            fn current_workspace_store_returns_scoped_store() {
758                let (dir, store) = scenario();
759                let ws_store = store.current_workspace_store().unwrap();
760                assert_eq!(
761                    ws_store.dir(),
762                    dir.path().join("workspaces").join(WS_A),
763                    "workspace store should be rooted in workspaces/<id>"
764                );
765            }
766
767            #[test]
768            fn clear_removes_selection() {
769                let (_dir, store) = scenario();
770                store.clear_current_workspace().unwrap();
771
772                let err = store.current_workspace().unwrap_err();
773                assert!(
774                    matches!(err, ProfileError::NoCurrentWorkspace),
775                    "expected NoCurrentWorkspace after clear, got: {err:?}"
776                );
777            }
778
779            #[test]
780            fn save_and_load_round_trips_through_workspace_store() {
781                let (dir, store) = scenario();
782                let ws_store = store.current_workspace_store().unwrap();
783
784                let data = WsData {
785                    name: "hello".into(),
786                };
787                ws_store.save_profile(&data).unwrap();
788
789                let loaded: WsData = ws_store.load_profile().unwrap();
790                assert_eq!(loaded, data, "workspace store should round-trip data");
791
792                assert!(
793                    dir.path()
794                        .join("workspaces")
795                        .join(WS_A)
796                        .join("ws-data.json")
797                        .exists(),
798                    "file should be in the workspace directory"
799                );
800                assert!(
801                    !store.exists_profile::<WsData>(),
802                    "root store should not see workspace-scoped file"
803                );
804            }
805        }
806
807        mod given_multiple_workspaces {
808            use super::*;
809
810            fn scenario() -> (tempfile::TempDir, ProfileStore) {
811                let dir = tempfile::tempdir().unwrap();
812                let store = ProfileStore::new(dir.path());
813
814                store
815                    .workspace_store(WS_A)
816                    .unwrap()
817                    .save_profile(&WsData {
818                        name: "alpha".into(),
819                    })
820                    .unwrap();
821                store
822                    .workspace_store(WS_B)
823                    .unwrap()
824                    .save_profile(&WsData {
825                        name: "bravo".into(),
826                    })
827                    .unwrap();
828
829                (dir, store)
830            }
831
832            #[test]
833            fn switching_changes_current_workspace_store_data() {
834                let (_dir, store) = scenario();
835
836                store.set_current_workspace(WS_A).unwrap();
837                let loaded: WsData = store
838                    .current_workspace_store()
839                    .unwrap()
840                    .load_profile()
841                    .unwrap();
842                assert_eq!(
843                    loaded.name, "alpha",
844                    "should load workspace A data after switching to A"
845                );
846
847                store.set_current_workspace(WS_B).unwrap();
848                let loaded: WsData = store
849                    .current_workspace_store()
850                    .unwrap()
851                    .load_profile()
852                    .unwrap();
853                assert_eq!(
854                    loaded.name, "bravo",
855                    "should load workspace B data after switching to B"
856                );
857            }
858
859            #[test]
860            fn list_workspaces_returns_sorted_ids() {
861                let (_dir, store) = scenario();
862
863                let workspaces = store.list_workspaces().unwrap();
864                assert_eq!(
865                    workspaces,
866                    vec![WS_A, WS_B],
867                    "should list both workspaces in sorted order"
868                );
869            }
870        }
871
872        mod list_workspaces {
873            use super::*;
874
875            #[test]
876            fn returns_empty_when_no_workspaces_dir() {
877                let dir = tempfile::tempdir().unwrap();
878                let store = ProfileStore::new(dir.path());
879                assert_eq!(
880                    store.list_workspaces().unwrap(),
881                    Vec::<String>::new(),
882                    "should return empty list when workspaces/ does not exist"
883                );
884            }
885
886            #[test]
887            fn ignores_files_and_invalid_dirs() {
888                let dir = tempfile::tempdir().unwrap();
889                let store = ProfileStore::new(dir.path());
890
891                let ws_dir = dir.path().join("workspaces");
892                std::fs::create_dir_all(&ws_dir).unwrap();
893                std::fs::create_dir(ws_dir.join(WS_A)).unwrap();
894                std::fs::write(ws_dir.join("not-a-dir.txt"), "").unwrap();
895                std::fs::create_dir(ws_dir.join("invalid-name")).unwrap();
896
897                let workspaces = store.list_workspaces().unwrap();
898                assert_eq!(
899                    workspaces,
900                    vec![WS_A],
901                    "should only include valid workspace directories"
902                );
903            }
904        }
905
906        mod workspace_store {
907            use super::*;
908
909            #[test]
910            fn returns_scoped_store() {
911                let dir = tempfile::tempdir().unwrap();
912                let store = ProfileStore::new(dir.path());
913
914                let ws_store = store.workspace_store(WS_A).unwrap();
915                assert_eq!(
916                    ws_store.dir(),
917                    dir.path().join("workspaces").join(WS_A),
918                    "workspace store should be rooted in workspaces/<id>"
919                );
920            }
921
922            #[test]
923            fn rejects_invalid_id() {
924                let dir = tempfile::tempdir().unwrap();
925                let store = ProfileStore::new(dir.path());
926
927                let err = store.workspace_store("../escape").unwrap_err();
928                assert!(
929                    matches!(err, ProfileError::InvalidWorkspaceId(_)),
930                    "expected InvalidWorkspaceId for path traversal, got: {err:?}"
931                );
932            }
933        }
934
935        mod validate_workspace_id {
936            use super::*;
937
938            #[test]
939            fn accepts_valid_base32() {
940                ProfileStore::validate_workspace_id("ABCDEFGH234567AB").unwrap();
941                ProfileStore::validate_workspace_id(WS_A).unwrap();
942            }
943
944            #[test]
945            fn rejects_lowercase() {
946                let err = ProfileStore::validate_workspace_id("abcdefgh234567ab").unwrap_err();
947                assert!(
948                    matches!(err, ProfileError::InvalidWorkspaceId(_)),
949                    "expected InvalidWorkspaceId for lowercase, got: {err:?}"
950                );
951            }
952
953            #[test]
954            fn rejects_wrong_length() {
955                let err = ProfileStore::validate_workspace_id("SHORT").unwrap_err();
956                assert!(
957                    matches!(err, ProfileError::InvalidWorkspaceId(_)),
958                    "expected InvalidWorkspaceId for short string, got: {err:?}"
959                );
960            }
961
962            #[test]
963            fn rejects_empty() {
964                let err = ProfileStore::validate_workspace_id("").unwrap_err();
965                assert!(
966                    matches!(err, ProfileError::InvalidWorkspaceId(_)),
967                    "expected InvalidWorkspaceId for empty string, got: {err:?}"
968                );
969            }
970
971            #[test]
972            fn rejects_path_traversal() {
973                let err = ProfileStore::validate_workspace_id("../escape.json..").unwrap_err();
974                assert!(
975                    matches!(err, ProfileError::InvalidWorkspaceId(_)),
976                    "expected InvalidWorkspaceId for path traversal, got: {err:?}"
977                );
978            }
979
980            #[test]
981            fn rejects_non_base32_digits() {
982                let err = ProfileStore::validate_workspace_id("0000000000000000").unwrap_err();
983                assert!(
984                    matches!(err, ProfileError::InvalidWorkspaceId(_)),
985                    "expected InvalidWorkspaceId for digits outside base32 alphabet, got: {err:?}"
986                );
987            }
988        }
989
990        mod migrate_to_workspace {
991            use super::*;
992
993            mod given_legacy_flat_files {
994                use super::*;
995
996                fn scenario() -> (tempfile::TempDir, ProfileStore) {
997                    let dir = tempfile::tempdir().unwrap();
998                    let store = ProfileStore::new(dir.path());
999                    std::fs::create_dir_all(dir.path()).unwrap();
1000                    std::fs::write(dir.path().join("auth.json"), r#"{"token":"old"}"#).unwrap();
1001                    std::fs::write(dir.path().join("secretkey.json"), r#"{"key":"old"}"#).unwrap();
1002                    (dir, store)
1003                }
1004
1005                #[test]
1006                fn moves_files_to_workspace_dir() {
1007                    let (dir, store) = scenario();
1008                    store.migrate_to_workspace(WS_A).unwrap();
1009
1010                    assert!(
1011                        !dir.path().join("auth.json").exists(),
1012                        "legacy auth.json should be removed from root"
1013                    );
1014                    assert!(
1015                        !dir.path().join("secretkey.json").exists(),
1016                        "legacy secretkey.json should be removed from root"
1017                    );
1018
1019                    let ws_dir = dir.path().join("workspaces").join(WS_A);
1020                    assert!(
1021                        ws_dir.join("auth.json").exists(),
1022                        "auth.json should be in workspace dir"
1023                    );
1024                    assert!(
1025                        ws_dir.join("secretkey.json").exists(),
1026                        "secretkey.json should be in workspace dir"
1027                    );
1028                }
1029
1030                #[test]
1031                fn sets_current_workspace() {
1032                    let (_dir, store) = scenario();
1033                    store.migrate_to_workspace(WS_A).unwrap();
1034                    assert_eq!(
1035                        store.current_workspace().unwrap(),
1036                        WS_A,
1037                        "current workspace should be set after migration"
1038                    );
1039                }
1040            }
1041
1042            mod given_existing_files_in_target {
1043                use super::*;
1044
1045                #[test]
1046                fn does_not_overwrite() {
1047                    let dir = tempfile::tempdir().unwrap();
1048                    let store = ProfileStore::new(dir.path());
1049
1050                    std::fs::create_dir_all(dir.path()).unwrap();
1051                    std::fs::write(dir.path().join("auth.json"), r#"{"token":"legacy"}"#).unwrap();
1052
1053                    let ws_dir = dir.path().join("workspaces").join(WS_A);
1054                    std::fs::create_dir_all(&ws_dir).unwrap();
1055                    std::fs::write(ws_dir.join("auth.json"), r#"{"token":"existing"}"#).unwrap();
1056
1057                    store.migrate_to_workspace(WS_A).unwrap();
1058
1059                    let contents = std::fs::read_to_string(ws_dir.join("auth.json")).unwrap();
1060                    assert!(
1061                        contents.contains("existing"),
1062                        "workspace file should be unchanged, got: {contents}"
1063                    );
1064                    assert!(
1065                        dir.path().join("auth.json").exists(),
1066                        "legacy file should remain when target exists"
1067                    );
1068                }
1069            }
1070
1071            mod given_no_legacy_files {
1072                use super::*;
1073
1074                #[test]
1075                fn sets_current_workspace() {
1076                    let dir = tempfile::tempdir().unwrap();
1077                    let store = ProfileStore::new(dir.path());
1078
1079                    store.migrate_to_workspace(WS_A).unwrap();
1080                    assert_eq!(
1081                        store.current_workspace().unwrap(),
1082                        WS_A,
1083                        "should set current workspace even without legacy files"
1084                    );
1085                }
1086            }
1087        }
1088    }
1089}