Skip to main content

table_editor/
server.rs

1//! The server a repository's binary builds and runs.
2
3use std::net::{Ipv4Addr, SocketAddr};
4use std::thread;
5use std::time::Duration;
6
7use anyhow::{Result, anyhow};
8use clap::{Args, Subcommand};
9use tiny_http::Server as HttpServer;
10
11use crate::context::Context;
12use crate::launch::{self, Occupant};
13use crate::routes;
14use crate::table::{App, Front};
15
16/// The bundle served when the repository does not supply its own.
17///
18/// The build script puts it here, from `assets/index.html` where that has been
19/// built and from `assets/placeholder.html` where it has not, so that the crate
20/// compiles in a checkout that has never run bun.
21const DEFAULT_INDEX_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
22
23/// Which of the two the build script found: `built` or `placeholder`. Only the
24/// crate's own test reads it; what a consumer is served is the page itself, and
25/// the placeholder says what it is.
26#[cfg(test)]
27const BUNDLE_KIND: &str = env!("TABLE_EDITOR_BUNDLE");
28
29/// The marker set on the detached worker process so it serves rather than
30/// re-spawning itself.
31const DEFAULT_CHILD_ENV: &str = "TABLE_EDITOR_CHILD";
32
33/// The subcommand that reaches [`Server::run`], used to re-invoke the binary as
34/// a detached worker.
35const DEFAULT_COMMAND: &str = "web";
36
37/// The port bound when neither the repository nor the command line names one.
38const DEFAULT_PORT: u16 = 8787;
39
40/// The arguments the editor's subcommand takes. A repository whose subcommand
41/// takes arguments of its own flattens this into its own `Args` struct.
42#[derive(Debug, Args)]
43#[command(args_conflicts_with_subcommands = true)]
44pub struct ServerArgs {
45    /// Subcommand. Omit to launch (or reuse) the server, the default.
46    #[command(subcommand)]
47    pub command: Option<ServerCommand>,
48
49    /// Table or view to open by name. Defaults to the app's front page.
50    pub table: Option<String>,
51
52    /// Port to bind on 127.0.0.1. Defaults to the app's own port, so two
53    /// editors on one machine do not collide.
54    #[arg(long, global = true)]
55    pub port: Option<u16>,
56
57    /// Do not open a browser; just run the server.
58    #[arg(long)]
59    pub no_open: bool,
60
61    /// Shut down any server already running on the port and start a fresh one
62    /// (e.g. to pick up a newly built binary). Without this, an existing server
63    /// for this app is reused.
64    #[arg(long)]
65    pub restart: bool,
66
67    /// Development mode: serve only `/api` in the foreground (no embedded UI,
68    /// no browser). Vite serves the UI and proxies `/api` here.
69    #[arg(long)]
70    pub api_only: bool,
71}
72
73impl ServerArgs {
74    /// Put the consuming app's own defaults into the help for `table` and
75    /// `--port`.
76    ///
77    /// The two arguments default to something only the [`Server`] knows: the
78    /// app's front page and the port it was built with. Help, though, is
79    /// rendered by clap before `run` is ever reached, so the text has to be
80    /// rewritten on the way in. Build the command, hand it here, and parse
81    /// from what comes back:
82    ///
83    /// ```no_run
84    /// # use clap::{CommandFactory, FromArgMatches, Parser, Subcommand};
85    /// # use table_editor::ServerArgs;
86    /// # #[derive(Parser)]
87    /// # struct Cli {
88    /// #     #[command(subcommand)]
89    /// #     command: Command,
90    /// # }
91    /// # #[derive(Subcommand)]
92    /// # enum Command {
93    /// #     Web(ServerArgs),
94    /// # }
95    /// let command = ServerArgs::augment_help(Cli::command(), "books", 8788);
96    /// let cli = Cli::from_arg_matches(&command.get_matches())?;
97    /// # Ok::<(), clap::Error>(())
98    /// ```
99    ///
100    /// Where the arguments sit does not matter: the whole command tree is
101    /// walked. A command is rewritten only where it holds both `table` and
102    /// `port` and both still carry this crate's own help, which is what
103    /// flattening [`ServerArgs`] leaves behind. A repository's own `--port`
104    /// on some other subcommand keeps its own wording, and so does one whose
105    /// help the repository has already rewritten. Nothing but the help
106    /// changes.
107    pub fn augment_help(
108        command: clap::Command,
109        default_page: &str,
110        default_port: u16,
111    ) -> clap::Command {
112        let table = format!("Table or view to open by name. Defaults to {default_page}.");
113        let port = format!("Port to bind on 127.0.0.1. Defaults to {default_port}.");
114        rewrite_help(command, &table, &port)
115    }
116}
117
118/// The help clap derives for this crate's own `table` and `port`, which is how
119/// an argument flattened from [`ServerArgs`] is told from a repository's own.
120fn crate_help() -> (String, String) {
121    let reference = ServerArgs::augment_args(clap::Command::new("table-editor"));
122    let of = |id: &str| {
123        reference
124            .get_arguments()
125            .find(|arg| arg.get_id() == id)
126            .and_then(|arg| arg.get_help())
127            .map(ToString::to_string)
128            .unwrap_or_default()
129    };
130    (of("table"), of("port"))
131}
132
133/// Rewrite the help of `table` and `--port` on every command in the tree that
134/// holds both of them with this crate's own wording.
135fn rewrite_help(command: clap::Command, table: &str, port: &str) -> clap::Command {
136    let (crate_table, crate_port) = crate_help();
137
138    let subcommands: Vec<String> = command
139        .get_subcommands()
140        .map(|sub| sub.get_name().to_string())
141        .collect();
142
143    let help_of = |command: &clap::Command, id: &str| -> Option<String> {
144        command
145            .get_arguments()
146            .find(|arg| arg.get_id() == id)
147            .and_then(|arg| arg.get_help())
148            .map(ToString::to_string)
149    };
150
151    let mut command = command;
152    let ours = help_of(&command, "table").as_deref() == Some(crate_table.as_str())
153        && help_of(&command, "port").as_deref() == Some(crate_port.as_str());
154    if ours {
155        let (table, port) = (table.to_string(), port.to_string());
156        command = command
157            .mut_arg("table", |arg| arg.help(table))
158            .mut_arg("port", |arg| arg.help(port));
159    }
160
161    for name in subcommands {
162        command = command.mut_subcommand(name, |sub| rewrite_help(sub, table, port));
163    }
164    command
165}
166
167/// The first character of `name` that may not appear in a path segment, or
168/// nothing where every character may.
169///
170/// A name is compared against a segment of the address, so it has to survive
171/// the journey there and back unchanged. The unreserved set from RFC 3986 is
172/// what does: anything else either means something to a URL or arrives
173/// escaped and no longer matches what the app called it.
174fn reserved_url_character(name: &str) -> Option<char> {
175    name.chars()
176        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')))
177}
178
179/// The parameter keys a view may not use, because the address already means
180/// something by them.
181const RESERVED_PARAM_KEYS: [&str; 2] = ["view", "table"];
182
183/// Whether a name is one file inside `Data/` rather than a path out of it.
184fn is_bare_file_name(file: &str) -> bool {
185    !file.is_empty()
186        && file != "."
187        && file != ".."
188        && !file.contains(['/', '\\'])
189        && !std::path::Path::new(file).is_absolute()
190}
191
192#[derive(Debug, Subcommand)]
193pub enum ServerCommand {
194    /// Stop a server left running on the port (e.g. a stale detached one).
195    /// Idempotent: a no-op when nothing is listening.
196    Stop,
197}
198
199/// The editor's HTTP server, configured for one repository.
200pub struct Server {
201    app: Box<dyn App>,
202    index_html: &'static str,
203    child_env: &'static str,
204    command: &'static str,
205    default_port: u16,
206    worker_args: Vec<String>,
207    before_launch: Option<Box<dyn Fn() + Send>>,
208}
209
210impl Server {
211    /// Build a server for an app.
212    ///
213    /// Panics when a table takes one of the reserved names, because such a
214    /// table is unreachable: the control endpoints and the `stop` subcommand
215    /// are matched first. Panics, too, when a table's file is not a bare name,
216    /// since every file is resolved against the one `Data/` directory.
217    pub fn new(app: impl App) -> Self {
218        let app: Box<dyn App> = Box::new(app);
219        for table in app.tables() {
220            assert!(
221                !routes::RESERVED_NAMES.contains(&table.route()),
222                "table \"{}\" uses a reserved name; the editor reserves {}",
223                table.route(),
224                routes::RESERVED_NAMES.join(", ")
225            );
226            assert!(
227                is_bare_file_name(table.data_file()),
228                "table \"{}\" names the file \"{}\"; a table's file is a bare name inside Data/",
229                table.route(),
230                table.data_file()
231            );
232        }
233
234        for table in app.tables() {
235            if let Some(bad) = reserved_url_character(table.route()) {
236                panic!(
237                    "table \"{}\" has {bad:?} in its name; a name is part of an address, so it \
238                     takes letters, digits, and - _ . ~ only",
239                    table.route()
240                );
241            }
242        }
243
244        let tables: Vec<&'static str> = app.tables().iter().map(|t| t.route()).collect();
245        let mut seen: Vec<&'static str> = Vec::new();
246        for view in app.views() {
247            let name = view.route();
248            assert!(
249                !routes::RESERVED_NAMES.contains(&name),
250                "view \"{name}\" uses a reserved name; the editor reserves {}",
251                routes::RESERVED_NAMES.join(", ")
252            );
253            if let Some(bad) = reserved_url_character(name) {
254                panic!(
255                    "view \"{name}\" has {bad:?} in its name; a name is part of an address, so \
256                     it takes letters, digits, and - _ . ~ only"
257                );
258            }
259            // A view and a table are told apart by the address that asks for
260            // them, so two of one name would make `?view=` and `?table=` name
261            // different things under one word.
262            assert!(
263                !tables.contains(&name),
264                "view \"{name}\" has the same name as a table; each name belongs to one of them"
265            );
266            assert!(
267                !seen.contains(&name),
268                "two views are named \"{name}\"; each view takes a name of its own"
269            );
270            seen.push(name);
271
272            // A parameter keyed `view` or `table` would be asking the address
273            // a question it already answers. The keys a view declares do not
274            // depend on the data behind them, so a context rooted anywhere
275            // serves to ask what they are; a view that cannot answer without
276            // its files goes unchecked rather than refusing to build.
277            let ctx = Context::new(".");
278            for key in view.param_keys(&ctx).unwrap_or_default() {
279                assert!(
280                    !RESERVED_PARAM_KEYS.contains(&key.as_str()),
281                    "view \"{name}\" has a parameter keyed \"{key}\"; the address uses that word                      to say which page it is on"
282                );
283            }
284        }
285
286        match app.front() {
287            Front::FirstTable => {}
288            Front::Table(name) => assert!(
289                tables.contains(&name),
290                "the front page names the table \"{name}\", which this app does not serve"
291            ),
292            Front::View(name) => assert!(
293                seen.contains(&name),
294                "the front page names the view \"{name}\", which this app does not serve"
295            ),
296        }
297
298        Self {
299            app,
300            index_html: DEFAULT_INDEX_HTML,
301            child_env: DEFAULT_CHILD_ENV,
302            command: DEFAULT_COMMAND,
303            default_port: DEFAULT_PORT,
304            worker_args: Vec::new(),
305            before_launch: None,
306        }
307    }
308
309    /// Serve a bundle of the repository's own in place of the embedded one.
310    pub fn index_html(mut self, html: &'static str) -> Self {
311        self.index_html = html;
312        self
313    }
314
315    /// The environment variable marking the detached worker process. A
316    /// repository whose server is registered as a system service keeps its own
317    /// name here, so the service entry does not have to change.
318    pub fn child_env(mut self, var: &'static str) -> Self {
319        self.child_env = var;
320        self
321    }
322
323    /// The subcommand that reaches [`Server::run`], used when re-invoking the
324    /// binary as a detached worker.
325    pub fn command(mut self, command: &'static str) -> Self {
326        self.command = command;
327        self
328    }
329
330    /// The port to bind when the command line names none. Each app on a
331    /// machine takes its own, so one editor never lands on another's port.
332    pub fn default_port(mut self, port: u16) -> Self {
333        self.default_port = port;
334        self
335    }
336
337    /// Arguments to pass on to the detached worker, after the table and the
338    /// port.
339    ///
340    /// The worker is a fresh invocation of this binary, and it is given only
341    /// the table and the port, so a flag the user passed the parent does not
342    /// reach it. A repository whose subcommand takes a flag the serving
343    /// process needs—one naming a companion service, say—forwards it here.
344    /// The worker inherits the environment either way, so a setting that
345    /// already lives in a variable needs no forwarding.
346    ///
347    /// What is forwarded lands on the worker's command line, so each argument
348    /// has to be one the editor's subcommand declares. It must be a flag and
349    /// not a positional, because the table is the only positional that command
350    /// line has—a forwarded positional is refused outright—and it must not
351    /// repeat `--port` or the table, which are passed already. An argument
352    /// that breaks these rules leaves the worker unable to parse its own
353    /// command line, and the launch then fails with what the worker said.
354    pub fn worker_args(mut self, args: impl IntoIterator<Item = String>) -> Self {
355        self.worker_args = args.into_iter().collect();
356        self
357    }
358
359    /// Work to do once, in the process the user invoked, before a server is
360    /// started or reused: bringing up a companion service, say. It does not run
361    /// in the detached worker.
362    pub fn before_launch(mut self, f: impl Fn() + Send + 'static) -> Self {
363        self.before_launch = Some(Box::new(f));
364        self
365    }
366
367    pub fn run(self, args: ServerArgs) -> Result<()> {
368        let port = self.port(&args);
369        let app = self.app.name();
370
371        if let Some(ServerCommand::Stop) = args.command {
372            return launch::stop(port, app);
373        }
374
375        // The detached worker carries the marker; it binds and serves. Handle
376        // it before `before_launch` so only the user-invoked parent runs that.
377        if std::env::var_os(self.child_env).is_some() {
378            return self.serve(port, args.api_only);
379        }
380
381        if let Some(f) = &self.before_launch {
382            f();
383        }
384
385        // Dev mode serves the API in the foreground so logs and Ctrl-C work;
386        // Vite owns the UI and proxies `/api` here.
387        if args.api_only {
388            return self.serve(port, args.api_only);
389        }
390
391        // What a repository forwards to the worker is settled here, before a
392        // single packet goes anywhere: an argument that could never work is
393        // that, whatever else happens to be on the port.
394        launch::check_worker_args(&self.worker_args)?;
395
396        let url = self.url(&args, port);
397        let occupant = launch::occupant(port);
398
399        if args.restart && launch::replaceable(&occupant, app) {
400            // A server that names no app is replaced but never adopted, so an
401            // upgrade can take its port back.
402            if occupant == Occupant::Unnamed {
403                println!("{app}: replacing a server on port {port} that does not name its app");
404            }
405            launch::request_shutdown(port);
406            launch::wait_until_down(port)?;
407        } else if launch::serves(&occupant, app) {
408            self.open_if_wanted(&args, &url);
409            println!("{app}: re-using server at {url}");
410            return Ok(());
411        } else if occupant != Occupant::Vacant {
412            let doing = if args.restart {
413                "not replacing it"
414            } else {
415                "not starting a second one"
416            };
417            return Err(launch::occupied(port, &occupant, app, doing));
418        }
419
420        // Launch a detached copy of ourselves and wait until it is serving, so
421        // the parent can return (this supports binding the command to a
422        // double-click shortcut) and the browser never races an unbound port.
423        let mut worker = launch::spawn_detached(
424            self.command,
425            self.child_env,
426            args.table.as_deref(),
427            port,
428            &self.worker_args,
429        )?;
430        launch::wait_until_up(port, app, &mut worker)?;
431        self.open_if_wanted(&args, &url);
432        println!("{app}: serving at {url}");
433        Ok(())
434    }
435
436    /// The port to use: the one named on the command line, or the app's own.
437    fn port(&self, args: &ServerArgs) -> u16 {
438        args.port.unwrap_or(self.default_port)
439    }
440
441    /// Bind the port and serve until the process is shut down (by a signal or
442    /// by `POST /api/shutdown`).
443    fn serve(&self, port: u16, api_only: bool) -> Result<()> {
444        let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
445        let server = bind_with_retry(addr)?;
446
447        for request in server.incoming_requests() {
448            if let Err(e) = routes::handle(request, self.app.as_ref(), self.index_html, api_only) {
449                eprintln!("request error: {e}");
450            }
451        }
452        Ok(())
453    }
454
455    /// Where to point the browser.
456    ///
457    /// A name on the command line opens that page, whether the app serves it
458    /// as a table or as a view. With no name, an app that declares a front
459    /// page is opened bare, so that page decides; an app that declares none is
460    /// opened on its first table by name, because a repository serving a
461    /// bundle of its own may read `?table=` and know nothing of front pages.
462    fn url(&self, args: &ServerArgs, port: u16) -> String {
463        let base = format!("http://127.0.0.1:{port}/");
464        match args.table.as_deref() {
465            Some(name) if self.app.view(name).is_some() => format!("{base}?view={name}"),
466            Some(name) => format!("{base}?table={name}"),
467            None => match self.app.front() {
468                Front::FirstTable => match self.app.tables().first() {
469                    Some(table) => format!("{base}?table={}", table.route()),
470                    None => base,
471                },
472                _ => base,
473            },
474        }
475    }
476
477    fn open_if_wanted(&self, args: &ServerArgs, url: &str) {
478        if args.no_open {
479            return;
480        }
481        if let Err(e) = launch::open_browser(url) {
482            eprintln!("could not open browser: {e}");
483        }
484    }
485}
486
487/// Bind, retrying briefly: after a `--restart` the previous server's socket can
488/// linger for a moment before the OS frees the port.
489fn bind_with_retry(addr: SocketAddr) -> Result<HttpServer> {
490    let mut last_err = None;
491    for _ in 0..20 {
492        match HttpServer::http(addr) {
493            Ok(server) => return Ok(server),
494            Err(e) => {
495                last_err = Some(e.to_string());
496                thread::sleep(Duration::from_millis(100));
497            }
498        }
499    }
500    Err(anyhow!(
501        "could not bind {}: {}",
502        addr,
503        last_err.unwrap_or_else(|| "unknown error".to_string())
504    ))
505}
506
507#[cfg(test)]
508mod tests {
509    use std::sync::Arc;
510    use std::sync::atomic::{AtomicUsize, Ordering};
511
512    use clap::{CommandFactory, Parser};
513
514    use super::*;
515    use crate::context::Context;
516    use crate::error::ApiError;
517    use crate::fixture::{Clashing, Library, Straying};
518    use crate::table::{App, Table};
519    use crate::view::{Param, View, ViewArgs, ViewData, ViewLogic};
520
521    /// A binary that takes the editor's arguments unchanged.
522    #[derive(Parser)]
523    #[command(name = "library")]
524    struct Cli {
525        #[command(subcommand)]
526        command: Command,
527    }
528
529    #[derive(Subcommand)]
530    enum Command {
531        Web(ServerArgs),
532    }
533
534    /// A binary that adds arguments of its own, the way a repository with a
535    /// companion service does.
536    #[derive(Parser)]
537    #[command(name = "archive")]
538    struct HostCli {
539        #[command(subcommand)]
540        command: HostCommand,
541    }
542
543    #[derive(Subcommand)]
544    enum HostCommand {
545        Web(WebArgs),
546        /// A companion service of the repository's own, with a port of its own.
547        ServeSpeech(SpeechArgs),
548    }
549
550    #[derive(Args)]
551    struct WebArgs {
552        #[command(flatten)]
553        server: ServerArgs,
554
555        #[arg(long)]
556        no_service: bool,
557    }
558
559    #[derive(Args)]
560    struct SpeechArgs {
561        /// Port the speech service listens on. Defaults to 8765.
562        #[arg(long)]
563        port: Option<u16>,
564    }
565
566    fn parse(argv: &[&str]) -> ServerArgs {
567        match Cli::parse_from(argv).command {
568            Command::Web(args) => args,
569        }
570    }
571
572    /// An app of one table and whatever views a test hands it.
573    struct WithViews(Vec<&'static dyn View>);
574
575    impl App for WithViews {
576        fn name(&self) -> &str {
577            "WithViews"
578        }
579        fn tables(&self) -> Vec<&dyn Table> {
580            vec![&crate::fixture::Books]
581        }
582        fn views(&self) -> Vec<&dyn View> {
583            self.0.clone()
584        }
585    }
586
587    fn host_web(argv: &[&str]) -> WebArgs {
588        match HostCli::parse_from(argv).command {
589            HostCommand::Web(args) => args,
590            HostCommand::ServeSpeech(_) => panic!("the web subcommand"),
591        }
592    }
593
594    fn help_of(command: &mut clap::Command, subcommand: &str) -> String {
595        command
596            .find_subcommand_mut(subcommand)
597            .unwrap_or_else(|| panic!("the {subcommand} subcommand"))
598            .render_help()
599            .to_string()
600    }
601
602    #[test]
603    fn table_and_port_are_unset_when_unstated() {
604        let args = parse(&["library", "web"]);
605        assert!(args.table.is_none());
606        assert!(args.port.is_none());
607        assert!(!args.no_open);
608    }
609
610    #[test]
611    fn a_named_table_and_flags_parse() {
612        let args = parse(&["library", "web", "books", "--no-open", "--port", "9000"]);
613        assert_eq!(args.table.as_deref(), Some("books"));
614        assert_eq!(args.port, Some(9000));
615        assert!(args.no_open);
616    }
617
618    #[test]
619    fn stop_takes_the_port_as_a_global() {
620        let args = parse(&["library", "web", "stop", "--port", "9000"]);
621        assert!(matches!(args.command, Some(ServerCommand::Stop)));
622        assert_eq!(args.port, Some(9000));
623    }
624
625    #[test]
626    fn flattening_keeps_both_halves_of_the_arguments() {
627        let args = host_web(&["archive", "web", "books", "--no-service", "--port", "9000"]);
628        assert!(args.no_service);
629        assert_eq!(args.server.table.as_deref(), Some("books"));
630        assert_eq!(args.server.port, Some(9000));
631    }
632
633    #[test]
634    fn flattening_keeps_the_stop_subcommand() {
635        let args = host_web(&["archive", "web", "stop", "--port", "9000"]);
636        assert!(matches!(args.server.command, Some(ServerCommand::Stop)));
637        assert_eq!(args.server.port, Some(9000));
638    }
639
640    #[test]
641    fn an_unstated_port_falls_back_to_the_apps_own() {
642        let server = Server::new(Library::new()).default_port(8788);
643        assert_eq!(server.port(&parse(&["library", "web"])), 8788);
644        assert_eq!(
645            server.port(&parse(&["library", "web", "--port", "9000"])),
646            9000
647        );
648    }
649
650    #[test]
651    fn the_default_port_is_8787_until_an_app_names_its_own() {
652        let server = Server::new(Library::new());
653        assert_eq!(server.port(&parse(&["library", "web"])), 8787);
654    }
655
656    #[test]
657    fn url_opens_the_first_table_when_the_app_declares_no_front_page() {
658        // Named rather than left bare, because a repository serving a bundle
659        // of its own may read `?table=` and know nothing of front pages.
660        struct Plainly(crate::fixture::Books);
661        impl App for Plainly {
662            fn name(&self) -> &str {
663                "Plainly"
664            }
665            fn tables(&self) -> Vec<&dyn Table> {
666                vec![&self.0]
667            }
668        }
669
670        let server = Server::new(Plainly(crate::fixture::Books));
671        let args = parse(&["library", "web"]);
672        assert_eq!(
673            server.url(&args, server.port(&args)),
674            "http://127.0.0.1:8787/?table=books"
675        );
676    }
677
678    #[test]
679    fn url_names_the_table_and_port() {
680        let server = Server::new(Library::new());
681        let args = parse(&["library", "web", "genres", "--port", "8788"]);
682        assert_eq!(
683            server.url(&args, server.port(&args)),
684            "http://127.0.0.1:8788/?table=genres"
685        );
686    }
687
688    #[test]
689    fn url_names_nothing_when_nothing_was_named() {
690        // The app's front page decides what a bare address opens, and the
691        // browser resolves it, so the launcher does not have to know.
692        let server = Server::new(Library::new());
693        let args = parse(&["library", "web"]);
694        assert_eq!(
695            server.url(&args, server.port(&args)),
696            "http://127.0.0.1:8787/"
697        );
698    }
699
700    #[test]
701    fn url_names_a_view_the_app_serves_as_a_view() {
702        let server = Server::new(Library::new());
703        let args = parse(&["library", "web", "on-loan"]);
704        assert_eq!(
705            server.url(&args, server.port(&args)),
706            "http://127.0.0.1:8787/?view=on-loan"
707        );
708    }
709
710    #[test]
711    fn builders_override_the_defaults() {
712        let server = Server::new(Library::new())
713            .index_html("<!doctype html><title>Library</title>")
714            .child_env("LIBRARY_WEB_CHILD")
715            .command("edit")
716            .default_port(8790)
717            .worker_args(["--no-service".to_string()]);
718        assert_eq!(server.index_html, "<!doctype html><title>Library</title>");
719        assert_eq!(server.child_env, "LIBRARY_WEB_CHILD");
720        assert_eq!(server.command, "edit");
721        assert_eq!(server.default_port, 8790);
722        assert_eq!(server.worker_args, ["--no-service"]);
723    }
724
725    #[test]
726    fn the_worker_is_started_with_what_the_app_forwards() {
727        let server = Server::new(Library::new())
728            .command("edit")
729            .worker_args(["--no-service".to_string()]);
730        assert_eq!(
731            launch::worker_argv(server.command, Some("books"), 8790, &server.worker_args).unwrap(),
732            ["edit", "books", "--port", "8790", "--no-service"]
733        );
734    }
735
736    #[test]
737    fn help_states_the_apps_own_defaults() {
738        let mut command = ServerArgs::augment_help(Cli::command(), "books", 8788);
739        let help = help_of(&mut command, "web");
740
741        assert!(help.contains("Defaults to books."), "{help}");
742        assert!(help.contains("Defaults to 8788."), "{help}");
743    }
744
745    #[test]
746    fn help_reaches_arguments_a_repository_has_flattened_into_its_own() {
747        let mut command = ServerArgs::augment_help(HostCli::command(), "books", 8788);
748        let help = help_of(&mut command, "web");
749
750        assert!(help.contains("Defaults to books."), "{help}");
751        assert!(help.contains("Defaults to 8788."), "{help}");
752        // The repository's own arguments are left as they were.
753        assert!(help.contains("--no-service"), "{help}");
754    }
755
756    #[test]
757    fn a_repositorys_own_port_keeps_its_own_help() {
758        let mut command = ServerArgs::augment_help(HostCli::command(), "books", 8788);
759        let help = help_of(&mut command, "serve-speech");
760
761        // Clap drops the full stop a doc comment ends in; the point is that
762        // this is still the repository's own sentence.
763        assert!(
764            help.contains("Port the speech service listens on"),
765            "{help}"
766        );
767        assert!(help.contains("Defaults to 8765"), "{help}");
768        assert!(!help.contains("Defaults to 8788"), "{help}");
769        assert!(!help.contains("127.0.0.1"), "{help}");
770    }
771
772    #[test]
773    fn help_a_repository_has_already_written_is_left_alone() {
774        let command = Cli::command().mut_subcommand("web", |web| {
775            web.mut_arg("port", |arg| arg.help("Port for the editor. Ask Ada."))
776        });
777        let mut command = ServerArgs::augment_help(command, "books", 8788);
778        let help = help_of(&mut command, "web");
779
780        assert!(help.contains("Ask Ada."), "{help}");
781        assert!(!help.contains("Defaults to 8788."), "{help}");
782        // Both arguments are judged together, so the table is left as it was.
783        assert!(!help.contains("Defaults to books."), "{help}");
784    }
785
786    #[test]
787    fn a_command_without_the_editors_arguments_is_left_alone() {
788        let command = ServerArgs::augment_help(clap::Command::new("bare"), "books", 8788);
789        assert_eq!(command.get_name(), "bare");
790        assert_eq!(command.get_arguments().count(), 0);
791    }
792
793    #[test]
794    fn a_worker_argument_that_cannot_work_is_refused_whatever_is_on_the_port() {
795        // Something else is listening, so a launch that probed the port first
796        // would complain about the port. The argument is the complaint,
797        // because it is settled before anything is asked of the network.
798        let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
799        let port = listener.local_addr().unwrap().port().to_string();
800
801        let failure = Server::new(Library::new())
802            .worker_args(["books".to_string()])
803            .run(parse(&["library", "web", "--no-open", "--port", &port]))
804            .expect_err("a positional cannot be forwarded to the worker");
805
806        assert!(failure.to_string().contains("not a flag"), "{failure}");
807    }
808
809    #[test]
810    #[should_panic(expected = "reserved name")]
811    fn a_table_may_not_take_a_reserved_name() {
812        let _ = Server::new(Clashing::new());
813    }
814
815    #[test]
816    #[should_panic(expected = "reserved name")]
817    fn a_view_may_not_take_a_reserved_name() {
818        struct Reserved;
819        impl ViewLogic for Reserved {
820            fn name(&self) -> &'static str {
821                "health"
822            }
823            fn title(&self) -> &'static str {
824                "Health"
825            }
826            fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
827                Ok(ViewData::new())
828            }
829        }
830        let _ = Server::new(WithViews(vec![&Reserved]));
831    }
832
833    #[test]
834    #[should_panic(expected = "same name as a table")]
835    fn a_view_may_not_take_a_tables_name() {
836        struct Books;
837        impl ViewLogic for Books {
838            fn name(&self) -> &'static str {
839                "books"
840            }
841            fn title(&self) -> &'static str {
842                "Books"
843            }
844            fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
845                Ok(ViewData::new())
846            }
847        }
848        let _ = Server::new(WithViews(vec![&Books]));
849    }
850
851    #[test]
852    #[should_panic(expected = "two views are named")]
853    fn two_views_may_not_share_a_name() {
854        let _ = Server::new(WithViews(vec![
855            &crate::fixture::Shelf,
856            &crate::fixture::Shelf,
857        ]));
858    }
859
860    #[test]
861    #[should_panic(expected = "front page names the view")]
862    fn the_front_page_may_not_name_a_view_the_app_does_not_serve() {
863        struct Missing;
864        impl App for Missing {
865            fn name(&self) -> &str {
866                "Missing"
867            }
868            fn tables(&self) -> Vec<&dyn Table> {
869                vec![&crate::fixture::Books]
870            }
871            fn front(&self) -> Front {
872                Front::View("nowhere")
873            }
874        }
875        let _ = Server::new(Missing);
876    }
877
878    #[test]
879    #[should_panic(expected = "front page names the table")]
880    fn the_front_page_may_not_name_a_table_the_app_does_not_serve() {
881        struct Missing;
882        impl App for Missing {
883            fn name(&self) -> &str {
884                "Missing"
885            }
886            fn tables(&self) -> Vec<&dyn Table> {
887                vec![&crate::fixture::Books]
888            }
889            fn front(&self) -> Front {
890                Front::Table("nowhere")
891            }
892        }
893        let _ = Server::new(Missing);
894    }
895
896    #[test]
897    #[should_panic(expected = "the address uses that word")]
898    fn a_parameter_may_not_be_keyed_for_the_address_itself() {
899        struct Hijack;
900        impl ViewLogic for Hijack {
901            fn name(&self) -> &'static str {
902                "hijack"
903            }
904            fn title(&self) -> &'static str {
905                "Hijack"
906            }
907            fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
908                Ok(vec![Param::string("view", "View")])
909            }
910            fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
911                Ok(ViewData::new())
912            }
913        }
914        let _ = Server::new(WithViews(vec![&Hijack]));
915    }
916
917    #[test]
918    #[should_panic(expected = "the address uses that word")]
919    fn a_parameter_may_not_be_keyed_for_a_table_either() {
920        struct Hijack;
921        impl ViewLogic for Hijack {
922            fn name(&self) -> &'static str {
923                "hijack"
924            }
925            fn title(&self) -> &'static str {
926                "Hijack"
927            }
928            fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
929                Ok(vec![Param::select("table", "Table", ["books"])])
930            }
931            fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
932                Ok(ViewData::new())
933            }
934        }
935        let _ = Server::new(WithViews(vec![&Hijack]));
936    }
937
938    #[test]
939    #[should_panic(expected = "takes letters, digits")]
940    fn a_view_name_may_not_carry_a_character_an_address_reserves() {
941        struct Spaced;
942        impl ViewLogic for Spaced {
943            fn name(&self) -> &'static str {
944                "on loan"
945            }
946            fn title(&self) -> &'static str {
947                "On loan"
948            }
949            fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
950                Ok(ViewData::new())
951            }
952        }
953        let _ = Server::new(WithViews(vec![&Spaced]));
954    }
955
956    #[test]
957    fn a_name_is_made_of_what_an_address_carries_unchanged() {
958        assert_eq!(reserved_url_character("on-loan"), None);
959        assert_eq!(reserved_url_character("books_2.0~a"), None);
960        assert_eq!(reserved_url_character("on loan"), Some(' '));
961        assert_eq!(reserved_url_character("on/loan"), Some('/'));
962        assert_eq!(reserved_url_character("what?"), Some('?'));
963        assert_eq!(reserved_url_character("a&b"), Some('&'));
964        assert_eq!(reserved_url_character("café"), Some('é'));
965    }
966
967    #[test]
968    fn an_app_that_serves_views_is_built_like_any_other() {
969        let server = Server::new(Library::new());
970        assert_eq!(server.app.views().len(), 2);
971        assert_eq!(server.app.front(), Front::View("on-loan"));
972    }
973
974    #[test]
975    #[should_panic(expected = "bare name inside Data/")]
976    fn a_tables_file_may_not_be_a_path() {
977        let _ = Server::new(Straying::new());
978    }
979
980    #[test]
981    fn a_bare_file_name_is_one_file_in_the_data_directory() {
982        assert!(is_bare_file_name("Books.jsonl"));
983        assert!(is_bare_file_name("books.with.dots.jsonl"));
984
985        for stray in [
986            "",
987            ".",
988            "..",
989            "../Books.jsonl",
990            "sub/Books.jsonl",
991            r"sub\Books.jsonl",
992            "/etc/passwd",
993        ] {
994            assert!(!is_bare_file_name(stray), "{stray}");
995        }
996    }
997
998    #[test]
999    fn the_server_can_be_moved_to_another_thread() {
1000        fn assert_send<T: Send>(_: &T) {}
1001        let server = Server::new(Library::new()).before_launch(|| {});
1002        assert_send(&server);
1003    }
1004
1005    /// Whatever the build script found, the page is held to the standard for
1006    /// what it claims to be: a checkout that has never run bun is a legitimate
1007    /// state and passes here, and a checkout that has built the bundle is held
1008    /// to everything a released page must be.
1009    #[test]
1010    fn the_embedded_page_is_what_it_says_it_is() {
1011        assert!(DEFAULT_INDEX_HTML.starts_with("<!doctype html>"));
1012        match BUNDLE_KIND {
1013            "built" => {
1014                // One self-contained page: the element the editor mounts on,
1015                // and its script inlined rather than fetched.
1016                assert!(DEFAULT_INDEX_HTML.contains(r#"<div id="root">"#));
1017                assert!(!DEFAULT_INDEX_HTML.contains(r#"src="/src/main.tsx""#));
1018                assert!(
1019                    DEFAULT_INDEX_HTML.len() > 50_000,
1020                    "the bundle is {} bytes, which is too small to be the built editor",
1021                    DEFAULT_INDEX_HTML.len()
1022                );
1023            }
1024            "placeholder" => {
1025                // It says which file is missing, since that is the whole of
1026                // what it is for.
1027                assert!(DEFAULT_INDEX_HTML.contains("assets/index.html"));
1028                assert!(!DEFAULT_INDEX_HTML.contains(r#"<div id="root">"#));
1029            }
1030            other => panic!("the build script embedded a page of unknown kind {other:?}"),
1031        }
1032    }
1033
1034    #[test]
1035    fn stop_does_not_run_before_launch() {
1036        let ran = Arc::new(AtomicUsize::new(0));
1037        let counter = Arc::clone(&ran);
1038        let server = Server::new(Library::new()).before_launch(move || {
1039            counter.fetch_add(1, Ordering::Relaxed);
1040        });
1041
1042        // Port 1 is privileged and never has our server, so the stop is a no-op.
1043        server
1044            .run(parse(&["library", "web", "stop", "--port", "1"]))
1045            .unwrap();
1046        assert_eq!(ran.load(Ordering::Relaxed), 0);
1047    }
1048}