Skip to main content

cvkg_cli/
dev_runtime.rs

1//! Dev Runtime Controller
2//! Responsible for launching runtime, maintaining connection, and coordinating updates
3
4use serde::{Deserialize, Serialize};
5use std::sync::Arc;
6
7use crate::patch_engine::{CompiledArtifact, PatchEngine, RuntimePatch};
8
9/// Abstract runtime handle trait
10pub trait RuntimeHandle: Send + Sync {
11    /// Send a patch to the runtime
12    fn send_patch(&self, patch: RuntimePatch);
13
14    /// Request current state from the runtime
15    fn request_state(&self) -> RuntimeStateSnapshot;
16
17    /// Send an event to the runtime
18    fn send_event(&self, event: RuntimeEvent);
19}
20
21/// DevRuntimeController manages the connection to the runtime
22pub struct DevRuntimeController {
23    runtime: Arc<dyn RuntimeHandle>,
24    patch_engine: PatchEngine,
25}
26
27impl DevRuntimeController {
28    /// Create a new DevRuntimeController
29    pub fn new(runtime: Arc<dyn RuntimeHandle>) -> Self {
30        Self {
31            runtime,
32            patch_engine: PatchEngine::new(),
33        }
34    }
35
36    /// Apply a code update by generating and sending a patch
37    pub fn apply_code_update(&mut self, compiled_artifact: CompiledArtifact) {
38        let patch = self.patch_engine.generate_patch(compiled_artifact);
39        self.runtime.send_patch(patch);
40    }
41
42    /// Inject an agent stream into the runtime
43    pub fn inject_agent_stream(&self, stream: Vec<RuntimeEvent>) {
44        for event in stream {
45            self.runtime.send_event(event);
46        }
47    }
48}
49
50/// Runtime event types
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub enum RuntimeEvent {
53    Agent(AgentEvent),
54    // Add other event types as needed
55}
56
57/// Agent event types
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub enum AgentEvent {
60    Token(String),
61    ToolCall(String),
62    StateChange(String),
63    Error(String),
64}
65
66/// Runtime state snapshot
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct RuntimeStateSnapshot {
69    // In a full implementation, this would contain the serialized state graph
70    pub data: String,
71}
72
73impl RuntimeStateSnapshot {
74    pub fn new(data: String) -> Self {
75        Self { data }
76    }
77}
78
79// =============================================================================
80// FILE WATCHER -- Item 17: Hot Reload / Dev Server
81// =============================================================================
82// Uses `notify` crate (already a workspace dependency) for cross-platform
83// file system event monitoring.
84
85use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
86use std::path::{Path, PathBuf};
87use std::sync::mpsc::{Receiver, channel};
88use std::time::{Duration, Instant};
89
90/// File watcher that monitors paths for changes and emits debounced events.
91pub struct FileWatcher {
92    _watcher: RecommendedWatcher,
93    rx: Receiver<Event>,
94    debounce: Duration,
95    last_event: Option<Instant>,
96    pending_paths: Vec<PathBuf>,
97}
98
99impl FileWatcher {
100    pub fn new(paths: Vec<PathBuf>) -> notify::Result<Self> {
101        let (tx, rx) = channel();
102        let mut watcher =
103            notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
104                if let Ok(event) = res {
105                    let _ = tx.send(event);
106                }
107            })?;
108
109        for path in &paths {
110            if path.exists() {
111                watcher.watch(path, RecursiveMode::Recursive)?;
112            }
113        }
114
115        Ok(Self {
116            _watcher: watcher,
117            rx,
118            debounce: Duration::from_millis(300),
119            last_event: None,
120            pending_paths: Vec::new(),
121        })
122    }
123
124    /// Poll for file changes. Returns paths of changed files after debounce.
125    pub fn poll_changes(&mut self) -> Vec<PathBuf> {
126        // Drain all pending events
127        while let Ok(event) = self.rx.try_recv() {
128            self.last_event = Some(Instant::now());
129            for path in event.paths {
130                if !self.pending_paths.contains(&path) {
131                    self.pending_paths.push(path);
132                }
133            }
134        }
135
136        // Return paths if debounce period has elapsed
137        if let Some(last) = self.last_event
138            && last.elapsed() >= self.debounce
139            && !self.pending_paths.is_empty()
140        {
141            let paths = std::mem::take(&mut self.pending_paths);
142            self.last_event = None;
143            return paths;
144        }
145
146        Vec::new()
147    }
148
149    /// Check if any changes are pending (even if not yet debounced).
150    pub fn has_pending_changes(&self) -> bool {
151        !self.pending_paths.is_empty()
152    }
153
154    /// Get the debounce duration.
155    pub fn debounce_duration(&self) -> Duration {
156        self.debounce
157    }
158}
159
160/// State snapshot for preserving app state across hot reloads.
161#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
162pub struct HotReloadState {
163    /// Current theme mode ("dark" or "light").
164    pub theme_mode: String,
165    /// Window size (width, height).
166    pub window_size: (f32, f32),
167    /// Scroll positions per scroll view (key = view ID).
168    pub scroll_positions: std::collections::HashMap<String, Vec<f32>>,
169    /// Input text per text field (key = input ID).
170    pub input_text: std::collections::HashMap<String, String>,
171    /// Expanded nodes per outline view (key = view ID).
172    pub expanded_nodes: std::collections::HashMap<String, Vec<usize>>,
173    /// Timestamp of last save.
174    pub saved_at: f64,
175}
176
177impl Default for HotReloadState {
178    fn default() -> Self {
179        Self {
180            theme_mode: "dark".to_string(),
181            window_size: (1200.0, 800.0),
182            scroll_positions: std::collections::HashMap::new(),
183            input_text: std::collections::HashMap::new(),
184            expanded_nodes: std::collections::HashMap::new(),
185            saved_at: 0.0,
186        }
187    }
188}
189
190impl HotReloadState {
191    /// Save state to a JSON file.
192    pub fn save(&self, path: &Path) -> std::io::Result<()> {
193        let json = serde_json::to_string_pretty(self)?;
194        std::fs::write(path, json)?;
195        Ok(())
196    }
197
198    /// Load state from a JSON file.
199    pub fn load(path: &Path) -> std::io::Result<Self> {
200        let json = std::fs::read_to_string(path)?;
201        let state = serde_json::from_str(&json)?;
202        Ok(state)
203    }
204}
205
206/// Error overlay for showing compilation errors in the app.
207#[derive(Clone, Debug)]
208pub struct ErrorOverlay {
209    /// Error message to display.
210    pub message: String,
211    /// Source file where the error occurred.
212    pub file: Option<String>,
213    /// Line number (1-indexed).
214    pub line: Option<u32>,
215    /// Column number (1-indexed).
216    pub column: Option<u32>,
217}
218
219impl ErrorOverlay {
220    /// Create a new error overlay from a cargo JSON error message.
221    ///
222    /// Parses cargo's `--message-format=json` output to extract structured
223    /// error information (file, line, column, message).
224    ///
225    /// Falls back to naive line scanning if JSON parsing fails.
226    pub fn from_cargo_output(output: &str) -> Option<Self> {
227        // Try structured JSON parsing first
228        for line in output.lines() {
229            let trimmed = line.trim();
230            if trimmed.is_empty() {
231                continue;
232            }
233            if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
234                // Cargo JSON messages have a "reason" field
235                if json.get("reason").and_then(|r| r.as_str()) == Some("compiler-message")
236                    && let Some(message) = json.get("message")
237                {
238                    let msg_text = message
239                        .get("message")
240                        .and_then(|m| m.as_str())
241                        .unwrap_or("")
242                        .to_string();
243
244                    // Only actual errors, not warnings
245                    let is_error = message
246                        .get("level")
247                        .and_then(|l| l.as_str())
248                        .map(|l| l == "error")
249                        .unwrap_or(false);
250                    if !is_error {
251                        continue;
252                    }
253
254                    // Extract file/line/column from spans
255                    let (file, line, column) = message
256                        .get("spans")
257                        .and_then(|s| s.as_array())
258                        .and_then(|spans| spans.first())
259                        .map(|span| {
260                            (
261                                span.get("file_name")
262                                    .and_then(|f| f.as_str())
263                                    .map(String::from),
264                                span.get("line_start")
265                                    .and_then(|l| l.as_u64())
266                                    .map(|l| l as u32),
267                                span.get("column_start")
268                                    .and_then(|c| c.as_u64())
269                                    .map(|c| c as u32),
270                            )
271                        })
272                        .unwrap_or((None, None, None));
273
274                    return Some(Self {
275                        message: msg_text,
276                        file,
277                        line,
278                        column,
279                    });
280                }
281            }
282        }
283
284        // Fallback: naive scan for lines containing "error[" or "error:"
285        for line in output.lines() {
286            let lower = line.to_lowercase();
287            if (lower.contains("error[") || lower.contains("error:"))
288                && !lower.contains("error-handling")
289            {
290                return Some(Self {
291                    message: line.to_string(),
292                    file: None,
293                    line: None,
294                    column: None,
295                });
296            }
297        }
298
299        None
300    }
301}