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::mpsc;
11use tokio::sync::Mutex;
12use tokio::time;
13
14use crate::commands::build::{self, env_toml};
15
16use super::build::clients::ScaffoldEnv;
17use super::build::env_toml::ENV_FILE;
18
19pub enum Message {
20 FileChanged,
21}
22
23#[derive(Parser, Debug, Clone)]
24#[group(skip)]
25pub struct Cmd {
26 #[command(flatten)]
27 pub build_cmd: build::Command,
28}
29
30#[derive(thiserror::Error, Debug)]
31pub enum Error {
32 #[error(transparent)]
33 Watcher(#[from] notify::Error),
34 #[error(transparent)]
35 Build(#[from] build::Error),
36 #[error("IO error: {0}")]
37 Io(#[from] std::io::Error),
38 #[error(transparent)]
39 Env(#[from] env_toml::Error),
40 #[error("Failed to start docker container")]
41 DockerStart,
42 #[error(transparent)]
43 Manifest(#[from] cargo_metadata::Error),
44}
45
46fn canonicalize_path(path: &Path) -> PathBuf {
47 if path.as_os_str().is_empty() {
48 env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
49 } else if path.components().count() == 1 {
50 env::current_dir()
52 .unwrap_or_else(|_| PathBuf::from("."))
53 .join(path)
54 } else {
55 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
56 }
57}
58
59#[derive(Clone)]
60pub struct Watcher {
61 env_toml_dir: Arc<PathBuf>,
62 packages: Arc<Vec<PathBuf>>,
63 ignores: Arc<Gitignore>,
64}
65
66impl Watcher {
67 pub fn new(env_toml_dir: &Path, packages: &[PathBuf]) -> Self {
68 let env_toml_dir: Arc<PathBuf> = Arc::new(canonicalize_path(env_toml_dir));
69 let packages: Arc<Vec<PathBuf>> =
70 Arc::new(packages.iter().map(|p| canonicalize_path(p)).collect());
71
72 let mut builder = GitignoreBuilder::new(&*env_toml_dir);
73 for package in packages.iter() {
74 builder.add(package);
75 }
76
77 let common_ignores = vec![
78 "*.swp",
79 "*.swo",
80 "*.swx", "4913", ".DS_Store", "Thumbs.db", "*~", "*.bak", ".vscode/", ".idea/", "*.tmp", "*.log", ".#*", "#*#", ];
93
94 for pattern in common_ignores {
95 builder
96 .add_line(None, pattern)
97 .expect("Failed to add ignore pattern");
98 }
99
100 let ignores = Arc::new(builder.build().expect("Failed to build GitIgnore"));
101
102 Self {
103 env_toml_dir,
104 packages,
105 ignores,
106 }
107 }
108
109 pub fn is_watched(&self, path: &Path) -> bool {
110 let path = canonicalize_path(path);
111 !self.ignores.matched(&path, path.is_dir()).is_ignore()
112 }
113
114 pub fn is_env_toml(&self, path: &Path) -> bool {
115 path == self.env_toml_dir.join(ENV_FILE)
116 }
117
118 pub fn handle_event(&self, event: ¬ify::Event, tx: &mpsc::Sender<Message>) {
119 if matches!(
120 event.kind,
121 notify::EventKind::Create(_)
122 | notify::EventKind::Modify(_)
123 | notify::EventKind::Remove(_)
124 ) {
125 if let Some(path) = event.paths.first() {
126 if self.is_watched(path) {
127 eprintln!("File changed: {path:?}");
128 if let Err(e) = tx.blocking_send(Message::FileChanged) {
129 eprintln!("Error sending through channel: {e:?}");
130 }
131 }
132 }
133 }
134 }
135}
136
137impl Cmd {
138 pub async fn run(
139 &mut self,
140 global_args: &stellar_cli::commands::global::Args,
141 ) -> Result<(), Error> {
142 let printer = Print::new(global_args.quiet);
143 let (tx, mut rx) = mpsc::channel::<Message>(100);
144 let rebuild_state = Arc::new(Mutex::new(false));
145 let metadata = &self.build_cmd.metadata()?;
146 let env_toml_dir = metadata.workspace_root.as_std_path();
147 if env_toml::Environment::get(env_toml_dir, &ScaffoldEnv::Development.to_string())?
148 .is_none()
149 {
150 return Ok(());
151 }
152 let packages = self
153 .build_cmd
154 .list_packages(metadata)?
155 .into_iter()
156 .map(|package| {
157 package
158 .manifest_path
159 .parent()
160 .unwrap()
161 .to_path_buf()
162 .into_std_path_buf()
163 })
164 .collect::<Vec<_>>();
165
166 let watcher = Watcher::new(env_toml_dir, &packages);
167
168 for package_path in watcher.packages.iter() {
169 printer.infoln(format!("Watching {}", package_path.display()));
170 }
171
172 let mut notify_watcher =
173 notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
174 if let Ok(event) = res {
175 watcher.handle_event(&event, &tx);
176 }
177 })
178 .unwrap();
179
180 notify_watcher.watch(
181 &canonicalize_path(env_toml_dir),
182 RecursiveMode::NonRecursive,
183 )?;
184 for package_path in packages {
185 notify_watcher.watch(&canonicalize_path(&package_path), RecursiveMode::Recursive)?;
186 }
187
188 let build_command = self.cloned_build_command(global_args);
189 if let Err(e) = build_command.0.run(&build_command.1).await {
190 printer.errorln(format!("Build error: {e}"));
191 }
192 printer.infoln("Watching for changes. Press Ctrl+C to stop.");
193
194 let rebuild_state_clone = rebuild_state.clone();
195 let printer_clone = printer.clone();
196 loop {
197 tokio::select! {
198 _ = rx.recv() => {
199 let mut state = rebuild_state_clone.lock().await;
200 let build_command_inner = build_command.clone();
201 if !*state {
202 *state = true;
203 tokio::spawn(Self::debounced_rebuild(build_command_inner, Arc::clone(&rebuild_state_clone), printer_clone.clone()));
204 }
205 }
206 _ = tokio::signal::ctrl_c() => {
207 printer.infoln("Stopping dev mode.");
208 break;
209 }
210 }
211 }
212 Ok(())
213 }
214
215 async fn debounced_rebuild(
216 build_command: Arc<(build::Command, stellar_cli::commands::global::Args)>,
217 rebuild_state: Arc<Mutex<bool>>,
218 printer: Print,
219 ) {
220 time::sleep(std::time::Duration::from_secs(1)).await;
222
223 printer.infoln("Changes detected. Rebuilding...");
224 if let Err(e) = build_command.0.run(&build_command.1).await {
225 printer.errorln(format!("Build error: {e}"));
226 }
227 printer.infoln("Watching for changes. Press Ctrl+C to stop.");
228
229 let mut state = rebuild_state.lock().await;
230 *state = false;
231 }
232
233 fn cloned_build_command(
234 &mut self,
235 global_args: &stellar_cli::commands::global::Args,
236 ) -> Arc<(build::Command, stellar_cli::commands::global::Args)> {
237 self.build_cmd
238 .build_clients_args
239 .env
240 .get_or_insert(ScaffoldEnv::Development);
241 Arc::new((self.build_cmd.clone(), global_args.clone()))
242 }
243}