loam_cli/commands/dev/
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::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 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 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 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}