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 {
194        tasks: claude_tasks,
195    };
196    let json = serde_json::to_string_pretty(&task_list)?;
197    std::fs::write(&task_file, json)?;
198
199    Ok(task_file)
200}
201
202/// Sync multiple phases (for --all-tags mode)
203///
204/// # Arguments
205/// * `phases` - Map of tag names to phases
206///
207/// # Returns
208/// A vector of paths to created/updated task files
209pub fn sync_phases(phases: &HashMap<String, Phase>) -> Result<Vec<PathBuf>> {
210    phases
211        .iter()
212        .map(|(tag, phase)| sync_phase(phase, tag))
213        .collect()
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::models::task::Priority;
220
221    #[test]
222    fn test_task_list_id() {
223        assert_eq!(task_list_id("auth"), "scud-auth");
224        assert_eq!(task_list_id("my-feature"), "scud-my-feature");
225    }
226
227    #[test]
228    fn test_claude_task_from_scud_task() {
229        let mut task = Task::new(
230            "1".to_string(),
231            "Implement login".to_string(),
232            "Add login functionality".to_string(),
233        );
234        task.complexity = 5;
235        task.priority = Priority::High;
236        task.dependencies = vec!["setup".to_string()];
237
238        let claude_task = ClaudeTask::from_scud_task(&task, "auth");
239
240        assert_eq!(claude_task.id, "auth:1");
241        assert_eq!(claude_task.subject, "Implement login");
242        assert_eq!(claude_task.status, "pending");
243        assert_eq!(claude_task.blocked_by, vec!["auth:setup"]);
244    }
245
246    #[test]
247    fn test_status_mapping() {
248        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
249
250        // Pending -> pending
251        task.status = TaskStatus::Pending;
252        assert_eq!(ClaudeTask::from_scud_task(&task, "t").status, "pending");
253
254        // InProgress -> in_progress
255        task.status = TaskStatus::InProgress;
256        assert_eq!(ClaudeTask::from_scud_task(&task, "t").status, "in_progress");
257
258        // Done -> completed
259        task.status = TaskStatus::Done;
260        assert_eq!(ClaudeTask::from_scud_task(&task, "t").status, "completed");
261
262        // Review -> in_progress
263        task.status = TaskStatus::Review;
264        assert_eq!(ClaudeTask::from_scud_task(&task, "t").status, "in_progress");
265
266        // Failed -> completed (with metadata flag)
267        task.status = TaskStatus::Failed;
268        assert_eq!(ClaudeTask::from_scud_task(&task, "t").status, "completed");
269    }
270
271    #[test]
272    fn test_cross_tag_dependencies() {
273        let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
274        task.dependencies = vec!["other:setup".to_string(), "local".to_string()];
275
276        let claude_task = ClaudeTask::from_scud_task(&task, "auth");
277
278        // Cross-tag deps keep their prefix, local deps get the tag added
279        assert!(claude_task.blocked_by.contains(&"other:setup".to_string()));
280        assert!(claude_task.blocked_by.contains(&"auth:local".to_string()));
281    }
282
283    #[test]
284    fn test_sync_phase() {
285        use tempfile::TempDir;
286
287        // Create a temp dir to use as home
288        let tmp = TempDir::new().unwrap();
289        let original_home = std::env::var("HOME").ok();
290
291        // This test would need HOME override which is tricky
292        // For now, just test the conversion logic
293        let mut phase = Phase::new("test".to_string());
294
295        let task1 = Task::new(
296            "1".to_string(),
297            "First".to_string(),
298            "First task".to_string(),
299        );
300        let mut task2 = Task::new(
301            "2".to_string(),
302            "Second".to_string(),
303            "Second task".to_string(),
304        );
305        task2.dependencies = vec!["1".to_string()];
306
307        phase.add_task(task1);
308        phase.add_task(task2);
309
310        // Test the conversion without actually writing
311        let claude_tasks: Vec<ClaudeTask> = phase
312            .tasks
313            .iter()
314            .map(|t| ClaudeTask::from_scud_task(t, "test"))
315            .collect();
316
317        assert_eq!(claude_tasks.len(), 2);
318        assert_eq!(claude_tasks[0].id, "test:1");
319        assert_eq!(claude_tasks[1].id, "test:2");
320        assert_eq!(claude_tasks[1].blocked_by, vec!["test:1"]);
321    }
322}