Skip to main content

vex_workstream/
lib.rs

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
54/// Reserved names that conflict with URL routing paths.
55const 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}