loam_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::LoamEnv;
16use super::build::env_toml::ENV_FILE;
17
18pub mod docker;
19
20pub enum Message {
21    FileChanged,
22}
23
24#[derive(Parser, Debug, Clone)]
25#[group(skip)]
26pub struct Cmd {
27    #[command(flatten)]
28    pub build_cmd: build::Cmd,
29}
30
31#[derive(thiserror::Error, Debug)]
32pub enum Error {
33    #[error(transparent)]
34    Watcher(#[from] notify::Error),
35    #[error(transparent)]
36    Build(#[from] build::Error),
37    #[error("IO error: {0}")]
38    Io(#[from] std::io::Error),
39    #[error(transparent)]
40    Env(#[from] env_toml::Error),
41    #[error("Failed to start docker container")]
42    DockerStart,
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 workspace_root: &Path = self
141            .build_cmd
142            .manifest_path
143            .parent()
144            .unwrap_or_else(|| Path::new("."));
145        let env_toml_dir = workspace_root;
146        let Some(current_env) =
147            env_toml::Environment::get(workspace_root, &LoamEnv::Development.to_string())?
148        else {
149            return Ok(());
150        };
151        if current_env.network.run_locally {
152            eprintln!("Starting local Stellar Docker container...");
153            docker::start_local_stellar().await.map_err(|e| {
154                eprintln!("Failed to start Stellar Docker container: {e:?}");
155                Error::DockerStart
156            })?;
157            eprintln!("Local Stellar network is healthy and running.");
158        }
159        let packages = self
160            .build_cmd
161            .list_packages()?
162            .into_iter()
163            .map(|package| PathBuf::from(package.manifest_path.parent().unwrap().as_str()))
164            .collect::<Vec<_>>();
165
166        let watcher = Watcher::new(env_toml_dir, &packages);
167
168        for package_path in watcher.packages.iter() {
169            eprintln!("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();
189        if let Err(e) = build_command.run().await {
190            eprintln!("Build error: {e}");
191        }
192        eprintln!("Watching for changes. Press Ctrl+C to stop.");
193
194        let rebuild_state_clone = rebuild_state.clone();
195        loop {
196            tokio::select! {
197                _ = rx.recv() => {
198                    let mut state = rebuild_state_clone.lock().await;
199                    let build_command_inner = build_command.clone();
200                    if !*state {
201                        *state = true;
202                        tokio::spawn(Self::debounced_rebuild(build_command_inner, Arc::clone(&rebuild_state_clone)));
203                    }
204                }
205                _ = tokio::signal::ctrl_c() => {
206                    eprintln!("Stopping dev mode.");
207                    break;
208                }
209            }
210        }
211        Ok(())
212    }
213
214    async fn debounced_rebuild(build_command: Arc<build::Cmd>, rebuild_state: Arc<Mutex<bool>>) {
215        // Debounce to avoid multiple rapid rebuilds
216        time::sleep(std::time::Duration::from_secs(1)).await;
217
218        eprintln!("Changes detected. Rebuilding...");
219        if let Err(e) = build_command.run().await {
220            eprintln!("Build error: {e}");
221        }
222        eprintln!("Watching for changes. Press Ctrl+C to stop.");
223
224        let mut state = rebuild_state.lock().await;
225        *state = false;
226    }
227
228    fn cloned_build_command(&mut self) -> Arc<build::Cmd> {
229        self.build_cmd
230            .build_clients_args
231            .env
232            .get_or_insert(LoamEnv::Development);
233        Arc::new(self.build_cmd.clone())
234    }
235}