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
54const RESERVED_NAMES: &[&str] = &["shell", "agent"];
56
57pub fn validate_workstream_name(name: &str) -> Result<(), WorkstreamError> {
58 if !validate_slug(name) {
59 return Err(WorkstreamError::InvalidName(name.into()));
60 }
61 if RESERVED_NAMES.contains(&name) {
62 return Err(WorkstreamError::InvalidName(name.into()));
63 }
64 Ok(())
65}
66
67pub fn branch_name_for_workstream(name: &str) -> String {
68 format!("task/{name}")
69}
70
71pub fn validate_transition(
72 from: WorkstreamState,
73 to: WorkstreamState,
74) -> Result<(), WorkstreamError> {
75 match (from, to) {
76 (WorkstreamState::Active, WorkstreamState::Paused)
77 | (WorkstreamState::Active, WorkstreamState::Completed)
78 | (WorkstreamState::Paused, WorkstreamState::Active)
79 | (WorkstreamState::Completed, WorkstreamState::Archived) => Ok(()),
80 _ => Err(WorkstreamError::InvalidTransition { from, to }),
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87
88 #[test]
89 fn valid_workstream_names() {
90 assert!(validate_workstream_name("fix-login").is_ok());
91 assert!(validate_workstream_name("feature-1").is_ok());
92 assert!(validate_workstream_name("a").is_ok());
93 }
94
95 #[test]
96 fn invalid_workstream_names() {
97 assert!(validate_workstream_name("").is_err());
98 assert!(validate_workstream_name("-bad").is_err());
99 assert!(validate_workstream_name("has spaces").is_err());
100 assert!(validate_workstream_name("under_score").is_err());
101 }
102
103 #[test]
104 fn branch_name_generation() {
105 assert_eq!(branch_name_for_workstream("fix-login"), "task/fix-login");
106 }
107
108 #[test]
109 fn valid_transitions() {
110 use WorkstreamState::*;
111 assert!(validate_transition(Active, Paused).is_ok());
112 assert!(validate_transition(Active, Completed).is_ok());
113 assert!(validate_transition(Paused, Active).is_ok());
114 assert!(validate_transition(Completed, Archived).is_ok());
115 }
116
117 #[test]
118 fn invalid_transitions() {
119 use WorkstreamState::*;
120 assert!(validate_transition(Paused, Completed).is_err());
121 assert!(validate_transition(Paused, Archived).is_err());
122 assert!(validate_transition(Archived, Active).is_err());
123 assert!(validate_transition(Completed, Active).is_err());
124 assert!(validate_transition(Active, Archived).is_err());
125 assert!(validate_transition(Active, Active).is_err());
126 }
127}