raz_core/
commands.rs

1//! Command structures and execution logic
2
3use crate::browser::{extract_server_url, open_browser};
4use crate::error::{RazError, RazResult};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::Arc;
10use tokio::io::{AsyncBufReadExt, BufReader};
11use tokio::process::Command as TokioCommand;
12
13/// Represents a command that can be executed
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct Command {
16    /// Unique identifier for this command
17    pub id: String,
18
19    /// Human-readable label for display
20    pub label: String,
21
22    /// Optional description providing more context
23    pub description: Option<String>,
24
25    /// The actual command to execute
26    pub command: String,
27
28    /// Command arguments
29    pub args: Vec<String>,
30
31    /// Environment variables to set
32    pub env: HashMap<String, String>,
33
34    /// Working directory for execution
35    pub cwd: Option<PathBuf>,
36
37    /// Command category for grouping
38    pub category: CommandCategory,
39
40    /// Priority for sorting (higher = more important)
41    pub priority: u8,
42
43    /// Conditions that must be met for this command to be available
44    pub conditions: Vec<Condition>,
45
46    /// Tags for filtering and search
47    pub tags: Vec<String>,
48
49    /// Whether this command requires user input
50    pub requires_input: bool,
51
52    /// Estimated execution time in seconds
53    pub estimated_duration: Option<u32>,
54}
55
56impl Command {
57    /// Create a new command builder
58    pub fn builder(id: impl Into<String>, command: impl Into<String>) -> CommandBuilder {
59        CommandBuilder::new(id, command)
60    }
61
62    /// Execute this command asynchronously
63    pub async fn execute(&self) -> RazResult<ExecutionResult> {
64        self.execute_with_options(false, None).await
65    }
66
67    /// Execute this command with browser launching options
68    pub async fn execute_with_browser(
69        &self,
70        open_browser: bool,
71        browser: Option<String>,
72    ) -> RazResult<ExecutionResult> {
73        self.execute_with_options(open_browser, browser).await
74    }
75
76    /// Internal execution with options
77    async fn execute_with_options(
78        &self,
79        should_open_browser: bool,
80        browser: Option<String>,
81    ) -> RazResult<ExecutionResult> {
82        // For long-running/interactive commands, we spawn and inherit stdio
83        let is_interactive = self.is_interactive();
84
85        if is_interactive {
86            self.execute_interactive_with_browser(should_open_browser, browser)
87                .await
88        } else {
89            self.execute_captured().await
90        }
91    }
92
93    /// Check if this is an interactive/long-running command
94    fn is_interactive(&self) -> bool {
95        // Commands that typically run indefinitely or need user interaction
96        let is_cargo_leptos = matches!(self.command.as_str(), "cargo-leptos");
97        let has_serve_or_watch =
98            self.args.contains(&"serve".to_string()) || self.args.contains(&"watch".to_string());
99        let has_serve_tag = self.tags.contains(&"serve".to_string());
100        let has_watch_tag = self.tags.contains(&"watch".to_string());
101        let has_interactive_tag = self.tags.contains(&"interactive".to_string());
102
103        (is_cargo_leptos && has_serve_or_watch)
104            || has_serve_tag
105            || has_watch_tag
106            || has_interactive_tag
107    }
108
109    /// Execute command with inherited stdio for interactive processes
110    async fn execute_interactive_with_browser(
111        &self,
112        should_open_browser: bool,
113        browser: Option<String>,
114    ) -> RazResult<ExecutionResult> {
115        let mut cmd = TokioCommand::new(&self.command);
116        cmd.args(&self.args);
117
118        // Set environment variables
119        for (key, value) in &self.env {
120            cmd.env(key, value);
121        }
122
123        // Set working directory
124        if let Some(cwd) = &self.cwd {
125            cmd.current_dir(cwd);
126        }
127
128        let start_time = std::time::Instant::now();
129
130        let is_server_cmd = self.is_server_command();
131
132        if should_open_browser && is_server_cmd {
133            // For server commands with browser launching, we need to capture initial output
134            cmd.stdout(Stdio::piped())
135                .stderr(Stdio::piped())
136                .stdin(Stdio::inherit());
137
138            let mut child = cmd.spawn().map_err(|e| {
139                RazError::execution(format!("Failed to spawn command '{}': {}", self.command, e))
140            })?;
141
142            // Spawn tasks to read and forward output while looking for server URL
143            let stdout = child.stdout.take().unwrap();
144            let stderr = child.stderr.take().unwrap();
145
146            let browser_clone = browser.clone();
147            let url_opened = Arc::new(tokio::sync::Mutex::new(false));
148            let url_opened_clone = url_opened.clone();
149
150            // Handle stdout
151            let stdout_task = tokio::spawn(async move {
152                let reader = BufReader::new(stdout);
153                let mut lines = reader.lines();
154
155                while let Ok(Some(line)) = lines.next_line().await {
156                    println!("{line}");
157
158                    // Check if we should open browser
159                    let mut opened = url_opened_clone.lock().await;
160                    if !*opened {
161                        if let Some(url) = extract_server_url(&line) {
162                            if let Err(e) = open_browser(&url, browser_clone.as_deref()) {
163                                eprintln!("Warning: Failed to open browser: {e}");
164                            } else {
165                                println!("\n🌐 Opening {url} in browser...");
166                            }
167                            *opened = true;
168                        }
169                    }
170                }
171            });
172
173            // Handle stderr
174            let stderr_task = tokio::spawn(async move {
175                let reader = BufReader::new(stderr);
176                let mut lines = reader.lines();
177
178                while let Ok(Some(line)) = lines.next_line().await {
179                    eprintln!("{line}");
180                }
181            });
182
183            // Wait for process to complete
184            let status = child.wait().await.map_err(|e| {
185                RazError::execution(format!(
186                    "Failed to wait for command '{}': {}",
187                    self.command, e
188                ))
189            })?;
190
191            // Clean up tasks
192            let _ = stdout_task.await;
193            let _ = stderr_task.await;
194
195            let duration = start_time.elapsed();
196
197            Ok(ExecutionResult {
198                exit_code: status.code().unwrap_or(0),
199                stdout: String::new(),
200                stderr: String::new(),
201                duration,
202                command: self.clone(),
203            })
204        } else {
205            // Standard interactive execution
206            cmd.stdin(Stdio::inherit())
207                .stdout(Stdio::inherit())
208                .stderr(Stdio::inherit());
209
210            let mut child = cmd.spawn().map_err(|e| {
211                RazError::execution(format!("Failed to spawn command '{}': {}", self.command, e))
212            })?;
213
214            let status = child.wait().await.map_err(|e| {
215                RazError::execution(format!(
216                    "Failed to wait for command '{}': {}",
217                    self.command, e
218                ))
219            })?;
220
221            let duration = start_time.elapsed();
222
223            Ok(ExecutionResult {
224                exit_code: status.code().unwrap_or(0),
225                stdout: String::new(), // Output was inherited
226                stderr: String::new(), // Output was inherited
227                duration,
228                command: self.clone(),
229            })
230        }
231    }
232
233    /// Check if this is a server command that might output a URL
234    fn is_server_command(&self) -> bool {
235        let has_serve_tag = self.tags.contains(&"serve".to_string());
236        let has_server_tag = self.tags.contains(&"server".to_string());
237        let has_dev_tag = self.tags.contains(&"dev".to_string());
238        let has_watch_tag = self.tags.contains(&"watch".to_string());
239        let is_cargo_leptos =
240            self.command == "cargo" && self.args.first() == Some(&"leptos".to_string());
241        let is_cargo_leptos_serve =
242            self.command == "cargo-leptos" && self.args.contains(&"serve".to_string());
243        let is_cargo_leptos_watch =
244            self.command == "cargo-leptos" && self.args.contains(&"watch".to_string());
245        let is_trunk_serve = self.command == "trunk" && self.args.contains(&"serve".to_string());
246        let is_dx_serve = self.command == "dx" && self.args.contains(&"serve".to_string());
247
248        has_serve_tag
249            || has_server_tag
250            || has_dev_tag
251            || has_watch_tag
252            || is_cargo_leptos
253            || is_cargo_leptos_serve
254            || is_cargo_leptos_watch
255            || is_trunk_serve
256            || is_dx_serve
257    }
258
259    /// Execute command with captured output
260    async fn execute_captured(&self) -> RazResult<ExecutionResult> {
261        let mut cmd = TokioCommand::new(&self.command);
262        cmd.args(&self.args);
263
264        // Set environment variables
265        for (key, value) in &self.env {
266            cmd.env(key, value);
267        }
268
269        // Set working directory
270        if let Some(cwd) = &self.cwd {
271            cmd.current_dir(cwd);
272        }
273
274        // Configure stdio
275        cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
276
277        let start_time = std::time::Instant::now();
278
279        let output = cmd.output().await.map_err(|e| {
280            RazError::execution(format!(
281                "Failed to execute command '{}': {}",
282                self.command, e
283            ))
284        })?;
285
286        let duration = start_time.elapsed();
287
288        Ok(ExecutionResult {
289            exit_code: output.status.code().unwrap_or(-1),
290            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
291            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
292            duration,
293            command: self.clone(),
294        })
295    }
296
297    /// Check if all conditions for this command are met
298    pub fn is_available(&self, context: &crate::ProjectContext) -> bool {
299        self.conditions
300            .iter()
301            .all(|condition| condition.is_met(context))
302    }
303
304    /// Get the full command line as a string
305    pub fn command_line(&self) -> String {
306        if self.args.is_empty() {
307            self.command.clone()
308        } else {
309            format!("{} {}", self.command, self.args.join(" "))
310        }
311    }
312}
313
314/// Builder for creating commands
315pub struct CommandBuilder {
316    command: Command,
317}
318
319impl CommandBuilder {
320    pub fn new(id: impl Into<String>, command: impl Into<String>) -> Self {
321        let command_str = command.into();
322        Self {
323            command: Command {
324                id: id.into(),
325                label: command_str.clone(),
326                description: None,
327                command: command_str,
328                args: Vec::new(),
329                env: HashMap::new(),
330                cwd: None,
331                category: CommandCategory::Custom("default".to_string()),
332                priority: 50,
333                conditions: Vec::new(),
334                tags: Vec::new(),
335                requires_input: false,
336                estimated_duration: None,
337            },
338        }
339    }
340
341    pub fn label(mut self, label: impl Into<String>) -> Self {
342        self.command.label = label.into();
343        self
344    }
345
346    pub fn description(mut self, description: impl Into<String>) -> Self {
347        self.command.description = Some(description.into());
348        self
349    }
350
351    pub fn args(mut self, args: Vec<String>) -> Self {
352        self.command.args = args;
353        self
354    }
355
356    pub fn arg(mut self, arg: impl Into<String>) -> Self {
357        self.command.args.push(arg.into());
358        self
359    }
360
361    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
362        self.command.env.insert(key.into(), value.into());
363        self
364    }
365
366    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
367        self.command.cwd = Some(cwd.into());
368        self
369    }
370
371    pub fn category(mut self, category: CommandCategory) -> Self {
372        self.command.category = category;
373        self
374    }
375
376    pub fn priority(mut self, priority: u8) -> Self {
377        self.command.priority = priority;
378        self
379    }
380
381    pub fn condition(mut self, condition: Condition) -> Self {
382        self.command.conditions.push(condition);
383        self
384    }
385
386    pub fn tag(mut self, tag: impl Into<String>) -> Self {
387        self.command.tags.push(tag.into());
388        self
389    }
390
391    pub fn requires_input(mut self, requires_input: bool) -> Self {
392        self.command.requires_input = requires_input;
393        self
394    }
395
396    pub fn estimated_duration(mut self, seconds: u32) -> Self {
397        self.command.estimated_duration = Some(seconds);
398        self
399    }
400
401    pub fn build(self) -> Command {
402        self.command
403    }
404}
405
406/// Command execution result
407#[derive(Debug, Clone)]
408pub struct ExecutionResult {
409    pub exit_code: i32,
410    pub stdout: String,
411    pub stderr: String,
412    pub duration: std::time::Duration,
413    pub command: Command,
414}
415
416impl ExecutionResult {
417    pub fn is_success(&self) -> bool {
418        self.exit_code == 0
419    }
420
421    pub fn output(&self) -> &str {
422        if self.stdout.is_empty() {
423            &self.stderr
424        } else {
425            &self.stdout
426        }
427    }
428}
429
430/// Categories for grouping commands
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
432pub enum CommandCategory {
433    Build,
434    Test,
435    Run,
436    Debug,
437    Deploy,
438    Lint,
439    Format,
440    Generate,
441    Install,
442    Update,
443    Clean,
444    Custom(String),
445}
446
447impl CommandCategory {
448    pub fn as_str(&self) -> &str {
449        match self {
450            CommandCategory::Build => "build",
451            CommandCategory::Test => "test",
452            CommandCategory::Run => "run",
453            CommandCategory::Debug => "debug",
454            CommandCategory::Deploy => "deploy",
455            CommandCategory::Lint => "lint",
456            CommandCategory::Format => "format",
457            CommandCategory::Generate => "generate",
458            CommandCategory::Install => "install",
459            CommandCategory::Update => "update",
460            CommandCategory::Clean => "clean",
461            CommandCategory::Custom(name) => name,
462        }
463    }
464}
465
466/// Conditions that determine when a command is available
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
468pub enum Condition {
469    /// File must exist
470    FileExists(PathBuf),
471
472    /// File pattern must match
473    FilePattern(String),
474
475    /// Cursor must be in a function with this name
476    CursorInFunction(String),
477
478    /// Cursor must be in a struct with this name
479    CursorInStruct(String),
480
481    /// Cursor must be in a test function
482    CursorInTest,
483
484    /// Project must have this dependency
485    HasDependency(String),
486
487    /// Must be in a workspace
488    InWorkspace,
489
490    /// Must be on a specific platform
491    Platform(String),
492
493    /// Custom condition with expression
494    Expression(String),
495}
496
497impl Condition {
498    /// Check if this condition is met in the given context
499    pub fn is_met(&self, context: &crate::ProjectContext) -> bool {
500        match self {
501            Condition::FileExists(path) => {
502                let full_path = if path.is_absolute() {
503                    path.clone()
504                } else {
505                    context.workspace_root.join(path)
506                };
507                full_path.exists()
508            }
509            Condition::FilePattern(pattern) => {
510                Self::check_file_pattern(&context.workspace_root, pattern)
511            }
512            Condition::CursorInFunction(name) => {
513                if let Some(file_context) = &context.current_file {
514                    if let Some(symbol) = &file_context.cursor_symbol {
515                        return symbol.name == *name && symbol.kind == crate::SymbolKind::Function;
516                    }
517                }
518                false
519            }
520            Condition::CursorInStruct(name) => {
521                if let Some(file_context) = &context.current_file {
522                    if let Some(symbol) = &file_context.cursor_symbol {
523                        return symbol.name == *name && symbol.kind == crate::SymbolKind::Struct;
524                    }
525                }
526                false
527            }
528            Condition::CursorInTest => {
529                if let Some(file_context) = &context.current_file {
530                    if let Some(symbol) = &file_context.cursor_symbol {
531                        return symbol.kind == crate::SymbolKind::Test;
532                    }
533                }
534                false
535            }
536            Condition::HasDependency(dep) => context.dependencies.iter().any(|d| d.name == *dep),
537            Condition::InWorkspace => context.workspace_members.len() > 1,
538            Condition::Platform(platform) => {
539                cfg!(target_os = "windows") && platform == "windows"
540                    || cfg!(target_os = "macos") && platform == "macos"
541                    || cfg!(target_os = "linux") && platform == "linux"
542            }
543            Condition::Expression(_expr) => {
544                // TODO: Implement expression evaluation
545                false
546            }
547        }
548    }
549
550    /// Check if any files matching the given pattern exist in the workspace
551    fn check_file_pattern(workspace_root: &Path, pattern: &str) -> bool {
552        // Create the full pattern by joining with workspace root
553        let full_pattern = workspace_root.join(pattern);
554        let pattern_str = full_pattern.to_string_lossy();
555
556        // Use glob to find matching files
557        match glob::glob(&pattern_str) {
558            Ok(paths) => {
559                // Check if any paths match
560                paths.filter_map(Result::ok).next().is_some()
561            }
562            Err(_) => false,
563        }
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn test_command_builder() {
573        let cmd = Command::builder("test-build", "cargo")
574            .label("Build Project")
575            .description("Build the project in debug mode")
576            .arg("build")
577            .category(CommandCategory::Build)
578            .priority(10)
579            .tag("cargo")
580            .build();
581
582        assert_eq!(cmd.id, "test-build");
583        assert_eq!(cmd.command, "cargo");
584        assert_eq!(cmd.args, vec!["build"]);
585        assert_eq!(cmd.category, CommandCategory::Build);
586        assert_eq!(cmd.priority, 10);
587        assert!(cmd.tags.contains(&"cargo".to_string()));
588    }
589
590    #[test]
591    fn test_command_line() {
592        let cmd = Command::builder("test", "cargo")
593            .args(vec!["test".to_string(), "--release".to_string()])
594            .build();
595
596        assert_eq!(cmd.command_line(), "cargo test --release");
597    }
598
599    #[tokio::test]
600    async fn test_command_execution() {
601        let cmd = Command::builder("echo-test", "echo").arg("hello").build();
602
603        let result = cmd.execute().await.unwrap();
604        assert!(result.is_success());
605        assert_eq!(result.stdout.trim(), "hello");
606    }
607
608    #[tokio::test]
609    async fn test_leptos_watch_command() {
610        // Create a command that mimics the leptos-watch command from LeptosProvider
611        let command = CommandBuilder::new("leptos-watch", "cargo-leptos")
612            .label("Leptos Dev Watch")
613            .description("Development server with auto-reload (recommended for development)")
614            .arg("watch")
615            .category(CommandCategory::Run)
616            .priority(95)
617            .tag("dev")
618            .tag("watch")
619            .tag("leptos")
620            .estimated_duration(5)
621            .build();
622
623        // Test that the command is properly configured
624        assert_eq!(command.command, "cargo-leptos");
625        assert_eq!(command.args, vec!["watch"]);
626        assert!(command.tags.contains(&"dev".to_string()));
627        assert!(command.tags.contains(&"watch".to_string()));
628        assert!(command.tags.contains(&"leptos".to_string()));
629
630        // Note: Actual execution would fail without cargo-leptos installed
631        // This test just verifies the command structure
632    }
633}