1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use thiserror::Error;
4use vex_domain::{ProjectId, WorkstreamId, WorkstreamState, validate_slug};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Workstream {
8 pub id: WorkstreamId,
9 pub project_id: ProjectId,
10 pub name: String,
11 pub branch: String,
12 pub state: WorkstreamState,
13 pub created_at: DateTime<Utc>,
14 pub from_pr: Option<String>,
15}
16
17#[derive(Debug, Error)]
18pub enum WorkstreamError {
19 #[error("workstream not found: {0}")]
20 NotFound(String),
21 #[error("workstream already exists: {0}")]
22 AlreadyExists(String),
23 #[error(
24 "invalid workstream name '{0}': must be alphanumeric with hyphens, starting with alphanumeric"
25 )]
26 InvalidName(String),
27 #[error("invalid state transition: {from} -> {to}")]
28 InvalidTransition {
29 from: WorkstreamState,
30 to: WorkstreamState,
31 },
32 #[error("project error: {0}")]
33 ProjectError(String),
34 #[error("git error: {0}")]
35 GitError(String),
36 #[error("IO error: {0}")]
37 Io(#[from] std::io::Error),
38 #[error("{0}")]
39 Other(String),
40}
41
42pub trait WorkstreamRepo {
43 fn create(&self, workstream: &Workstream) -> Result<(), WorkstreamError>;
44 fn find_by_name(
45 &self,
46 project_id: &ProjectId,
47 name: &str,
48 ) -> Result<Option<Workstream>, WorkstreamError>;
49 fn list_by_project(&self, project_id: &ProjectId) -> Result<Vec<Workstream>, WorkstreamError>;
50 fn update(&self, workstream: &Workstream) -> Result<(), WorkstreamError>;
51 fn delete(&self, project_id: &ProjectId, id: &WorkstreamId) -> Result<(), WorkstreamError>;
52}
53
54pub fn validate_workstream_name(name: &str) -> Result<(), WorkstreamError> {
55 if !validate_slug(name) {
56 return Err(WorkstreamError::InvalidName(name.into()));
57 }
58 Ok(())
59}
60
61pub fn branch_name_for_workstream(name: &str) -> String {
62 format!("task/{name}")
63}
64
65pub fn validate_transition(
66 from: WorkstreamState,
67 to: WorkstreamState,
68) -> Result<(), WorkstreamError> {
69 match (from, to) {
70 (WorkstreamState::Active, WorkstreamState::Paused)
71 | (WorkstreamState::Active, WorkstreamState::Completed)
72 | (WorkstreamState::Paused, WorkstreamState::Active)
73 | (WorkstreamState::Completed, WorkstreamState::Archived) => Ok(()),
74 _ => Err(WorkstreamError::InvalidTransition { from, to }),
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 fn valid_workstream_names() {
84 assert!(validate_workstream_name("fix-login").is_ok());
85 assert!(validate_workstream_name("feature-1").is_ok());
86 assert!(validate_workstream_name("a").is_ok());
87 }
88
89 #[test]
90 fn invalid_workstream_names() {
91 assert!(validate_workstream_name("").is_err());
92 assert!(validate_workstream_name("-bad").is_err());
93 assert!(validate_workstream_name("has spaces").is_err());
94 assert!(validate_workstream_name("under_score").is_err());
95 }
96
97 #[test]
98 fn branch_name_generation() {
99 assert_eq!(branch_name_for_workstream("fix-login"), "task/fix-login");
100 }
101
102 #[test]
103 fn valid_transitions() {
104 use WorkstreamState::*;
105 assert!(validate_transition(Active, Paused).is_ok());
106 assert!(validate_transition(Active, Completed).is_ok());
107 assert!(validate_transition(Paused, Active).is_ok());
108 assert!(validate_transition(Completed, Archived).is_ok());
109 }
110
111 #[test]
112 fn invalid_transitions() {
113 use WorkstreamState::*;
114 assert!(validate_transition(Paused, Completed).is_err());
115 assert!(validate_transition(Paused, Archived).is_err());
116 assert!(validate_transition(Archived, Active).is_err());
117 assert!(validate_transition(Completed, Active).is_err());
118 assert!(validate_transition(Active, Archived).is_err());
119 assert!(validate_transition(Active, Active).is_err());
120 }
121}