Skip to main content

vercel_rpc_cli/
watch.rs

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