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