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