Skip to main content

sofie_cli/
dev_command.rs

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
32/// Development builds migrate on start, so a database on another host takes
33/// `remote_database`, never what the shell or `.env` happens to hold.
34pub 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
93/// The development app `sofie dev` and `sofie mcp` run: built with
94/// `sofie/development`, restarted on a code change, signalled on a template or
95/// island change, its client bundles rebuilt on a template change.
96pub(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    /// The running app's `SOFIE_DEV_DIR`.
103    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    /// A failed build keeps the running app.
146    pub(crate) fn rebuild(&mut self) -> Result<u32, GenError> {
147        let generated = self.generate();
148        // Taken before cargo runs: what gen wrote is not a change of its own,
149        // but a file saved during the build is.
150        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    /// The pid when it restarted.
158    pub(crate) fn refresh(&mut self) -> Result<Option<u32>, GenError> {
159        let current = snapshot(&self.root)?;
160        let signalled = match classify(&self.built, &current) {
161            None => return Ok(None),
162            // After a failed build any edit, a template too, may be the fix.
163            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    /// A client area's screens are compiled into its bundle, so a page reload alone would show
259    /// the old ones.
260    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
285/// Served instead of the embedded bundles while the app runs under `sofie dev`.
286fn 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
302/// The development binary run once, as `sofie dev` runs it.
303pub(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
311/// The app's binary in the app root with its `.env`, as every command runs it.
312pub(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
321/// The app root's `.env`, without the keys the environment sets.
322pub(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
335/// An env file's line numbers, keys and unquoted values.
336pub(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
348/// A quoted value ends at its closing quote; an unquoted one at a ` #` comment.
349fn 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    /// Canonical folders walked, so a symlink back to one is not walked again.
379    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    /// Needs Node and `npm ci` in the web toolchain.
564    #[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(&notes, "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}