scud/commands/
view.rs

1use anyhow::Result;
2use colored::Colorize;
3use serde::Serialize;
4use std::collections::{HashMap, HashSet};
5use std::fs;
6use std::path::PathBuf;
7
8use crate::models::phase::Phase;
9use crate::models::task::{Task, TaskStatus};
10use crate::storage::Storage;
11
12// Embed the static assets at compile time
13const VIEWER_STYLES: &str = include_str!("../../assets/viewer.css");
14const VIEWER_SCRIPT: &str = include_str!("../../assets/viewer.js");
15
16/// Wave data structure for the viewer
17#[derive(Debug, Clone, Serialize)]
18struct WaveTask {
19    id: String,
20    title: String,
21    status: String,
22    complexity: u32,
23    dependencies: Vec<String>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27struct Wave {
28    wave: usize,
29    round: usize,
30    tasks: Vec<WaveTask>,
31}
32
33pub fn run(project_root: Option<PathBuf>) -> Result<()> {
34    let storage = Storage::new(project_root);
35
36    if !storage.is_initialized() {
37        anyhow::bail!("SCUD is not initialized. Run: scud init");
38    }
39
40    // Load tasks from storage
41    let tasks_data = storage.load_tasks()?;
42
43    if tasks_data.is_empty() {
44        anyhow::bail!("No tasks found. Run: scud parse-prd <prd-file>");
45    }
46
47    // Compute waves for each phase
48    let waves_data = compute_all_waves(&tasks_data);
49
50    // Generate HTML
51    let html = generate_viewer_html(&tasks_data, &waves_data);
52
53    // Write to temp file
54    let temp_dir = std::env::temp_dir();
55    let timestamp = std::time::SystemTime::now()
56        .duration_since(std::time::UNIX_EPOCH)
57        .unwrap()
58        .as_millis();
59    let temp_file = temp_dir.join(format!("scud-view-{}.html", timestamp));
60
61    fs::write(&temp_file, html)?;
62
63    println!("{}", "Opening SCUD viewer...".green());
64    webbrowser::open(temp_file.to_str().unwrap())?;
65
66    Ok(())
67}
68
69/// Compute execution waves for all phases
70fn compute_all_waves(tasks_data: &HashMap<String, Phase>) -> HashMap<String, Vec<Wave>> {
71    let mut waves_data = HashMap::new();
72
73    for (phase_name, phase) in tasks_data {
74        let waves = compute_waves(&phase.tasks);
75        waves_data.insert(phase_name.clone(), waves);
76    }
77
78    waves_data
79}
80
81/// Compute execution waves using Kahn's algorithm (topological sort with level assignment)
82fn compute_waves(tasks: &[Task]) -> Vec<Wave> {
83    // Filter to actionable tasks only (not done, not expanded parents, not cancelled)
84    let actionable: Vec<&Task> = tasks
85        .iter()
86        .filter(|task| {
87            if task.status == TaskStatus::Done
88                || task.status == TaskStatus::Expanded
89                || task.status == TaskStatus::Cancelled
90            {
91                return false;
92            }
93
94            // If it's a subtask, only include if parent is expanded
95            if let Some(ref parent_id) = task.parent_id {
96                let parent_expanded = tasks
97                    .iter()
98                    .find(|t| &t.id == parent_id)
99                    .map(|p| p.is_expanded())
100                    .unwrap_or(false);
101                if !parent_expanded {
102                    return false;
103                }
104            }
105
106            true
107        })
108        .collect();
109
110    if actionable.is_empty() {
111        return vec![];
112    }
113
114    // Build set of actionable task IDs
115    let task_ids: HashSet<String> = actionable.iter().map(|t| t.id.clone()).collect();
116
117    // Build in-degree map (how many dependencies does each task have within our set?)
118    let mut in_degree: HashMap<String, usize> = HashMap::new();
119    let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
120
121    for task in &actionable {
122        in_degree.entry(task.id.clone()).or_insert(0);
123
124        for dep in &task.dependencies {
125            // Only count dependencies that are in our actionable task set
126            if task_ids.contains(dep) {
127                *in_degree.entry(task.id.clone()).or_insert(0) += 1;
128                dependents
129                    .entry(dep.clone())
130                    .or_default()
131                    .push(task.id.clone());
132            }
133        }
134    }
135
136    // Kahn's algorithm with wave tracking
137    let mut waves: Vec<Wave> = Vec::new();
138    let mut remaining = in_degree.clone();
139    let mut wave_number = 1;
140
141    while !remaining.is_empty() {
142        // Find all tasks with no remaining dependencies (in-degree = 0)
143        let ready: Vec<String> = remaining
144            .iter()
145            .filter(|(_, &deg)| deg == 0)
146            .map(|(id, _)| id.clone())
147            .collect();
148
149        if ready.is_empty() {
150            // Circular dependency detected - break out
151            eprintln!("Warning: Circular dependency detected in tasks");
152            break;
153        }
154
155        // Remove ready tasks from remaining and update dependents
156        for task_id in &ready {
157            remaining.remove(task_id);
158
159            if let Some(deps) = dependents.get(task_id) {
160                for dep_id in deps {
161                    if let Some(deg) = remaining.get_mut(dep_id) {
162                        *deg = deg.saturating_sub(1);
163                    }
164                }
165            }
166        }
167
168        // Create wave with task details
169        let wave_tasks: Vec<WaveTask> = ready
170            .iter()
171            .filter_map(|task_id| {
172                actionable
173                    .iter()
174                    .find(|t| &t.id == task_id)
175                    .map(|task| WaveTask {
176                        id: task_id.clone(),
177                        title: task.title.clone(),
178                        status: task.status.as_str().to_string(),
179                        complexity: task.complexity,
180                        dependencies: task.dependencies.clone(),
181                    })
182            })
183            .collect();
184
185        waves.push(Wave {
186            wave: wave_number,
187            round: 1,
188            tasks: wave_tasks,
189        });
190
191        wave_number += 1;
192    }
193
194    waves
195}
196
197/// Generate the complete HTML viewer
198fn generate_viewer_html(
199    tasks_data: &HashMap<String, Phase>,
200    waves_data: &HashMap<String, Vec<Wave>>,
201) -> String {
202    // Convert tasks to JSON-serializable format
203    let tasks_json = serde_json::to_string_pretty(tasks_data).unwrap_or_default();
204    let waves_json = serde_json::to_string_pretty(waves_data).unwrap_or_default();
205
206    format!(
207        r#"<!DOCTYPE html>
208<html lang="en">
209<head>
210  <meta charset="UTF-8">
211  <meta name="viewport" content="width=device-width, initial-scale=1.0">
212  <title>SCUD Task Viewer</title>
213  <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
214  <style>
215{styles}
216  </style>
217</head>
218<body>
219  <header>
220    <h1>SCUD Task Viewer</h1>
221    <nav>
222      <button class="tab-btn active" data-tab="tasks">Tasks</button>
223      <button class="tab-btn" data-tab="waves">Waves</button>
224      <button class="tab-btn" data-tab="diagram">Diagram</button>
225      <button class="tab-btn" data-tab="stats">Stats</button>
226    </nav>
227  </header>
228
229  <div class="layout">
230    <main>
231      <section id="tasks" class="tab-content active">
232        <div class="phase-selector">
233          <label>Phase: </label>
234          <select id="phase-select"></select>
235        </div>
236        <div id="task-list"></div>
237      </section>
238
239      <section id="waves" class="tab-content">
240        <div class="phase-selector">
241          <label>Phase: </label>
242          <select id="waves-phase-select"></select>
243        </div>
244        <div id="waves-list"></div>
245      </section>
246
247      <section id="diagram" class="tab-content">
248        <div class="diagram-controls">
249          <label class="checkbox-label">
250            <input type="checkbox" id="simplified-view" checked> Simplified (hide subtasks)
251          </label>
252        </div>
253        <div id="diagram-phases-container"></div>
254      </section>
255
256      <section id="stats" class="tab-content">
257        <div id="stats-content"></div>
258      </section>
259    </main>
260
261    <aside id="detail-panel" class="detail-panel hidden">
262      <div class="detail-header">
263        <h2 id="detail-title">Task Details</h2>
264        <button id="close-detail" class="close-btn">&times;</button>
265      </div>
266      <div id="detail-content"></div>
267    </aside>
268  </div>
269
270  <script>
271    const TASKS_DATA = {tasks_json};
272    const WAVES_DATA = {waves_json};
273{script}
274  </script>
275</body>
276</html>"#,
277        styles = VIEWER_STYLES,
278        tasks_json = tasks_json,
279        waves_json = waves_json,
280        script = VIEWER_SCRIPT
281    )
282}