1use std::collections::{BTreeMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::{Child, Command, ExitStatus, Stdio};
5use std::sync::Arc;
6use std::time::{Duration, SystemTime};
7
8use sofie_check::report;
9use sofie_model::App;
10
11use crate::cargo::{self, Diagnostics};
12use crate::child_process::{ChildProcess, ProcessGroup, stop_on_interrupt};
13use crate::clients::build_clients;
14use crate::gen_command::{GenError, GenMode, generated_app, io_error};
15use crate::islands::{IslandMode, build_islands};
16use crate::local_app::{database_urls, remote_database_key};
17use crate::log_buffer::LogBuffer;
18
19const POLL: Duration = Duration::from_millis(300);
20pub(crate) const DEVELOPMENT: &[&str] = &["--features", "sofie/development"];
21pub(crate) const NOT_SOURCES: [&str; 3] = ["target", "node_modules", ".git"];
22
23type Snapshot = BTreeMap<PathBuf, SystemTime>;
24
25#[derive(Debug, PartialEq, Eq)]
26enum Change {
27 Templates,
28 Islands,
29 Code,
30}
31
32pub fn dev(root: &Path, at: Option<&str>, remote_database: bool) -> Result<(), GenError> {
35 if !remote_database {
36 refuse_remote_development(&database_urls(root)?)?;
37 }
38 stop_on_interrupt().map_err(|source| io_error(root, source))?;
39 let mut app = DevApp::new(root, at, AppOutput::Terminal);
40 report_started(app.rebuild().map(Some));
41 loop {
42 std::thread::sleep(POLL);
43 if let Some(status) = app.exited()? {
44 eprintln!("sofie dev: the app exited with {status}; waiting for a change");
45 }
46 report_started(app.refresh());
47 }
48}
49
50fn refuse_remote_development(urls: &BTreeMap<String, String>) -> Result<(), GenError> {
51 match remote_database_key(urls) {
52 Some(key) => Err(GenError::Usage(format!(
53 "`sofie dev` migrates and writes to the app's database, and {key} names a host other \
54 than this machine; pass `--remote-database` to run against it anyway"
55 ))),
56 None => Ok(()),
57 }
58}
59
60fn report_started(started: Result<Option<u32>, GenError>) {
61 match started {
62 Ok(Some(pid)) => println!("sofie dev: running the app (pid {pid})"),
63 Ok(None) => {}
64 Err(error) => eprintln!("sofie dev: {error}\nsofie dev: waiting for a change"),
65 }
66}
67
68pub(crate) enum AppOutput {
69 Terminal,
70 Captured {
71 log: Arc<LogBuffer>,
72 address: std::net::SocketAddr,
73 },
74}
75
76impl AppOutput {
77 fn spawn(&self, command: &mut Command) -> std::io::Result<Child> {
78 match self {
79 Self::Terminal => command.spawn(),
80 Self::Captured { log, address } => {
81 let mut child = command
82 .env("SOFIE_ADDRESS", address.to_string())
83 .stdout(Stdio::piped())
84 .stderr(Stdio::piped())
85 .spawn()?;
86 log.follow(child.stdout.take(), child.stderr.take());
87 Ok(child)
88 }
89 }
90 }
91}
92
93pub(crate) struct DevApp {
97 root: PathBuf,
98 at: Option<String>,
99 output: AppOutput,
100 variables: Vec<(&'static str, &'static str)>,
101 process: Option<ChildProcess>,
102 directory: Option<PathBuf>,
104 generation: u64,
105 built: Snapshot,
106 build_failed: bool,
107 last_exit: Option<ExitStatus>,
108}
109
110impl DevApp {
111 pub(crate) fn new(root: &Path, at: Option<&str>, output: AppOutput) -> Self {
112 Self {
113 root: root.to_path_buf(),
114 at: at.map(str::to_owned),
115 output,
116 variables: Vec::new(),
117 process: None,
118 directory: None,
119 generation: 0,
120 built: Snapshot::new(),
121 build_failed: false,
122 last_exit: None,
123 }
124 }
125
126 pub(crate) fn set_variable(&mut self, key: &'static str, value: &'static str) {
127 self.variables.push((key, value));
128 }
129
130 #[cfg(feature = "mcp")]
131 pub(crate) fn pid(&self) -> Option<u32> {
132 self.process.as_ref().map(ChildProcess::pid)
133 }
134
135 #[cfg(feature = "mcp")]
136 pub(crate) fn build_failed(&self) -> bool {
137 self.build_failed
138 }
139
140 #[cfg(feature = "mcp")]
141 pub(crate) fn last_exit(&self) -> Option<ExitStatus> {
142 self.last_exit
143 }
144
145 pub(crate) fn rebuild(&mut self) -> Result<u32, GenError> {
147 let generated = self.generate();
148 self.built = snapshot(&self.root)?;
151 let built = generated.and_then(|app| Ok((app, self.compile()?)));
152 self.build_failed = built.is_err();
153 let (app, executable) = built?;
154 self.restart(&app, &executable)
155 }
156
157 pub(crate) fn refresh(&mut self) -> Result<Option<u32>, GenError> {
159 let current = snapshot(&self.root)?;
160 let signalled = match classify(&self.built, ¤t) {
161 None => return Ok(None),
162 Some(_) if self.build_failed => return self.rebuild().map(Some),
164 Some(Change::Templates) => self.rebuild_clients(),
165 Some(Change::Islands) if self.process.is_some() => self.rebuild_islands(),
166 Some(Change::Islands | Change::Code) => return self.rebuild().map(Some),
167 };
168 self.built = current;
169 signalled.map(|()| None)
170 }
171
172 pub(crate) fn exited(&mut self) -> Result<Option<ExitStatus>, GenError> {
173 let Some(process) = self.process.as_mut() else {
174 return Ok(None);
175 };
176 let exited = process.exited().map_err(|source| {
177 GenError::App(format!(
178 "watching the app (pid {}) failed: {source}",
179 process.pid()
180 ))
181 })?;
182 let Some(status) = exited else {
183 return Ok(None);
184 };
185 self.process = None;
186 self.last_exit = Some(status);
187 Ok(Some(status))
188 }
189
190 pub(crate) fn stop(&mut self) {
191 if let Some(status) = self.process.take().and_then(ChildProcess::kill) {
192 self.last_exit = Some(status);
193 }
194 }
195
196 fn generate(&self) -> Result<App, GenError> {
197 match generated_app(&self.root, GenMode::Write) {
198 Err(GenError::Check(findings)) => Err(GenError::App(report(&self.root, &findings))),
199 result => result.map(|(app, _)| app),
200 }
201 }
202
203 fn diagnostics(&self) -> Diagnostics {
204 match self.output {
205 AppOutput::Terminal => Diagnostics::Terminal,
206 #[cfg(feature = "mcp")]
207 AppOutput::Captured { .. } => Diagnostics::Captured,
208 #[cfg(not(feature = "mcp"))]
209 AppOutput::Captured { .. } => Diagnostics::Terminal,
210 }
211 }
212
213 fn compile(&self) -> Result<PathBuf, GenError> {
214 cargo::build(&self.root, DEVELOPMENT, self.diagnostics())
215 }
216
217 fn restart(&mut self, app: &App, executable: &Path) -> Result<u32, GenError> {
218 let directory = executable.parent().unwrap_or(&self.root).join("sofie-dev");
219 build_islands(
220 app,
221 &directory.join("islands"),
222 IslandMode::Development,
223 self.diagnostics(),
224 )?;
225 build_development_clients(app, &directory, self.diagnostics())?;
226 self.stop();
227 let mut command = app_command(&self.root, executable)?;
228 command
229 .arg("serve")
230 .args(self.at.iter().flat_map(|at| ["--at", at.as_str()]))
231 .env("SOFIE_MIGRATE", "on")
232 .env("SOFIE_DEV_DIR", &directory)
233 .envs(self.variables.iter().copied());
234 let process = ChildProcess::spawn(ProcessGroup::Inherited, &mut command, |command| {
235 self.output.spawn(command)
236 })
237 .map_err(|source| io_error(executable, source))?;
238 let pid = process.pid();
239 self.process = Some(process);
240 self.directory = Some(directory);
241 Ok(pid)
242 }
243
244 fn rebuild_islands(&mut self) -> Result<(), GenError> {
245 let Some(directory) = self.running_directory() else {
246 return Ok(());
247 };
248 let app = sofie_model::load(&self.root).map_err(GenError::Model)?;
249 build_islands(
250 &app,
251 &directory.join("islands"),
252 IslandMode::Development,
253 self.diagnostics(),
254 )?;
255 self.signal("islands")
256 }
257
258 fn rebuild_clients(&mut self) -> Result<(), GenError> {
261 if let Some(directory) = self.running_directory() {
262 let app = sofie_model::load(&self.root).map_err(GenError::Model)?;
263 build_development_clients(&app, directory, self.diagnostics())?;
264 }
265 self.signal("page")
266 }
267
268 fn signal(&mut self, kind: &str) -> Result<(), GenError> {
269 let Some(file) = self
270 .running_directory()
271 .map(|directory| directory.join("generation"))
272 else {
273 return Ok(());
274 };
275 self.generation += 1;
276 fs::write(&file, format!("{} {kind}\n", self.generation))
277 .map_err(|source| io_error(&file, source))
278 }
279
280 fn running_directory(&self) -> Option<&Path> {
281 self.process.as_ref().and(self.directory.as_deref())
282 }
283}
284
285fn build_development_clients(
287 app: &App,
288 directory: &Path,
289 diagnostics: Diagnostics,
290) -> Result<(), GenError> {
291 let screens = sofie_check::Screens::read(&app.modules);
292 build_clients(
293 app,
294 &screens,
295 &directory.join("clients"),
296 IslandMode::Development,
297 diagnostics,
298 )
299 .map(drop)
300}
301
302pub(crate) fn run_development(root: &Path, arguments: &[&str]) -> Result<ExitStatus, GenError> {
304 let executable = cargo::build(root, DEVELOPMENT, Diagnostics::Terminal)?;
305 app_command(root, &executable)?
306 .args(arguments)
307 .status()
308 .map_err(|source| io_error(&executable, source))
309}
310
311pub(crate) fn app_command(root: &Path, executable: &Path) -> Result<Command, GenError> {
313 let mut command = Command::new(executable);
314 command
315 .envs(dotenv(root)?)
316 .current_dir(root)
317 .stdin(Stdio::null());
318 Ok(command)
319}
320
321pub(crate) fn dotenv(root: &Path) -> Result<Vec<(String, String)>, GenError> {
323 let file = root.join(".env");
324 let text = match fs::read_to_string(&file) {
325 Ok(text) => text,
326 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
327 Err(source) => return Err(io_error(&file, source)),
328 };
329 Ok(env_entries(&text)
330 .filter(|(_, key, _)| std::env::var_os(key).is_none())
331 .map(|(_, key, value)| (key.to_owned(), value.to_owned()))
332 .collect())
333}
334
335pub(crate) fn env_entries(text: &str) -> impl Iterator<Item = (usize, &str, &str)> {
337 text.lines().enumerate().filter_map(|(index, line)| {
338 let line = line.trim();
339 let (key, value) = line
340 .strip_prefix("export ")
341 .unwrap_or(line)
342 .split_once('=')
343 .filter(|_| !line.starts_with('#'))?;
344 Some((index + 1, key.trim(), value_of(value.trim())))
345 })
346}
347
348fn value_of(written: &str) -> &str {
350 if let Some(quote) = written
351 .chars()
352 .next()
353 .filter(|first| matches!(first, '"' | '\''))
354 {
355 return written[1..]
356 .find(quote)
357 .map_or(written, |end| &written[1..=end]);
358 }
359 written
360 .char_indices()
361 .find(|&(at, character)| {
362 character.is_whitespace() && written[at..].trim_start().starts_with('#')
363 })
364 .map_or(written, |(at, _)| &written[..at])
365}
366
367fn snapshot(root: &Path) -> Result<Snapshot, GenError> {
368 let mut walk = Walk::default();
369 for watched in ["modules", "app", "web", ".env"] {
370 walk.record(&root.join(watched))?;
371 }
372 Ok(walk.files)
373}
374
375#[derive(Default)]
376struct Walk {
377 files: Snapshot,
378 folders: HashSet<PathBuf>,
380}
381
382impl Walk {
383 fn record(&mut self, path: &Path) -> Result<(), GenError> {
384 let Ok(metadata) = fs::metadata(path) else {
385 return Ok(());
386 };
387 if metadata.is_file() {
388 let modified = metadata
389 .modified()
390 .map_err(|source| io_error(path, source))?;
391 self.files.insert(path.to_path_buf(), modified);
392 return Ok(());
393 }
394 let skipped = path
395 .file_name()
396 .and_then(|name| name.to_str())
397 .is_some_and(|name| NOT_SOURCES.contains(&name));
398 let canonical = fs::canonicalize(path).map_err(|source| io_error(path, source))?;
399 if skipped || !self.folders.insert(canonical) {
400 return Ok(());
401 }
402 for entry in fs::read_dir(path).map_err(|source| io_error(path, source))? {
403 let entry = entry.map_err(|source| io_error(path, source))?;
404 self.record(&entry.path())?;
405 }
406 Ok(())
407 }
408}
409
410fn classify(before: &Snapshot, after: &Snapshot) -> Option<Change> {
411 if before == after {
412 return None;
413 }
414 if before.len() != after.len() || before.keys().ne(after.keys()) {
415 return Some(Change::Code);
416 }
417 let edited: Vec<&PathBuf> = after
418 .iter()
419 .filter(|(path, modified)| before.get(*path) != Some(modified))
420 .map(|(path, _)| path)
421 .collect();
422 let has_extension = |extension: &str| {
423 edited
424 .iter()
425 .all(|path| path.extension().is_some_and(|found| found == extension))
426 };
427 Some(if has_extension("html") {
428 Change::Templates
429 } else if has_extension("svelte") {
430 Change::Islands
431 } else {
432 Change::Code
433 })
434}
435
436#[cfg(test)]
437mod tests {
438 use sofie_model::testing::TemporaryDirectory;
439
440 use super::*;
441
442 fn at(files: &[(&str, u64)]) -> Snapshot {
443 files
444 .iter()
445 .map(|(path, seconds)| {
446 (
447 PathBuf::from(path),
448 SystemTime::UNIX_EPOCH + Duration::from_secs(*seconds),
449 )
450 })
451 .collect()
452 }
453
454 #[test]
455 fn an_edit_is_told_apart_by_what_it_touched() {
456 let before = at(&[("a.html", 1), ("b.svelte", 1), ("c.rs", 1)]);
457
458 assert_eq!(classify(&before, &before), None);
459 assert_eq!(
460 classify(&before, &at(&[("a.html", 2), ("b.svelte", 1), ("c.rs", 1)])),
461 Some(Change::Templates)
462 );
463 assert_eq!(
464 classify(&before, &at(&[("a.html", 1), ("b.svelte", 2), ("c.rs", 1)])),
465 Some(Change::Islands)
466 );
467 assert_eq!(
468 classify(&before, &at(&[("a.html", 2), ("b.svelte", 2), ("c.rs", 1)])),
469 Some(Change::Code)
470 );
471 assert_eq!(
472 classify(
473 &before,
474 &at(&[("a.html", 1), ("b.svelte", 1), ("d.html", 1)])
475 ),
476 Some(Change::Code),
477 "a new template needs the wiring"
478 );
479 let mail = at(&[("modules/order/src/mails/receipt.html", 1)]);
480 assert_eq!(
481 classify(&mail, &at(&[("modules/order/src/mails/receipt.html", 2)])),
482 Some(Change::Templates),
483 "a development build rereads a mail template as a screen's"
484 );
485 }
486
487 #[test]
488 fn development_refuses_a_database_on_another_host() {
489 let urls = |url: &str| BTreeMap::from([("DATABASE_URL".to_owned(), url.to_owned())]);
490
491 let refused = super::refuse_remote_development(&urls("postgres://db.example.com/shop"));
492
493 assert!(
494 refused
495 .unwrap_err()
496 .to_string()
497 .contains("--remote-database")
498 );
499 assert!(super::refuse_remote_development(&urls("postgres://127.0.0.1/shop")).is_ok());
500 assert!(super::refuse_remote_development(&BTreeMap::new()).is_ok());
501 }
502
503 #[test]
504 fn the_env_file_fills_what_the_environment_does_not_set() {
505 let directory = TemporaryDirectory::new("sofie-dev-env");
506 directory.write(
507 ".env",
508 "# keys\nSOFIE_DEV_TEST_KEY=\"a=b\" # quoted\n\nPATH=/nowhere\n\
509 export SOFIE_DEV_TEST_EXPORTED='x y'\nSOFIE_DEV_TEST_NOTED=v#1 and 2 # a note\n",
510 );
511
512 let keys = dotenv(directory.root()).unwrap();
513
514 assert_eq!(
515 keys,
516 [
517 ("SOFIE_DEV_TEST_KEY".to_owned(), "a=b".to_owned()),
518 ("SOFIE_DEV_TEST_EXPORTED".to_owned(), "x y".to_owned()),
519 ("SOFIE_DEV_TEST_NOTED".to_owned(), "v#1 and 2".to_owned()),
520 ],
521 "read as `sofie config check` reads it"
522 );
523 assert_eq!(dotenv(&directory.path("missing")).unwrap(), []);
524 }
525
526 #[test]
527 fn an_app_command_runs_in_the_app_root_with_its_env_file() {
528 let directory = TemporaryDirectory::new("sofie-app-command");
529 directory.write(".env", "SOFIE_APP_COMMAND_TEST_KEY=local\n");
530
531 let command = app_command(directory.root(), Path::new("app")).unwrap();
532
533 assert_eq!(command.get_current_dir(), Some(directory.root()));
534 let variables: Vec<_> = command.get_envs().collect();
535 assert_eq!(
536 variables,
537 [(
538 std::ffi::OsStr::new("SOFIE_APP_COMMAND_TEST_KEY"),
539 Some(std::ffi::OsStr::new("local"))
540 )]
541 );
542 }
543
544 #[cfg(unix)]
545 #[test]
546 fn a_symlink_back_to_a_watched_folder_is_walked_once() {
547 let directory = TemporaryDirectory::new("sofie-dev-loop");
548 directory.write("modules/order/src/lib.rs", "");
549 std::os::unix::fs::symlink(
550 directory.path("modules"),
551 directory.path("modules/order/src/back"),
552 )
553 .unwrap();
554
555 let files = snapshot(directory.root()).unwrap();
556
557 assert_eq!(
558 files.keys().collect::<Vec<_>>(),
559 [&directory.path("modules/order/src/lib.rs")]
560 );
561 }
562
563 #[cfg(feature = "slow-tests")]
565 #[test]
566 fn a_template_edit_is_compiled_into_the_development_client_bundle() {
567 use sofie_model::testing::{app, area, define_area, module};
568
569 let directory = TemporaryDirectory::new("sofie-dev-clients");
570 let template = directory.write(
571 "modules/notes/src/areas/board/screens/notes.html",
572 "<p>First words</p>",
573 );
574 let mut notes = module("notes", directory.path("modules/notes"));
575 define_area(&mut notes, "board");
576 notes.defined_areas[0].client = sofie_model::AreaClient::Svelte;
577 let mut board = area(¬es, "board", Vec::new());
578 board.screens = vec![template.clone()];
579 notes.areas.push(board);
580 let demo = app(directory.root(), vec![notes]);
581 let bundle = directory.path("dev/clients/board.js");
582
583 build_development_clients(&demo, &directory.path("dev"), Diagnostics::Terminal).unwrap();
584 assert!(fs::read_to_string(&bundle).unwrap().contains("First words"));
585 fs::write(&template, "<p>Second words</p>").unwrap();
586 build_development_clients(&demo, &directory.path("dev"), Diagnostics::Terminal).unwrap();
587
588 assert!(
589 fs::read_to_string(&bundle)
590 .unwrap()
591 .contains("Second words")
592 );
593 }
594
595 #[cfg(unix)]
596 #[test]
597 fn a_failed_watch_names_the_app_and_its_pid() {
598 let untracked = ChildProcess::untracked();
599 let pid = untracked.pid();
600 let mut app = DevApp::new(Path::new("/app"), None, AppOutput::Terminal);
601 app.process = Some(untracked);
602
603 let error = app.exited().unwrap_err().to_string();
604
605 assert_eq!(
606 error,
607 format!(
608 "watching the app (pid {pid}) failed: child process {pid} is no longer tracked"
609 )
610 );
611 }
612}