Skip to main content

stellar_scaffold_cli/commands/watch/
mod.rs

1use clap::Parser;
2use ignore::gitignore::{Gitignore, GitignoreBuilder};
3use notify::{self, RecursiveMode, Watcher as _};
4use std::{
5    env, fs,
6    path::{Path, PathBuf},
7    sync::Arc,
8};
9use stellar_cli::print::Print;
10use tokio::sync::Mutex;
11use tokio::sync::mpsc;
12use tokio::time;
13
14use crate::commands::build::{self, env_toml};
15use crate::extension;
16use stellar_scaffold_ext_types::{HookName, ProjectContext, ProjectContractInfo};
17
18use super::build::clients::ScaffoldEnv;
19use super::build::env_toml::ENV_FILE;
20
21pub enum Message {
22    FileChanged,
23}
24
25#[derive(Parser, Debug, Clone)]
26#[group(skip)]
27pub struct Cmd {
28    #[command(flatten)]
29    pub build_cmd: build::Command,
30}
31
32#[derive(thiserror::Error, Debug)]
33pub enum Error {
34    #[error(transparent)]
35    Watcher(#[from] notify::Error),
36    #[error(transparent)]
37    Build(#[from] build::Error),
38    #[error("IO error: {0}")]
39    Io(#[from] std::io::Error),
40    #[error(transparent)]
41    Env(#[from] env_toml::Error),
42    #[error("Failed to start docker container")]
43    DockerStart,
44    #[error(transparent)]
45    Manifest(#[from] cargo_metadata::Error),
46}
47
48fn canonicalize_path(path: &Path) -> PathBuf {
49    if path.as_os_str().is_empty() {
50        env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
51    } else if path.components().count() == 1 {
52        // Path is a single component, assuming it's a filename
53        env::current_dir()
54            .unwrap_or_else(|_| PathBuf::from("."))
55            .join(path)
56    } else {
57        fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
58    }
59}
60
61#[derive(Clone)]
62pub struct Watcher {
63    env_toml_dir: Arc<PathBuf>,
64    packages: Arc<Vec<PathBuf>>,
65    ignores: Arc<Gitignore>,
66}
67
68impl Watcher {
69    pub fn new(env_toml_dir: &Path, packages: &[PathBuf]) -> Self {
70        let env_toml_dir: Arc<PathBuf> = Arc::new(canonicalize_path(env_toml_dir));
71        let packages: Arc<Vec<PathBuf>> =
72            Arc::new(packages.iter().map(|p| canonicalize_path(p)).collect());
73
74        let mut builder = GitignoreBuilder::new(&*env_toml_dir);
75        for package in packages.iter() {
76            builder.add(package);
77        }
78
79        let ignores = Arc::new(builder.build().expect("Failed to build GitIgnore"));
80
81        Self {
82            env_toml_dir,
83            packages,
84            ignores,
85        }
86    }
87
88    pub fn is_watched(&self, path: &Path) -> bool {
89        let path = canonicalize_path(path);
90        !self.ignores.matched(&path, path.is_dir()).is_ignore()
91    }
92
93    pub fn is_env_toml(&self, path: &Path) -> bool {
94        path == self.env_toml_dir.join(ENV_FILE)
95    }
96
97    pub fn handle_event(&self, event: &notify::Event, tx: &mpsc::Sender<Message>) {
98        if matches!(
99            event.kind,
100            notify::EventKind::Create(notify::event::CreateKind::File)
101                | notify::EventKind::Modify(notify::event::ModifyKind::Data(_))
102                | notify::EventKind::Remove(notify::event::RemoveKind::File)
103        ) {
104            let watched_file = event.paths.iter().find(|path| {
105                let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
106                    return false;
107                };
108                if ext.eq_ignore_ascii_case("toml") {
109                    let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
110                        return false;
111                    };
112                    if stem.eq_ignore_ascii_case("environments")
113                        || stem.eq_ignore_ascii_case("cargo")
114                    {
115                        return self.is_watched(path);
116                    }
117                } else if ext.eq_ignore_ascii_case("rs") {
118                    return self.is_watched(path);
119                }
120                false
121            });
122
123            if let Some(path) = watched_file {
124                eprintln!("File changed: {}", path.display());
125                if let Err(e) = tx.blocking_send(Message::FileChanged) {
126                    eprintln!("Error sending through channel: {e:?}");
127                }
128            }
129        }
130    }
131}
132
133impl Cmd {
134    #[allow(clippy::too_many_lines)]
135    pub async fn run(
136        &mut self,
137        global_args: &stellar_cli::commands::global::Args,
138    ) -> Result<(), Error> {
139        let printer = Print::new(global_args.quiet);
140        let (tx, mut rx) = mpsc::channel::<Message>(100);
141        let rebuild_state = Arc::new(Mutex::new(false));
142        let metadata = &self.build_cmd.metadata()?;
143        let workspace_root = metadata.workspace_root.as_std_path();
144
145        let scaffold_env = self
146            .build_cmd
147            .build_clients_args
148            .env
149            .unwrap_or(ScaffoldEnv::Development);
150
151        let Some(current_env) = env_toml::Environment::get(workspace_root, &scaffold_env)? else {
152            return Ok(());
153        };
154
155        // Discover extensions for pre/post-dev hooks. The build pipeline hooks
156        // (compile/deploy/codegen) are handled inside build::Command::run().
157        let extensions = if current_env.extensions.is_empty() {
158            vec![]
159        } else {
160            extension::discover(&current_env.extensions, &printer)
161        };
162
163        let all_packages = self.build_cmd.list_packages(metadata)?;
164        let packages: Vec<PathBuf> = all_packages
165            .iter()
166            .map(|p| {
167                p.manifest_path
168                    .parent()
169                    .unwrap()
170                    .to_path_buf()
171                    .into_std_path_buf()
172            })
173            .collect();
174
175        let watcher = Watcher::new(workspace_root, &packages);
176
177        for package_path in watcher.packages.iter() {
178            printer.infoln(format!("Watching {}", package_path.display()));
179        }
180
181        let mut notify_watcher =
182            notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
183                if let Ok(event) = res {
184                    watcher.handle_event(&event, &tx);
185                }
186            })
187            .unwrap();
188
189        notify_watcher.watch(
190            &canonicalize_path(workspace_root),
191            RecursiveMode::NonRecursive,
192        )?;
193        for package_path in &packages {
194            notify_watcher.watch(&canonicalize_path(package_path), RecursiveMode::Recursive)?;
195        }
196
197        // Build a ProjectContext for pre/post-dev hooks. Both hooks receive the
198        // same context: per-contract wasm/deploy fields are not available at
199        // this level (extensions that need them should use compile/deploy/codegen
200        // hooks instead).
201        let target_dir = metadata.target_directory.as_std_path();
202        let watch_paths: Vec<PathBuf> = std::iter::once(workspace_root.to_path_buf())
203            .chain(packages.iter().cloned())
204            .collect();
205        let project_ctx = ProjectContext {
206            project_root: workspace_root.to_path_buf(),
207            env: scaffold_env.to_string(),
208            wasm_out_dir: stellar_build::deps::stellar_wasm_out_dir(target_dir),
209            source_dirs: packages.clone(),
210            network: None,
211            contracts: all_packages
212                .iter()
213                .map(|p| ProjectContractInfo {
214                    name: p.name.replace('-', "_"),
215                    source_dir: p
216                        .manifest_path
217                        .parent()
218                        .unwrap()
219                        .as_std_path()
220                        .to_path_buf(),
221                    wasm_path: None,
222                    wasm_hash: None,
223                    contract_id: None,
224                    ts_package_dir: None,
225                    src_template_path: None,
226                })
227                .collect(),
228            watch_paths,
229        };
230
231        // Fire pre-dev once before any build work begins.
232        extension::run_hook(&extensions, HookName::PreDev, &project_ctx, &printer).await;
233
234        let build_command = self.cloned_build_command(global_args);
235        if let Err(e) = build_command.0.run(&build_command.1).await {
236            printer.errorln(format!("Build error: {e}"));
237        }
238        printer.infoln("Watching for changes. Press Ctrl+C to stop.");
239
240        // Set up SIGTERM handler so graceful shutdown fires post-dev on both
241        // Ctrl+C (SIGINT) and SIGTERM.
242        #[cfg(unix)]
243        let mut sigterm =
244            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
245
246        let rebuild_state_clone = rebuild_state.clone();
247        let printer_clone = printer.clone();
248        loop {
249            // `tokio::select!` doesn't support `#[cfg]` on arms, so the SIGTERM
250            // future is expressed as an async block whose body is platform-gated.
251            // On non-Unix it becomes `pending()` and never resolves.
252            let stop = async {
253                #[cfg(unix)]
254                {
255                    sigterm.recv().await;
256                }
257                #[cfg(not(unix))]
258                {
259                    std::future::pending::<()>().await;
260                }
261            };
262            tokio::select! {
263                _ = rx.recv() => {
264                    let mut state = rebuild_state_clone.lock().await;
265                    let build_command_inner = build_command.clone();
266                    if !*state {
267                        *state = true;
268                        tokio::spawn(Self::debounced_rebuild(build_command_inner, Arc::clone(&rebuild_state_clone), printer_clone.clone()));
269                    }
270                }
271                _ = tokio::signal::ctrl_c() => {
272                    printer.infoln("Stopping dev mode.");
273                    break;
274                }
275                () = stop => {
276                    printer.infoln("Stopping dev mode.");
277                    break;
278                }
279            }
280        }
281
282        // Fire post-dev after the loop — guaranteed to run for both Ctrl+C and
283        // SIGTERM shutdowns.
284        extension::run_hook(&extensions, HookName::PostDev, &project_ctx, &printer).await;
285
286        Ok(())
287    }
288
289    async fn debounced_rebuild(
290        build_command: Arc<(build::Command, stellar_cli::commands::global::Args)>,
291        rebuild_state: Arc<Mutex<bool>>,
292        printer: Print,
293    ) {
294        // Debounce to avoid multiple rapid rebuilds
295        time::sleep(std::time::Duration::from_secs(1)).await;
296
297        printer.infoln("Changes detected. Rebuilding...");
298        if let Err(e) = build_command.0.run(&build_command.1).await {
299            printer.errorln(format!("Build error: {e}"));
300        }
301        printer.infoln("Watching for changes. Press Ctrl+C to stop.");
302
303        let mut state = rebuild_state.lock().await;
304        *state = false;
305    }
306
307    fn cloned_build_command(
308        &mut self,
309        global_args: &stellar_cli::commands::global::Args,
310    ) -> Arc<(build::Command, stellar_cli::commands::global::Args)> {
311        self.build_cmd
312            .build_clients_args
313            .env
314            .get_or_insert(ScaffoldEnv::Development);
315        Arc::new((self.build_cmd.clone(), global_args.clone()))
316    }
317}