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