1use std::io::{BufRead, Write};
28use std::net::{SocketAddr, TcpStream};
29use std::path::PathBuf;
30use std::process::{Child, Command};
31use std::time::{Duration, Instant};
32
33use anyhow::{Context, Result, bail};
34use clap::Args;
35
36use crate::{cargo, workspace_root};
37
38const LISTEN: &str = "127.0.0.1:4100";
41
42fn default_db() -> PathBuf {
45 workspace_root().join("target").join("blockworx.db")
46}
47
48fn ws_url(listen: &str) -> String {
49 format!("ws://{listen}/ws")
50}
51
52#[derive(Clone, Copy)]
55enum Profile {
56 Debug,
57 Release,
58}
59
60impl From<bool> for Profile {
61 fn from(release: bool) -> Self {
62 if release {
63 Profile::Release
64 } else {
65 Profile::Debug
66 }
67 }
68}
69
70impl Profile {
71 fn dir(self) -> &'static str {
72 match self {
73 Profile::Debug => "debug",
74 Profile::Release => "release",
75 }
76 }
77
78 fn flag(self) -> Option<&'static str> {
79 matches!(self, Profile::Release).then_some("--release")
80 }
81}
82
83#[derive(Clone, Copy)]
85enum Narration {
86 Quiet,
87 Narrating,
88}
89
90impl From<bool> for Narration {
91 fn from(narrate: bool) -> Self {
92 if narrate {
93 Narration::Narrating
94 } else {
95 Narration::Quiet
96 }
97 }
98}
99
100impl Narration {
101 fn filter(self) -> Option<&'static str> {
102 matches!(self, Narration::Narrating).then_some("blockworx=info,edit=debug")
103 }
104}
105
106#[derive(Args)]
107pub struct ServerArgs {
108 #[arg(long)]
111 db: Option<PathBuf>,
112
113 #[arg(long, default_value = LISTEN)]
115 listen: String,
116
117 #[arg(long)]
119 release: bool,
120}
121
122#[derive(Args)]
123pub struct ClientArgs {
124 #[arg(long)]
126 connect: Option<String>,
127
128 #[arg(long)]
131 narrate: bool,
132
133 #[arg(long)]
135 release: bool,
136
137 #[arg(last = true)]
139 app_args: Vec<String>,
140}
141
142#[derive(Args)]
143pub struct DemoArgs {
144 #[arg(long, default_value_t = 2)]
146 clients: usize,
147
148 #[arg(long, default_value = LISTEN)]
150 listen: String,
151
152 #[arg(long)]
155 db: Option<PathBuf>,
156
157 #[arg(long)]
159 narrate: bool,
160
161 #[arg(long)]
163 release: bool,
164}
165
166pub fn server(args: &ServerArgs) -> Result<()> {
168 let db = args.db.clone().unwrap_or_else(default_db);
169 if let Some(parent) = db.parent() {
170 std::fs::create_dir_all(parent).ok();
171 }
172 eprintln!(" {} on {}", db.display(), ws_url(&args.listen));
173 eprintln!(" Ctrl-Z freezes it (editors queue their commits), `fg` thaws it.");
174 eprintln!();
175
176 let db = db.to_string_lossy().into_owned();
177 let mut argv = vec!["run", "-p", "blockworx-server", "--bin", "blockworx-server"];
178 argv.extend(Profile::from(args.release).flag());
179 argv.extend(["--", &db, "--listen", &args.listen]);
180 cargo(&argv).run().context("running blockworx-server")?;
181 Ok(())
182}
183
184pub fn client(args: &ClientArgs) -> Result<()> {
186 let url = args.connect.clone().unwrap_or_else(|| ws_url(LISTEN));
187 eprintln!(" editing {url}");
188 eprintln!();
189
190 let mut argv = vec!["run", "-p", "blockworx", "--bin", "blockworx"];
191 argv.extend(Profile::from(args.release).flag());
192 argv.extend(["--", "--connect", &url]);
193 argv.extend(args.app_args.iter().map(String::as_str));
194
195 let mut command = cargo(&argv);
196 if let Some(filter) = Narration::from(args.narrate).filter() {
197 command = command.env("RUST_LOG", filter);
198 }
199 command.run().context("running blockworx")?;
200 Ok(())
201}
202
203pub fn demo(args: &DemoArgs) -> Result<()> {
206 if args.clients == 0 {
207 bail!("--clients must be at least 1");
208 }
209 let addr: SocketAddr = args
210 .listen
211 .parse()
212 .with_context(|| format!("--listen {} is not an address", args.listen))?;
213
214 let profile = Profile::from(args.release);
215 let narration = Narration::from(args.narrate);
216 build(profile)?;
217
218 let scratch = args.db.is_none();
219 let db = args.db.clone().unwrap_or_else(|| {
220 workspace_root()
221 .join("target")
222 .join("demo-scratch.blockworx.db")
223 });
224 if scratch && db.exists() {
225 std::fs::remove_file(&db).ok();
226 }
227
228 let mut session = Session {
229 server: spawn_server(profile, &db, &args.listen)?,
230 clients: Vec::new(),
231 db: scratch.then(|| db.clone()),
232 frozen: false,
233 };
234 wait_for(addr).context("the server never accepted a connection")?;
235
236 let url = ws_url(&args.listen);
237 for _ in 0..args.clients {
238 session
239 .clients
240 .push(spawn_client(profile, narration, &url)?);
241 }
242
243 eprintln!();
244 eprintln!(" {} editors on {}", args.clients, db.display());
245 eprintln!(" serving {url}");
246 eprintln!();
247 eprintln!(" Edit in either window; the other follows. To see the case hands");
248 eprintln!(" cannot reach, wait for both titles to say `live`, then freeze,");
249 eprintln!(" edit the same block in both, and thaw: the later commit wins and");
250 eprintln!(" both canvases agree.");
251 eprintln!();
252 console(&mut session)
253}
254
255struct Session {
266 server: Child,
267 clients: Vec<Child>,
268 db: Option<PathBuf>,
269 frozen: bool,
270}
271
272impl Drop for Session {
273 fn drop(&mut self) {
274 if self.frozen {
275 signal(&self.server, "-CONT");
277 }
278 for child in self.clients.iter_mut().chain([&mut self.server]) {
279 child.kill().ok();
280 child.wait().ok();
281 }
282 if let Some(db) = &self.db {
283 std::fs::remove_file(db).ok();
284 }
285 }
286}
287
288fn console(session: &mut Session) -> Result<()> {
289 let stdin = std::io::stdin();
290 loop {
291 eprint!(
292 "{} [f]reeze [t]haw [q]uit > ",
293 if session.frozen { "frozen" } else { "live " }
294 );
295 std::io::stderr().flush().ok();
296
297 let mut line = String::new();
298 if stdin.lock().read_line(&mut line)? == 0 {
299 return Ok(());
300 }
301 match line.trim() {
302 "f" | "freeze" if !session.frozen => {
303 signal(&session.server, "-STOP");
304 session.frozen = true;
305 eprintln!(" server stopped — edits now queue in each editor, unacknowledged");
306 }
307 "t" | "thaw" if session.frozen => {
308 signal(&session.server, "-CONT");
309 session.frozen = false;
310 eprintln!(" server running — the queues flush and the titles catch up");
311 }
312 "f" | "t" | "freeze" | "thaw" => eprintln!(" already there"),
313 "q" | "quit" | "" => return Ok(()),
314 other => eprintln!(" don't know {other:?}"),
315 }
316 }
317}
318
319fn build(profile: Profile) -> Result<()> {
320 let mut argv = vec![
321 "build",
322 "-p",
323 "blockworx",
324 "--bin",
325 "blockworx",
326 "-p",
327 "blockworx-server",
328 "--bin",
329 "blockworx-server",
330 ];
331 argv.extend(profile.flag());
332 cargo(&argv).run().context("building the demo binaries")?;
333 Ok(())
334}
335
336fn bin(profile: Profile, name: &str) -> PathBuf {
337 workspace_root()
338 .join("target")
339 .join(profile.dir())
340 .join(name)
341}
342
343fn spawn_server(profile: Profile, db: &PathBuf, listen: &str) -> Result<Child> {
344 Command::new(bin(profile, "blockworx-server"))
345 .arg(db)
346 .args(["--listen", listen])
347 .current_dir(workspace_root())
348 .spawn()
349 .context("starting blockworx-server")
350}
351
352fn spawn_client(profile: Profile, narration: Narration, url: &str) -> Result<Child> {
353 let mut cmd = Command::new(bin(profile, "blockworx"));
354 cmd.args(["--connect", url]).current_dir(workspace_root());
355 if let Some(filter) = narration.filter() {
356 cmd.env("RUST_LOG", filter);
357 }
358 cmd.spawn().context("starting blockworx")
359}
360
361fn signal(child: &Child, sig: &str) {
364 Command::new("kill")
365 .args([sig, &child.id().to_string()])
366 .status()
367 .ok();
368}
369
370fn wait_for(addr: SocketAddr) -> Result<()> {
371 let deadline = Instant::now() + Duration::from_secs(20);
372 while Instant::now() < deadline {
373 if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
374 return Ok(());
375 }
376 std::thread::sleep(Duration::from_millis(100));
377 }
378 bail!("{addr} did not come up within 20s")
379}