Skip to main content

maw/model/
types.rs

1//! Core workspace types for Manifold.
2//!
3//! Foundation types used throughout Manifold: workspace identifiers, epoch
4//! identifiers, git object IDs, workspace state, and workspace info.
5
6use std::fmt;
7use std::path::PathBuf;
8use std::str::FromStr;
9
10use serde::{Deserialize, Serialize};
11
12// ---------------------------------------------------------------------------
13// GitOid
14// ---------------------------------------------------------------------------
15
16/// A validated 40-character lowercase hex Git object ID (SHA-1).
17#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(try_from = "String", into = "String")]
19pub struct GitOid(String);
20
21impl GitOid {
22    /// Create a new `GitOid` from a string, validating format.
23    ///
24    /// # Errors
25    /// Returns an error if the string is not exactly 40 lowercase hex characters.
26    pub fn new(s: &str) -> Result<Self, ValidationError> {
27        Self::validate(s)?;
28        Ok(Self(s.to_owned()))
29    }
30
31    /// Return the inner hex string.
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36
37    fn validate(s: &str) -> Result<(), ValidationError> {
38        if s.len() != 40 {
39            return Err(ValidationError {
40                kind: ErrorKind::GitOid,
41                value: s.to_owned(),
42                reason: format!("expected 40 hex characters, got {}", s.len()),
43            });
44        }
45        if !s
46            .chars()
47            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
48        {
49            return Err(ValidationError {
50                kind: ErrorKind::GitOid,
51                value: s.to_owned(),
52                reason: "must contain only lowercase hex characters (0-9, a-f)".to_owned(),
53            });
54        }
55        Ok(())
56    }
57}
58
59impl fmt::Display for GitOid {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(&self.0)
62    }
63}
64
65impl FromStr for GitOid {
66    type Err = ValidationError;
67    fn from_str(s: &str) -> Result<Self, Self::Err> {
68        Self::new(s)
69    }
70}
71
72impl TryFrom<String> for GitOid {
73    type Error = ValidationError;
74    fn try_from(s: String) -> Result<Self, Self::Error> {
75        Self::validate(&s)?;
76        Ok(Self(s))
77    }
78}
79
80impl From<GitOid> for String {
81    fn from(oid: GitOid) -> Self {
82        oid.0
83    }
84}
85
86// ---------------------------------------------------------------------------
87// EpochId
88// ---------------------------------------------------------------------------
89
90/// An epoch identifier — a newtype over [`GitOid`] representing a specific
91/// immutable snapshot (epoch) of the repository mainline.
92#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
93#[serde(try_from = "String", into = "String")]
94pub struct EpochId(GitOid);
95
96impl EpochId {
97    /// Create a new `EpochId` from a hex string.
98    ///
99    /// # Errors
100    /// Returns an error if the string is not a valid git OID.
101    pub fn new(s: &str) -> Result<Self, ValidationError> {
102        let oid = GitOid::new(s).map_err(|mut e| {
103            e.kind = ErrorKind::EpochId;
104            e
105        })?;
106        Ok(Self(oid))
107    }
108
109    /// Return the inner [`GitOid`].
110    #[must_use]
111    pub const fn oid(&self) -> &GitOid {
112        &self.0
113    }
114
115    /// Return the hex string.
116    #[must_use]
117    pub fn as_str(&self) -> &str {
118        self.0.as_str()
119    }
120}
121
122impl fmt::Display for EpochId {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        fmt::Display::fmt(&self.0, f)
125    }
126}
127
128impl FromStr for EpochId {
129    type Err = ValidationError;
130    fn from_str(s: &str) -> Result<Self, Self::Err> {
131        Self::new(s)
132    }
133}
134
135impl TryFrom<String> for EpochId {
136    type Error = ValidationError;
137    fn try_from(s: String) -> Result<Self, Self::Error> {
138        GitOid::validate(&s).map_err(|mut e| {
139            e.kind = ErrorKind::EpochId;
140            e
141        })?;
142        Ok(Self(GitOid(s)))
143    }
144}
145
146impl From<EpochId> for String {
147    fn from(epoch: EpochId) -> Self {
148        epoch.0.into()
149    }
150}
151
152// ---------------------------------------------------------------------------
153// WorkspaceId
154// ---------------------------------------------------------------------------
155
156/// A validated workspace identifier.
157///
158/// Workspace names must be lowercase alphanumeric with hyphens, 1–64 characters.
159/// Examples: `agent-1`, `feature-auth`, `bugfix-123`.
160#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
161#[serde(try_from = "String", into = "String")]
162pub struct WorkspaceId(String);
163
164impl WorkspaceId {
165    /// The maximum length of a workspace name.
166    pub const MAX_LEN: usize = 64;
167
168    /// Create a new `WorkspaceId` from a string, validating format.
169    ///
170    /// # Errors
171    /// Returns an error if the name is empty, too long, or contains invalid characters.
172    pub fn new(s: &str) -> Result<Self, ValidationError> {
173        Self::validate(s)?;
174        Ok(Self(s.to_owned()))
175    }
176
177    /// Return the workspace name as a string.
178    #[must_use]
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182
183    fn validate(s: &str) -> Result<(), ValidationError> {
184        if s.is_empty() {
185            return Err(ValidationError {
186                kind: ErrorKind::WorkspaceId,
187                value: s.to_owned(),
188                reason: "workspace name must not be empty".to_owned(),
189            });
190        }
191        if s.len() > Self::MAX_LEN {
192            return Err(ValidationError {
193                kind: ErrorKind::WorkspaceId,
194                value: s.to_owned(),
195                reason: format!(
196                    "workspace name must be at most {} characters, got {}",
197                    Self::MAX_LEN,
198                    s.len()
199                ),
200            });
201        }
202        if s.starts_with('-') || s.ends_with('-') {
203            return Err(ValidationError {
204                kind: ErrorKind::WorkspaceId,
205                value: s.to_owned(),
206                reason: "workspace name must not start or end with a hyphen".to_owned(),
207            });
208        }
209        if !s
210            .chars()
211            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
212        {
213            return Err(ValidationError {
214                kind: ErrorKind::WorkspaceId,
215                value: s.to_owned(),
216                reason: "workspace name must contain only lowercase letters (a-z), digits (0-9), and hyphens (-)".to_owned(),
217            });
218        }
219        if s.contains("--") {
220            return Err(ValidationError {
221                kind: ErrorKind::WorkspaceId,
222                value: s.to_owned(),
223                reason: "workspace name must not contain consecutive hyphens".to_owned(),
224            });
225        }
226        Ok(())
227    }
228}
229
230impl fmt::Display for WorkspaceId {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        f.write_str(&self.0)
233    }
234}
235
236impl FromStr for WorkspaceId {
237    type Err = ValidationError;
238    fn from_str(s: &str) -> Result<Self, Self::Err> {
239        Self::new(s)
240    }
241}
242
243impl TryFrom<String> for WorkspaceId {
244    type Error = ValidationError;
245    fn try_from(s: String) -> Result<Self, Self::Error> {
246        Self::validate(&s)?;
247        Ok(Self(s))
248    }
249}
250
251impl From<WorkspaceId> for String {
252    fn from(id: WorkspaceId) -> Self {
253        id.0
254    }
255}
256
257// ---------------------------------------------------------------------------
258// WorkspaceState
259// ---------------------------------------------------------------------------
260
261/// The state of a workspace relative to the current epoch.
262#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
263#[serde(tag = "state", rename_all = "snake_case")]
264pub enum WorkspaceState {
265    /// Workspace is up-to-date with the current epoch.
266    Active,
267    /// Workspace is behind the current epoch by some number of epochs.
268    Stale {
269        /// Number of epoch advancements since this workspace was last synced.
270        behind_epochs: u32,
271    },
272    /// Workspace has been destroyed (metadata retained for history).
273    Destroyed,
274}
275
276impl WorkspaceState {
277    /// Returns `true` if the workspace is active.
278    #[must_use]
279    pub const fn is_active(&self) -> bool {
280        matches!(self, Self::Active)
281    }
282
283    /// Returns `true` if the workspace is stale.
284    #[must_use]
285    pub const fn is_stale(&self) -> bool {
286        matches!(self, Self::Stale { .. })
287    }
288
289    /// Returns `true` if the workspace is destroyed.
290    #[must_use]
291    pub const fn is_destroyed(&self) -> bool {
292        matches!(self, Self::Destroyed)
293    }
294}
295
296impl fmt::Display for WorkspaceState {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        match self {
299            Self::Active => write!(f, "active"),
300            Self::Stale { behind_epochs } => {
301                write!(f, "stale (behind by {behind_epochs} epoch(s))")
302            }
303            Self::Destroyed => write!(f, "destroyed"),
304        }
305    }
306}
307
308// ---------------------------------------------------------------------------
309// WorkspaceMode
310// ---------------------------------------------------------------------------
311
312/// The lifetime mode of a workspace.
313///
314/// - **Ephemeral** (default): Created from the current epoch, must be merged
315///   or destroyed before the next epoch advance. Warns if it survives epochs.
316/// - **Persistent** (opt-in): Can survive across epochs. Supports explicit
317///   `maw ws advance <name>` to rebase onto the latest epoch.
318#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum WorkspaceMode {
321    /// Default: workspace should be merged or destroyed before epoch advances.
322    #[default]
323    Ephemeral,
324    /// Opt-in: workspace can survive across epochs; advance explicitly.
325    Persistent,
326}
327
328impl WorkspaceMode {
329    /// Returns `true` if this is a persistent workspace.
330    #[must_use]
331    pub const fn is_persistent(&self) -> bool {
332        matches!(self, Self::Persistent)
333    }
334
335    /// Returns `true` if this is an ephemeral workspace.
336    #[must_use]
337    pub const fn is_ephemeral(&self) -> bool {
338        matches!(self, Self::Ephemeral)
339    }
340}
341
342impl fmt::Display for WorkspaceMode {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        match self {
345            Self::Ephemeral => write!(f, "ephemeral"),
346            Self::Persistent => write!(f, "persistent"),
347        }
348    }
349}
350
351// ---------------------------------------------------------------------------
352// WorkspaceInfo
353// ---------------------------------------------------------------------------
354
355/// Complete information about a workspace.
356#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
357pub struct WorkspaceInfo {
358    /// Unique workspace identifier.
359    pub id: WorkspaceId,
360    /// Absolute path to the workspace root directory.
361    pub path: PathBuf,
362    /// The epoch this workspace is based on (or the workspace HEAD if ahead of epoch).
363    pub epoch: EpochId,
364    /// Current state of the workspace.
365    pub state: WorkspaceState,
366    /// Lifetime mode: ephemeral (default) or persistent.
367    #[serde(default)]
368    pub mode: WorkspaceMode,
369    /// Number of commits in the workspace that are ahead of the current epoch.
370    /// Non-zero means the workspace has committed work that hasn't been merged yet.
371    #[serde(default)]
372    pub commits_ahead: u32,
373}
374
375// ---------------------------------------------------------------------------
376// Validation errors
377// ---------------------------------------------------------------------------
378
379/// The kind of value that failed validation.
380#[derive(Clone, Debug, PartialEq, Eq)]
381pub enum ErrorKind {
382    /// A [`GitOid`] validation error.
383    GitOid,
384    /// An [`EpochId`] validation error.
385    EpochId,
386    /// A [`WorkspaceId`] validation error.
387    WorkspaceId,
388}
389
390impl fmt::Display for ErrorKind {
391    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392        match self {
393            Self::GitOid => write!(f, "GitOid"),
394            Self::EpochId => write!(f, "EpochId"),
395            Self::WorkspaceId => write!(f, "WorkspaceId"),
396        }
397    }
398}
399
400/// A validation error for Manifold core types.
401#[derive(Clone, Debug, PartialEq, Eq)]
402pub struct ValidationError {
403    /// What kind of value was being validated.
404    pub kind: ErrorKind,
405    /// The invalid value.
406    pub value: String,
407    /// Human-readable explanation.
408    pub reason: String,
409}
410
411impl fmt::Display for ValidationError {
412    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413        write!(
414            f,
415            "invalid {}: {:?} — {}",
416            self.kind, self.value, self.reason
417        )
418    }
419}
420
421impl std::error::Error for ValidationError {}
422
423// ---------------------------------------------------------------------------
424// Tests
425// ---------------------------------------------------------------------------
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    // -- GitOid --
432
433    #[test]
434    fn git_oid_valid() {
435        let hex = "a".repeat(40);
436        let oid = GitOid::new(&hex).unwrap();
437        assert_eq!(oid.as_str(), hex);
438    }
439
440    #[test]
441    fn git_oid_mixed_hex() {
442        let hex = "0123456789abcdef0123456789abcdef01234567";
443        assert!(GitOid::new(hex).is_ok());
444    }
445
446    #[test]
447    fn git_oid_rejects_short() {
448        assert!(GitOid::new("abc123").is_err());
449    }
450
451    #[test]
452    fn git_oid_rejects_long() {
453        let hex = "a".repeat(41);
454        assert!(GitOid::new(&hex).is_err());
455    }
456
457    #[test]
458    fn git_oid_rejects_uppercase() {
459        let hex = "A".repeat(40);
460        assert!(GitOid::new(&hex).is_err());
461    }
462
463    #[test]
464    fn git_oid_rejects_non_hex() {
465        let bad = "g".repeat(40);
466        assert!(GitOid::new(&bad).is_err());
467    }
468
469    #[test]
470    fn git_oid_display() {
471        let hex = "b".repeat(40);
472        let oid = GitOid::new(&hex).unwrap();
473        assert_eq!(format!("{oid}"), hex);
474    }
475
476    #[test]
477    fn git_oid_from_str() {
478        let hex = "c".repeat(40);
479        let oid: GitOid = hex.parse().unwrap();
480        assert_eq!(oid.as_str(), hex);
481    }
482
483    #[test]
484    fn git_oid_serde_roundtrip() {
485        let hex = "d".repeat(40);
486        let oid = GitOid::new(&hex).unwrap();
487        let json = serde_json::to_string(&oid).unwrap();
488        assert_eq!(json, format!("\"{hex}\""));
489        let decoded: GitOid = serde_json::from_str(&json).unwrap();
490        assert_eq!(decoded, oid);
491    }
492
493    #[test]
494    fn git_oid_serde_rejects_invalid() {
495        let json = "\"not-a-valid-oid\"";
496        assert!(serde_json::from_str::<GitOid>(json).is_err());
497    }
498
499    // -- EpochId --
500
501    #[test]
502    fn epoch_id_valid() {
503        let hex = "1".repeat(40);
504        let epoch = EpochId::new(&hex).unwrap();
505        assert_eq!(epoch.as_str(), hex);
506        assert_eq!(epoch.oid().as_str(), hex);
507    }
508
509    #[test]
510    fn epoch_id_rejects_invalid() {
511        assert!(EpochId::new("short").is_err());
512    }
513
514    #[test]
515    fn epoch_id_error_kind() {
516        let err = EpochId::new("bad").unwrap_err();
517        assert_eq!(err.kind, ErrorKind::EpochId);
518    }
519
520    #[test]
521    fn epoch_id_display() {
522        let hex = "2".repeat(40);
523        let epoch = EpochId::new(&hex).unwrap();
524        assert_eq!(format!("{epoch}"), hex);
525    }
526
527    #[test]
528    fn epoch_id_serde_roundtrip() {
529        let hex = "3".repeat(40);
530        let epoch = EpochId::new(&hex).unwrap();
531        let json = serde_json::to_string(&epoch).unwrap();
532        let decoded: EpochId = serde_json::from_str(&json).unwrap();
533        assert_eq!(decoded, epoch);
534    }
535
536    // -- WorkspaceId --
537
538    #[test]
539    fn workspace_id_valid_simple() {
540        let id = WorkspaceId::new("agent-1").unwrap();
541        assert_eq!(id.as_str(), "agent-1");
542    }
543
544    #[test]
545    fn workspace_id_valid_letters() {
546        assert!(WorkspaceId::new("default").is_ok());
547    }
548
549    #[test]
550    fn workspace_id_valid_digits() {
551        assert!(WorkspaceId::new("123").is_ok());
552    }
553
554    #[test]
555    fn workspace_id_valid_mixed() {
556        assert!(WorkspaceId::new("feature-auth-2").is_ok());
557    }
558
559    #[test]
560    fn workspace_id_rejects_empty() {
561        let err = WorkspaceId::new("").unwrap_err();
562        assert_eq!(err.kind, ErrorKind::WorkspaceId);
563    }
564
565    #[test]
566    fn workspace_id_rejects_uppercase() {
567        assert!(WorkspaceId::new("Agent-1").is_err());
568    }
569
570    #[test]
571    fn workspace_id_rejects_underscore() {
572        assert!(WorkspaceId::new("agent_1").is_err());
573    }
574
575    #[test]
576    fn workspace_id_rejects_leading_hyphen() {
577        assert!(WorkspaceId::new("-agent").is_err());
578    }
579
580    #[test]
581    fn workspace_id_rejects_trailing_hyphen() {
582        assert!(WorkspaceId::new("agent-").is_err());
583    }
584
585    #[test]
586    fn workspace_id_rejects_consecutive_hyphens() {
587        assert!(WorkspaceId::new("agent--1").is_err());
588    }
589
590    #[test]
591    fn workspace_id_rejects_too_long() {
592        let long = "a".repeat(65);
593        assert!(WorkspaceId::new(&long).is_err());
594    }
595
596    #[test]
597    fn workspace_id_max_length_ok() {
598        let max = "a".repeat(64);
599        assert!(WorkspaceId::new(&max).is_ok());
600    }
601
602    #[test]
603    fn workspace_id_display() {
604        let id = WorkspaceId::new("test-ws").unwrap();
605        assert_eq!(format!("{id}"), "test-ws");
606    }
607
608    #[test]
609    fn workspace_id_serde_roundtrip() {
610        let id = WorkspaceId::new("my-workspace").unwrap();
611        let json = serde_json::to_string(&id).unwrap();
612        assert_eq!(json, "\"my-workspace\"");
613        let decoded: WorkspaceId = serde_json::from_str(&json).unwrap();
614        assert_eq!(decoded, id);
615    }
616
617    #[test]
618    fn workspace_id_serde_rejects_invalid() {
619        let json = "\"INVALID\"";
620        assert!(serde_json::from_str::<WorkspaceId>(json).is_err());
621    }
622
623    // -- WorkspaceState --
624
625    #[test]
626    fn workspace_state_active() {
627        let state = WorkspaceState::Active;
628        assert!(state.is_active());
629        assert!(!state.is_stale());
630        assert!(!state.is_destroyed());
631    }
632
633    #[test]
634    fn workspace_state_stale() {
635        let state = WorkspaceState::Stale { behind_epochs: 3 };
636        assert!(!state.is_active());
637        assert!(state.is_stale());
638        assert!(!state.is_destroyed());
639    }
640
641    #[test]
642    fn workspace_state_destroyed() {
643        let state = WorkspaceState::Destroyed;
644        assert!(!state.is_active());
645        assert!(!state.is_stale());
646        assert!(state.is_destroyed());
647    }
648
649    #[test]
650    fn workspace_state_display() {
651        assert_eq!(format!("{}", WorkspaceState::Active), "active");
652        assert_eq!(
653            format!("{}", WorkspaceState::Stale { behind_epochs: 2 }),
654            "stale (behind by 2 epoch(s))"
655        );
656        assert_eq!(format!("{}", WorkspaceState::Destroyed), "destroyed");
657    }
658
659    #[test]
660    fn workspace_state_serde_roundtrip() {
661        let states = vec![
662            WorkspaceState::Active,
663            WorkspaceState::Stale { behind_epochs: 5 },
664            WorkspaceState::Destroyed,
665        ];
666        for state in states {
667            let json = serde_json::to_string(&state).unwrap();
668            let decoded: WorkspaceState = serde_json::from_str(&json).unwrap();
669            assert_eq!(decoded, state);
670        }
671    }
672
673    #[test]
674    fn workspace_state_serde_tagged() {
675        let json = serde_json::to_string(&WorkspaceState::Active).unwrap();
676        assert!(json.contains("\"state\":\"active\""));
677
678        let json = serde_json::to_string(&WorkspaceState::Stale { behind_epochs: 1 }).unwrap();
679        assert!(json.contains("\"state\":\"stale\""));
680        assert!(json.contains("\"behind_epochs\":1"));
681    }
682
683    // -- WorkspaceMode --
684
685    #[test]
686    fn workspace_mode_ephemeral() {
687        let mode = WorkspaceMode::Ephemeral;
688        assert!(mode.is_ephemeral());
689        assert!(!mode.is_persistent());
690        assert_eq!(format!("{mode}"), "ephemeral");
691    }
692
693    #[test]
694    fn workspace_mode_persistent() {
695        let mode = WorkspaceMode::Persistent;
696        assert!(mode.is_persistent());
697        assert!(!mode.is_ephemeral());
698        assert_eq!(format!("{mode}"), "persistent");
699    }
700
701    #[test]
702    fn workspace_mode_default_is_ephemeral() {
703        let mode = WorkspaceMode::default();
704        assert!(mode.is_ephemeral());
705    }
706
707    #[test]
708    fn workspace_mode_serde_roundtrip() {
709        for mode in [WorkspaceMode::Ephemeral, WorkspaceMode::Persistent] {
710            let json = serde_json::to_string(&mode).unwrap();
711            let decoded: WorkspaceMode = serde_json::from_str(&json).unwrap();
712            assert_eq!(decoded, mode);
713        }
714    }
715
716    // -- WorkspaceInfo --
717
718    #[test]
719    fn workspace_info_construction() {
720        let info = WorkspaceInfo {
721            id: WorkspaceId::new("test").unwrap(),
722            path: PathBuf::from("/tmp/ws/test"),
723            epoch: EpochId::new(&"a".repeat(40)).unwrap(),
724            state: WorkspaceState::Active,
725            mode: WorkspaceMode::Ephemeral,
726            commits_ahead: 0,
727        };
728        assert_eq!(info.id.as_str(), "test");
729        assert_eq!(info.path, PathBuf::from("/tmp/ws/test"));
730        assert!(info.state.is_active());
731        assert!(info.mode.is_ephemeral());
732    }
733
734    #[test]
735    fn workspace_info_persistent_mode() {
736        let info = WorkspaceInfo {
737            id: WorkspaceId::new("agent-1").unwrap(),
738            path: PathBuf::from("/repo/ws/agent-1"),
739            epoch: EpochId::new(&"f".repeat(40)).unwrap(),
740            state: WorkspaceState::Active,
741            mode: WorkspaceMode::Persistent,
742            commits_ahead: 0,
743        };
744        assert!(info.mode.is_persistent());
745    }
746
747    #[test]
748    fn workspace_info_serde_roundtrip() {
749        let info = WorkspaceInfo {
750            id: WorkspaceId::new("agent-1").unwrap(),
751            path: PathBuf::from("/repo/ws/agent-1"),
752            epoch: EpochId::new(&"f".repeat(40)).unwrap(),
753            state: WorkspaceState::Stale { behind_epochs: 2 },
754            mode: WorkspaceMode::Persistent,
755            commits_ahead: 0,
756        };
757        let json = serde_json::to_string(&info).unwrap();
758        let decoded: WorkspaceInfo = serde_json::from_str(&json).unwrap();
759        assert_eq!(decoded, info);
760    }
761
762    #[test]
763    fn workspace_info_serde_default_mode() {
764        // mode field has default, so old JSON without it deserializes to Ephemeral
765        let json = r#"{"id":"test","path":"/tmp/ws/test","epoch":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","state":{"state":"active"}}"#;
766        let info: WorkspaceInfo = serde_json::from_str(json).unwrap();
767        assert!(info.mode.is_ephemeral());
768    }
769
770    // -- ValidationError --
771
772    #[test]
773    fn validation_error_display() {
774        let err = ValidationError {
775            kind: ErrorKind::WorkspaceId,
776            value: "BAD".to_owned(),
777            reason: "must be lowercase".to_owned(),
778        };
779        let msg = format!("{err}");
780        assert!(msg.contains("WorkspaceId"));
781        assert!(msg.contains("BAD"));
782        assert!(msg.contains("must be lowercase"));
783    }
784}