1use std::fmt;
7use std::path::PathBuf;
8use std::str::FromStr;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(try_from = "String", into = "String")]
19pub struct GitOid(String);
20
21impl GitOid {
22 pub fn new(s: &str) -> Result<Self, ValidationError> {
27 Self::validate(s)?;
28 Ok(Self(s.to_owned()))
29 }
30
31 #[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#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
93#[serde(try_from = "String", into = "String")]
94pub struct EpochId(GitOid);
95
96impl EpochId {
97 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 #[must_use]
111 pub const fn oid(&self) -> &GitOid {
112 &self.0
113 }
114
115 #[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#[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 pub const MAX_LEN: usize = 64;
167
168 pub fn new(s: &str) -> Result<Self, ValidationError> {
173 Self::validate(s)?;
174 Ok(Self(s.to_owned()))
175 }
176
177 #[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#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
263#[serde(tag = "state", rename_all = "snake_case")]
264pub enum WorkspaceState {
265 Active,
267 Stale {
269 behind_epochs: u32,
271 },
272 Destroyed,
274}
275
276impl WorkspaceState {
277 #[must_use]
279 pub const fn is_active(&self) -> bool {
280 matches!(self, Self::Active)
281 }
282
283 #[must_use]
285 pub const fn is_stale(&self) -> bool {
286 matches!(self, Self::Stale { .. })
287 }
288
289 #[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#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum WorkspaceMode {
321 #[default]
323 Ephemeral,
324 Persistent,
326}
327
328impl WorkspaceMode {
329 #[must_use]
331 pub const fn is_persistent(&self) -> bool {
332 matches!(self, Self::Persistent)
333 }
334
335 #[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
357pub struct WorkspaceInfo {
358 pub id: WorkspaceId,
360 pub path: PathBuf,
362 pub epoch: EpochId,
364 pub state: WorkspaceState,
366 #[serde(default)]
368 pub mode: WorkspaceMode,
369 #[serde(default)]
372 pub commits_ahead: u32,
373}
374
375#[derive(Clone, Debug, PartialEq, Eq)]
381pub enum ErrorKind {
382 GitOid,
384 EpochId,
386 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#[derive(Clone, Debug, PartialEq, Eq)]
402pub struct ValidationError {
403 pub kind: ErrorKind,
405 pub value: String,
407 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#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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}