Skip to main content

tmai_core/teams/
task.rs

1//! Team task file reading from `~/.claude/tasks/{team-name}/`
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7/// Status of a team task
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum TaskStatus {
11    /// Task is waiting to be started
12    Pending,
13    /// Task is currently being worked on
14    InProgress,
15    /// Task has been completed
16    Completed,
17}
18
19impl std::fmt::Display for TaskStatus {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            TaskStatus::Pending => write!(f, "pending"),
23            TaskStatus::InProgress => write!(f, "in_progress"),
24            TaskStatus::Completed => write!(f, "completed"),
25        }
26    }
27}
28
29/// A task in a team's task list
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TeamTask {
32    /// Task identifier (numeric string)
33    #[serde(default)]
34    pub id: String,
35    /// Brief title of the task
36    #[serde(default)]
37    pub subject: String,
38    /// Detailed description
39    #[serde(default)]
40    pub description: String,
41    /// Present continuous form shown in spinner (e.g., "Fixing bug...")
42    #[serde(default, rename = "activeForm")]
43    pub active_form: Option<String>,
44    /// Current status
45    #[serde(default = "default_task_status")]
46    pub status: TaskStatus,
47    /// Owner (member name)
48    #[serde(default)]
49    pub owner: Option<String>,
50    /// Task IDs that this task blocks
51    #[serde(default)]
52    pub blocks: Vec<String>,
53    /// Task IDs that block this task
54    #[serde(default, rename = "blockedBy")]
55    pub blocked_by: Vec<String>,
56}
57
58/// Default task status
59fn default_task_status() -> TaskStatus {
60    TaskStatus::Pending
61}
62
63/// Read a single task file
64///
65/// # Arguments
66/// * `task_path` - Path to the task JSON file (e.g., `1.json`)
67pub fn read_task(task_path: &Path) -> Result<TeamTask> {
68    let content = std::fs::read_to_string(task_path)
69        .with_context(|| format!("Failed to read task file: {:?}", task_path))?;
70
71    let task: TeamTask = serde_json::from_str(&content)
72        .with_context(|| format!("Failed to parse task file: {:?}", task_path))?;
73
74    Ok(task)
75}
76
77/// Read all task files from a team's task directory
78///
79/// Only reads numeric JSON files (e.g., `1.json`, `2.json`), skipping other files.
80///
81/// # Arguments
82/// * `tasks_dir` - Path to the team's tasks directory
83pub fn read_all_tasks(tasks_dir: &Path) -> Result<Vec<TeamTask>> {
84    if !tasks_dir.exists() {
85        return Ok(Vec::new());
86    }
87
88    let mut tasks = Vec::new();
89    let entries = std::fs::read_dir(tasks_dir)
90        .with_context(|| format!("Failed to read tasks directory: {:?}", tasks_dir))?;
91
92    for entry in entries {
93        let entry = entry?;
94        let path = entry.path();
95
96        // Only process .json files
97        if path.extension().and_then(|s| s.to_str()) != Some("json") {
98            continue;
99        }
100
101        // Only process numeric filenames (e.g., 1.json, 23.json)
102        let is_numeric = path
103            .file_stem()
104            .and_then(|s| s.to_str())
105            .map(|s| s.chars().all(|c| c.is_ascii_digit()))
106            .unwrap_or(false);
107
108        if !is_numeric {
109            continue;
110        }
111
112        match read_task(&path) {
113            Ok(mut task) => {
114                // Set task ID from filename if empty
115                if task.id.is_empty() {
116                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
117                        task.id = stem.to_string();
118                    }
119                }
120                tasks.push(task);
121            }
122            Err(e) => {
123                eprintln!("Warning: Failed to read task file {:?}: {}", path, e);
124            }
125        }
126    }
127
128    // Sort by ID numerically
129    tasks.sort_by(|a, b| {
130        let id_a: u64 = a.id.parse().unwrap_or(u64::MAX);
131        let id_b: u64 = b.id.parse().unwrap_or(u64::MAX);
132        id_a.cmp(&id_b)
133    });
134
135    Ok(tasks)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_task_deserialization() {
144        let json = r#"{
145            "id": "1",
146            "subject": "Fix authentication bug",
147            "description": "The login flow has a bug",
148            "activeForm": "Fixing authentication bug",
149            "status": "in_progress",
150            "owner": "dev",
151            "blocks": ["3"],
152            "blockedBy": []
153        }"#;
154
155        let task: TeamTask = serde_json::from_str(json).unwrap();
156        assert_eq!(task.id, "1");
157        assert_eq!(task.subject, "Fix authentication bug");
158        assert_eq!(task.status, TaskStatus::InProgress);
159        assert_eq!(task.owner.as_deref(), Some("dev"));
160        assert_eq!(task.blocks, vec!["3"]);
161        assert!(task.blocked_by.is_empty());
162        assert_eq!(
163            task.active_form.as_deref(),
164            Some("Fixing authentication bug")
165        );
166    }
167
168    #[test]
169    fn test_task_default_status() {
170        let json = r#"{
171            "id": "2",
172            "subject": "Write tests"
173        }"#;
174
175        let task: TeamTask = serde_json::from_str(json).unwrap();
176        assert_eq!(task.status, TaskStatus::Pending);
177        assert!(task.owner.is_none());
178        assert!(task.blocks.is_empty());
179        assert!(task.blocked_by.is_empty());
180    }
181
182    #[test]
183    fn test_task_forward_compat() {
184        let json = r#"{
185            "id": "1",
186            "subject": "Test",
187            "status": "completed",
188            "unknown_field": true
189        }"#;
190
191        let task: TeamTask = serde_json::from_str(json).unwrap();
192        assert_eq!(task.status, TaskStatus::Completed);
193    }
194
195    #[test]
196    fn test_task_status_display() {
197        assert_eq!(TaskStatus::Pending.to_string(), "pending");
198        assert_eq!(TaskStatus::InProgress.to_string(), "in_progress");
199        assert_eq!(TaskStatus::Completed.to_string(), "completed");
200    }
201}