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::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        // Path is a single component, assuming it's a filename
51        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",     // Vim swap files
81            "4913",      // Vim temp files
82            ".DS_Store", // macOS
83            "Thumbs.db", // Windows
84            "*~",        // Backup files
85            "*.bak",     // Backup files
86            ".vscode/",  // VS Code
87            ".idea/",    // IntelliJ
88            "*.tmp",     // Temporary files
89            "*.log",     // Log files
90            ".#*",       // Emacs lock files
91            "#*#",       // Emacs auto-save files
92        ];
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: &notify::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        // Debounce to avoid multiple rapid rebuilds
221        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}