command_stream/development/
watch.rs1use super::child::ManagedChild;
2use crate::trace::trace_lazy;
3use glob::{MatchOptions, Pattern};
4use std::collections::{BTreeMap, BTreeSet};
5use std::io;
6use std::path::{Path, PathBuf};
7use std::process::Stdio;
8use std::time::{Duration, Instant, SystemTime};
9use tokio::process::Command;
10use tokio::sync::{mpsc, watch};
11use tokio::task::JoinHandle;
12
13#[derive(Debug, Clone)]
15pub struct DevOptions {
16 pub cwd: PathBuf,
17 pub watch: Vec<String>,
18 pub command: Vec<String>,
20 pub initial: bool,
21 pub interval: Duration,
22 pub debounce: Duration,
23}
24
25impl Default for DevOptions {
26 fn default() -> Self {
27 Self {
28 cwd: std::env::current_dir().unwrap_or_else(|_| ".".into()),
29 watch: vec![
30 "**/*.rs".into(),
31 "**/Cargo.toml".into(),
32 "**/Cargo.lock".into(),
33 ],
34 command: vec!["cargo".into(), "run".into()],
35 initial: true,
36 interval: Duration::from_millis(100),
37 debounce: Duration::from_millis(50),
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
43pub enum DevEvent {
44 Restarted {
45 files: Vec<PathBuf>,
47 pid: Option<u32>,
48 },
49 Error(String),
50}
51
52pub struct DevController {
55 stop: watch::Sender<bool>,
56 task: Option<JoinHandle<io::Result<()>>>,
57 events: mpsc::Receiver<DevEvent>,
58}
59
60impl DevController {
61 pub async fn next_event(&mut self) -> Option<DevEvent> {
62 self.events.recv().await
63 }
64 pub async fn close(mut self) -> io::Result<()> {
65 let _ = self.stop.send(true);
66 self.task.take().unwrap().await.map_err(io::Error::other)?
67 }
68}
69impl Drop for DevController {
70 fn drop(&mut self) {
71 let _ = self.stop.send(true);
72 }
73}
74
75type Snapshot = BTreeMap<PathBuf, (SystemTime, u64)>;
76fn snapshot(root: &Path, patterns: &[Pattern]) -> io::Result<Snapshot> {
77 let mut files = BTreeMap::new();
78 let mut directories = vec![root.to_owned()];
79 let options = MatchOptions {
80 require_literal_separator: true,
81 ..MatchOptions::new()
82 };
83 while let Some(dir) = directories.pop() {
84 for entry in std::fs::read_dir(dir)? {
85 let entry = entry?;
86 let name = entry.file_name();
87 if ["target", ".git", "node_modules"]
88 .iter()
89 .any(|ignored| name == *ignored)
90 || name.to_string_lossy().starts_with(".command-stream-")
91 {
92 continue;
93 }
94 let path = entry.path();
95 let file_type = entry.file_type()?;
96 if file_type.is_dir() {
97 directories.push(path);
98 } else if file_type.is_file()
99 && patterns.iter().any(|pattern| {
100 pattern.matches_path_with(path.strip_prefix(root).unwrap(), options)
101 })
102 {
103 match entry.metadata() {
104 Ok(info) => {
105 files.insert(path, (info.modified()?, info.len()));
106 }
107 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
108 Err(error) => return Err(error),
109 }
110 }
111 }
112 }
113 Ok(files)
114}
115
116fn spawn(options: &DevOptions) -> io::Result<ManagedChild> {
117 let mut command = Command::new(&options.command[0]);
118 command
119 .args(&options.command[1..])
120 .current_dir(&options.cwd)
121 .env("COMMAND_STREAM_DEV_CHILD", "1")
122 .stdin(Stdio::null())
123 .stdout(Stdio::inherit())
124 .stderr(Stdio::inherit());
125 ManagedChild::spawn(&mut command, false)
126}
127
128pub async fn dev(mut options: DevOptions) -> io::Result<DevController> {
131 if options.interval < Duration::from_millis(10) || options.command.is_empty() {
132 return Err(io::Error::new(
133 io::ErrorKind::InvalidInput,
134 "Invalid watch interval or empty command",
135 ));
136 }
137 options.cwd = options.cwd.canonicalize()?;
138 let patterns = options
139 .watch
140 .iter()
141 .map(|pattern| Pattern::new(pattern.trim_start_matches("./")))
142 .collect::<std::result::Result<Vec<_>, _>>()
143 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
144 let previous = snapshot(&options.cwd, &patterns)?;
145 let (stop, receiver) = watch::channel(false);
146 let (tx, events) = mpsc::channel(32);
147 let child_mode = std::env::var_os("COMMAND_STREAM_DEV_CHILD").is_some();
148 let child = if options.initial && !child_mode {
149 Some(spawn(&options)?)
150 } else {
151 None
152 };
153 if let Some(child) = &child {
154 let _ = tx.try_send(DevEvent::Restarted {
155 files: vec![],
156 pid: child.child.id(),
157 });
158 }
159 let task = tokio::spawn(async move {
160 if child_mode {
161 return Ok(());
162 }
163 watch_loop(options, patterns, previous, child, receiver, tx).await
164 });
165 Ok(DevController {
166 stop,
167 task: Some(task),
168 events,
169 })
170}
171
172async fn watch_loop(
173 options: DevOptions,
174 patterns: Vec<Pattern>,
175 mut previous: Snapshot,
176 mut child: Option<ManagedChild>,
177 mut stop: watch::Receiver<bool>,
178 events: mpsc::Sender<DevEvent>,
179) -> io::Result<()> {
180 let mut tick = tokio::time::interval(options.interval);
181 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
182 let mut changes = BTreeSet::new();
183 let mut changed_at = Instant::now();
184 loop {
185 tokio::select! {
186 _ = stop.changed() => break,
187 _ = tick.tick() => {
188 let next = match snapshot(&options.cwd, &patterns) {
189 Ok(next) => next,
190 Err(error) => { let _ = events.try_send(DevEvent::Error(error.to_string())); continue; }
191 };
192 for file in previous.keys().chain(next.keys()) {
193 if previous.get(file) != next.get(file) {
194 changes.insert(file.clone());
195 changed_at = Instant::now();
196 }
197 }
198 previous = next;
199 if !changes.is_empty() && changed_at.elapsed() >= options.debounce {
200 if let Some(mut current) = child.take() { current.stop().await?; }
201 if *stop.borrow() { break; }
202 let files = std::mem::take(&mut changes).into_iter().collect::<Vec<_>>();
203 trace_lazy("Development", || format!("Restarting after changes: {files:?}"));
204 match spawn(&options) {
205 Ok(next) => {
206 let _ = events.try_send(DevEvent::Restarted { files, pid: next.child.id() });
207 child = Some(next);
208 }
209 Err(error) => { let _ = events.try_send(DevEvent::Error(error.to_string())); }
210 }
211 }
212 }
213 }
214 }
215 if let Some(mut child) = child {
216 child.stop().await?;
217 }
218 Ok(())
219}