Skip to main content

scud/sync/
claude_tasks.rs

1//! Claude Code Tasks format conversion and sync
2//!
3//! Converts SCUD tasks to Claude Code's native Tasks JSON format,
4//! enabling agents to see tasks via the `TaskList` tool.
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11use crate::models::phase::Phase;
12use crate::models::task::{Task, TaskStatus};
13
14/// Claude Code task format
15///
16/// This matches the JSON structure that Claude Code's Task tools expect.
17/// See: `~/.claude/tasks/<list-id>.json`
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ClaudeTask {
20    /// Task identifier (format: "tag:id" for SCUD tasks)
21    pub id: String,
22
23    /// Task title (maps to SCUD's `title`)
24    pub subject: String,
25
26    /// Task description
27    #[serde(default)]
28    pub description: String,
29
30    /// Task status: "pending", "in_progress", or "completed"
31    pub status: String,
32
33    /// Task IDs that must complete before this one can start
34    #[serde(default, rename = "blockedBy")]
35    pub blocked_by: Vec<String>,
36
37    /// Task IDs that are waiting for this task to complete
38    #[serde(default)]
39    pub blocks: Vec<String>,
40
41    /// Agent/session currently working on this task
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub owner: Option<String>,
44
45    /// Additional metadata (SCUD-specific fields)
46    #[serde(default)]
47    pub metadata: serde_json::Value,
48}
49
50/// Claude Code task list format
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ClaudeTaskList {
53    /// List of tasks
54    pub tasks: Vec<ClaudeTask>,
55}
56
57impl ClaudeTask {
58    /// Convert a SCUD task to Claude Code's task format
59    ///
60    /// # Arguments
61    /// * `task` - The SCUD task to convert
62    /// * `tag` - The phase tag (used to namespace task IDs)
63    ///
64    /// # Returns
65    /// A `ClaudeTask` with SCUD fields mapped to Claude format
66    pub fn from_scud_task(task: &Task, tag: &str) -> Self {
67        let status = match task.status {
68            TaskStatus::Pending => "pending",
69            TaskStatus::InProgress => "in_progress",
70            TaskStatus::Done => "completed",
71            // Map other statuses with metadata to track original
72            TaskStatus::Blocked | TaskStatus::Deferred => "pending",
73            TaskStatus::Failed | TaskStatus::Cancelled => "completed",
74            TaskStatus::Review => "in_progress",
75            TaskStatus::Expanded => "completed",
76        };
77
78        ClaudeTask {
79            id: format!("{}:{}", tag, task.id),
80            subject: task.title.clone(),
81            description: task.description.clone(),
82            status: status.to_string(),
83            blocked_by: task
84                .dependencies
85                .iter()
86                .map(|d: &String| {
87                    // Handle cross-tag dependencies (already namespaced)
88                    if d.contains(':') {
89                        d.clone()
90                    } else {
91                        format!("{}:{}", tag, d)
92                    }
93                })
94                .collect(),
95            blocks: vec![], // Filled in by sync_phase()
96            owner: task.assigned_to.clone(),
97            metadata: serde_json::json!({
98                "scud_tag": tag,
99                "scud_status": format!("{:?}", task.status),
100                "complexity": task.complexity,
101                "priority": format!("{:?}", task.priority),
102                "agent_type": task.agent_type,
103            }),
104        }
105    }
106}
107
108/// Get the Claude Code tasks directory
109///
110/// Returns `~/.claude/tasks/`
111pub fn claude_tasks_dir() -> PathBuf {
112    dirs::home_dir()
113        .unwrap_or_else(|| PathBuf::from("."))
114        .join(".claude")
115        .join("tasks")
116}
117
118/// Generate a task list ID for a SCUD tag
119///
120/// The task list ID is used for the `CLAUDE_CODE_TASK_LIST_ID` environment
121/// variable and as the filename for the tasks JSON file.
122///
123/// # Arguments
124/// * `tag` - The SCUD phase tag
125///
126/// # Returns
127/// A task list ID in the format "scud-{tag}"
128pub fn task_list_id(tag: &str) -> String {
129    format!("scud-{}", tag)
130}
131
132/// Sync a SCUD phase to Claude Code's Tasks format
133///
134/// Creates or updates `~/.claude/tasks/scud-{tag}.json` with tasks
135/// from the given phase.
136///
137/// # Arguments
138/// * `phase` - The SCUD phase containing tasks to sync
139/// * `tag` - The phase tag
140///
141/// # Returns
142/// The path to the created/updated task file
143///
144/// # Example
145///
146/// ```no_run
147/// use scud::sync::claude_tasks;
148/// use scud::models::phase::Phase;
149///
150/// let phase = Phase::new("auth".to_string());
151/// let task_file = claude_tasks::sync_phase(&phase, "auth").unwrap();
152/// // Creates ~/.claude/tasks/scud-auth.json
153/// ```
154pub fn sync_phase(phase: &Phase, tag: &str) -> Result<PathBuf> {
155    let tasks_dir = claude_tasks_dir();
156    std::fs::create_dir_all(&tasks_dir)?;
157
158    let list_id = task_list_id(tag);
159    let task_file = tasks_dir.join(format!("{}.json", list_id));
160
161    // Build dependency reverse map for "blocks" field
162    let mut blocks_map: HashMap<String, Vec<String>> = HashMap::new();
163
164    for task in phase.tasks.iter() {
165        let task_full_id = format!("{}:{}", tag, task.id);
166        for dep in task.dependencies.iter() {
167            // Handle cross-tag dependencies
168            let dep_full_id: String = if dep.contains(':') {
169                dep.clone()
170            } else {
171                format!("{}:{}", tag, dep)
172            };
173            blocks_map
174                .entry(dep_full_id)
175                .or_default()
176                .push(task_full_id.clone());
177        }
178    }
179
180    // Convert tasks
181    let claude_tasks: Vec<ClaudeTask> = phase
182        .tasks
183        .iter()
184        .filter(|t: &&Task| !t.is_expanded()) // Skip expanded parent tasks
185        .map(|t: &Task| {
186            let mut ct = ClaudeTask::from_scud_task(t, tag);
187            let full_id = format!("{}:{}", tag, t.id);
188            ct.blocks = blocks_map.get(&full_id).cloned().unwrap_or_default();
189            ct
190        })
191        .collect();
192
193    let task_list = ClaudeTaskList { tasks: claude_tasks };
194    let json = serde_json::to_string_pretty(&task_list)?;
195    std::fs::write(&task_file, json)?;
196
197    Ok(task_file)
198}
199
200/// Sync multiple phases (for --all-tags mode)
201///
202/// # Arguments
203/// * `phases` - Map of tag names to phases
204///
205/// # Returns
206/// A vector of paths to created/updated task files
207pub fn sync_phases(phases: &HashMap<String, Phase>) -> Result<Vec<PathBuf>> {
208    phases
209        .iter()
210        .map(|(tag, phase)| sync_phase(phase, tag))
211        .collect()
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::models::task::Priority;
218
219    #[test]
220    fn test_task_list_id() {
221        assert_eq!(task_list_id("auth"), "scud-auth");
222        assert_eq!(task_list_id("my-feature"), "scud-my-feature");
223    }
224
225    #[test]
226    fn test_claude_task_from_scud_task() {
227        let mut task = Task::new(
228            "1".to_string(),
229            "Implement login".to_string(),
230            "Add login functionality".to_string(),
231        );
232        task.complexity = 5;
233        task.priority = Priority::High;
234        task.dependencies = vec!["setup".to_string()];
235
236        let claude_task = ClaudeTask::from_scud_task(&task, "auth");
237
238        assert_eq!(claude_task.id, "auth:1");
239        assert_eq!(claude_task.subject, "Implement login");
240        assert_eq!(claude_task.status, "pending");
241        assert_eq!(claude_task.blocked_by, vec!["auth:setup"]);
242    }
243
244    #[test]
245    fn test_status_mapping() {
246        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
247
248        // Pending -> pending
249        task.status = TaskStatus::Pending;
250        assert_eq!(
251            ClaudeTask::from_scud_task(&task, "t").status,
252            "pending"
253        );
254
255        // InProgress -> in_progress
256        task.status = TaskStatus::InProgress;
257        assert_eq!(
258            ClaudeTask::from_scud_task(&task, "t").status,
259            "in_progress"
260        );
261
262        // Done -> completed
263        task.status = TaskStatus::Done;
264        assert_eq!(
265            ClaudeTask::from_scud_task(&task, "t").status,
266            "completed"
267        );
268
269        // Review -> in_progress
270        task.status = TaskStatus::Review;
271        assert_eq!(
272            ClaudeTask::from_scud_task(&task, "t").status,
273            "in_progress"
274        );
275
276        // Failed -> completed (with metadata flag)
277        task.status = TaskStatus::Failed;
278        assert_eq!(
279            ClaudeTask::from_scud_task(&task, "t").status,
280            "completed"
281        );
282    }
283
284    #[test]
285    fn test_cross_tag_dependencies() {
286        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
287        task.dependencies = vec!["other:setup".to_string(), "local".to_string()];
288
289        let claude_task = ClaudeTask::from_scud_task(&task, "auth");
290
291        // Cross-tag deps keep their prefix, local deps get the tag added
292        assert!(claude_task.blocked_by.contains(&"other:setup".to_string()));
293        assert!(claude_task.blocked_by.contains(&"auth:local".to_string()));
294    }
295
296    #[test]
297    fn test_sync_phase() {
298        use tempfile::TempDir;
299
300        // Create a temp dir to use as home
301        let tmp = TempDir::new().unwrap();
302        let original_home = std::env::var("HOME").ok();
303
304        // This test would need HOME override which is tricky
305        // For now, just test the conversion logic
306        let mut phase = Phase::new("test".to_string());
307
308        let task1 = Task::new("1".to_string(), "First".to_string(), "First task".to_string());
309        let mut task2 = Task::new(
310            "2".to_string(),
311            "Second".to_string(),
312            "Second task".to_string(),
313        );
314        task2.dependencies = vec!["1".to_string()];
315
316        phase.add_task(task1);
317        phase.add_task(task2);
318
319        // Test the conversion without actually writing
320        let claude_tasks: Vec<ClaudeTask> = phase
321            .tasks
322            .iter()
323            .map(|t| ClaudeTask::from_scud_task(t, "test"))
324            .collect();
325
326        assert_eq!(claude_tasks.len(), 2);
327        assert_eq!(claude_tasks[0].id, "test:1");
328        assert_eq!(claude_tasks[1].id, "test:2");
329        assert_eq!(claude_tasks[1].blocked_by, vec!["test:1"]);
330    }
331}