1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct Command {
16 pub id: String,
18
19 pub label: String,
21
22 pub description: Option<String>,
24
25 pub command: String,
27
28 pub args: Vec<String>,
30
31 pub env: HashMap<String, String>,
33
34 pub cwd: Option<PathBuf>,
36
37 pub category: CommandCategory,
39
40 pub priority: u8,
42
43 pub conditions: Vec<Condition>,
45
46 pub tags: Vec<String>,
48
49 pub requires_input: bool,
51
52 pub estimated_duration: Option<u32>,
54}
55
56impl Command {
57 pub fn builder(id: impl Into<String>, command: impl Into<String>) -> CommandBuilder {
59 CommandBuilder::new(id, command)
60 }
61
62 pub async fn execute(&self) -> RazResult<ExecutionResult> {
64 self.execute_with_options(false, None).await
65 }
66
67 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 async fn execute_with_options(
78 &self,
79 should_open_browser: bool,
80 browser: Option<String>,
81 ) -> RazResult<ExecutionResult> {
82 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 fn is_interactive(&self) -> bool {
95 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 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 for (key, value) in &self.env {
120 cmd.env(key, value);
121 }
122
123 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 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 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 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 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 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 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 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 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(), stderr: String::new(), duration,
228 command: self.clone(),
229 })
230 }
231 }
232
233 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 async fn execute_captured(&self) -> RazResult<ExecutionResult> {
261 let mut cmd = TokioCommand::new(&self.command);
262 cmd.args(&self.args);
263
264 for (key, value) in &self.env {
266 cmd.env(key, value);
267 }
268
269 if let Some(cwd) = &self.cwd {
271 cmd.current_dir(cwd);
272 }
273
274 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 pub fn is_available(&self, context: &crate::ProjectContext) -> bool {
299 self.conditions
300 .iter()
301 .all(|condition| condition.is_met(context))
302 }
303
304 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
314pub 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
468pub enum Condition {
469 FileExists(PathBuf),
471
472 FilePattern(String),
474
475 CursorInFunction(String),
477
478 CursorInStruct(String),
480
481 CursorInTest,
483
484 HasDependency(String),
486
487 InWorkspace,
489
490 Platform(String),
492
493 Expression(String),
495}
496
497impl Condition {
498 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 false
546 }
547 }
548 }
549
550 fn check_file_pattern(workspace_root: &Path, pattern: &str) -> bool {
552 let full_pattern = workspace_root.join(pattern);
554 let pattern_str = full_pattern.to_string_lossy();
555
556 match glob::glob(&pattern_str) {
558 Ok(paths) => {
559 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 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 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 }
633}