stellar_scaffold_cli/commands/watch/
mod.rs1use 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 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: ¬ify::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 let extensions = if current_env.extensions.is_empty() {
158 vec![]
159 } else {
160 extension::discover(¤t_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 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 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 #[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 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 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 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}