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