Skip to main content

vercel_rpc_cli/
watch.rs

1use std::path::Path;
2use std::sync::atomic::{AtomicBool, Ordering::Relaxed};
3use std::sync::{Arc, mpsc};
4use std::time::Instant;
5
6use anyhow::{Context, Result};
7use colored::Colorize;
8use notify::RecursiveMode;
9use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
10
11use crate::commands;
12use crate::config::RpcConfig;
13
14/// Runs the watch loop: performs an initial generation, then watches for changes
15/// in the api directory and regenerates TypeScript files on each change.
16///
17/// Blocks until the process receives SIGINT (Ctrl+C).
18#[cfg(not(tarpaulin_include))]
19pub fn run(config: &RpcConfig) -> Result<()> {
20    let running = Arc::new(AtomicBool::new(true));
21    let running_clone = running.clone();
22
23    ctrlc::set_handler(move || {
24        running_clone.store(false, Relaxed);
25    })
26    .context("Failed to set Ctrl+C handler")?;
27
28    if config.watch.clear_screen {
29        clear_screen();
30    }
31    print_banner(config);
32
33    // Initial generation
34    if let Err(e) = generate(config) {
35        print_error(&e);
36    }
37
38    // Set up file watcher with debouncing
39    let (tx, rx) = mpsc::channel();
40    let debounce_duration = std::time::Duration::from_millis(config.watch.debounce_ms);
41
42    let mut debouncer =
43        new_debouncer(debounce_duration, tx).context("Failed to create file watcher")?;
44
45    debouncer
46        .watcher()
47        .watch(config.input.dir.as_ref(), RecursiveMode::Recursive)
48        .with_context(|| format!("Failed to watch {}", config.input.dir.display()))?;
49
50    println!(
51        "  {} for changes in {}\n",
52        "Watching".cyan().bold(),
53        config.input.dir.display().to_string().underline(),
54    );
55
56    while running.load(Relaxed) {
57        match rx.recv_timeout(std::time::Duration::from_millis(100)) {
58            Ok(Ok(events)) => {
59                let has_rs_change = events.iter().any(|e| {
60                    e.kind == DebouncedEventKind::Any
61                        && e.path.extension().is_some_and(|ext| ext == "rs")
62                });
63
64                if has_rs_change {
65                    let changed: Vec<&Path> = events
66                        .iter()
67                        .filter(|e| e.path.extension().is_some_and(|ext| ext == "rs"))
68                        .map(|e| e.path.as_path())
69                        .collect();
70
71                    if config.watch.clear_screen {
72                        clear_screen();
73                        print_banner(config);
74                    }
75
76                    print_change(&changed);
77
78                    if let Err(e) = generate(config) {
79                        print_error(&e);
80                    }
81                }
82            }
83            Ok(Err(errs)) => {
84                eprintln!("  {} Watch error: {errs}", "✗".red().bold());
85            }
86            Err(mpsc::RecvTimeoutError::Timeout) => continue,
87            Err(mpsc::RecvTimeoutError::Disconnected) => break,
88        }
89    }
90
91    println!("\n  {} Stopped watching.", "●".dimmed());
92    Ok(())
93}
94
95/// Performs a full scan + generation cycle, printing timing info.
96#[cfg(not(tarpaulin_include))]
97fn generate(config: &RpcConfig) -> Result<()> {
98    let start = Instant::now();
99
100    let manifest = commands::generate_all(config)?;
101
102    let elapsed = start.elapsed();
103
104    println!(
105        "  {} Generated {} procedure(s), {} struct(s) in {:.0?}",
106        "✓".green().bold(),
107        manifest.procedures.len().to_string().bold(),
108        manifest.structs.len().to_string().bold(),
109        elapsed,
110    );
111    println!(
112        "    {} {}",
113        "→".dimmed(),
114        config.output.types.display().to_string().dimmed(),
115    );
116    println!(
117        "    {} {}",
118        "→".dimmed(),
119        config.output.client.display().to_string().dimmed(),
120    );
121    for path in [
122        &config.output.svelte,
123        &config.output.react,
124        &config.output.vue,
125        &config.output.solid,
126    ]
127    .into_iter()
128    .flatten()
129    {
130        println!(
131            "    {} {}",
132            "→".dimmed(),
133            path.display().to_string().dimmed(),
134        );
135    }
136
137    Ok(())
138}
139
140#[cfg(not(tarpaulin_include))]
141fn print_banner(config: &RpcConfig) {
142    println!();
143    println!("  {} {}", "vercel-rpc".bold(), "watch mode".cyan(),);
144    println!("  {} {}", "api dir:".dimmed(), config.input.dir.display(),);
145    println!("  {} {}", "types:".dimmed(), config.output.types.display(),);
146    println!(
147        "  {} {}",
148        "client:".dimmed(),
149        config.output.client.display(),
150    );
151    if let Some(svelte) = &config.output.svelte {
152        println!("  {} {}", "svelte:".dimmed(), svelte.display(),);
153    }
154    if let Some(react) = &config.output.react {
155        println!("  {} {}", "react:".dimmed(), react.display(),);
156    }
157    if let Some(vue) = &config.output.vue {
158        println!("  {} {}", "vue:".dimmed(), vue.display(),);
159    }
160    if let Some(solid) = &config.output.solid {
161        println!("  {} {}", "solid:".dimmed(), solid.display(),);
162    }
163    println!();
164}
165
166#[cfg(not(tarpaulin_include))]
167fn print_change(paths: &[&Path]) {
168    for p in paths {
169        let name = p
170            .file_name()
171            .map(|n| n.to_string_lossy())
172            .unwrap_or_default();
173        println!("\n  {} {}", "↻".yellow().bold(), name);
174    }
175}
176
177#[cfg(not(tarpaulin_include))]
178fn clear_screen() {
179    print!("\x1B[2J\x1B[H");
180}
181
182#[cfg(not(tarpaulin_include))]
183fn print_error(err: &anyhow::Error) {
184    eprintln!("  {} {err:#}", "✗".red().bold());
185    for cause in err.chain().skip(1) {
186        eprintln!("    {} {cause}", "caused by:".dimmed());
187    }
188}