1use 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
16const DEFAULT_INDEX_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
22
23#[cfg(test)]
27const BUNDLE_KIND: &str = env!("TABLE_EDITOR_BUNDLE");
28
29const DEFAULT_CHILD_ENV: &str = "TABLE_EDITOR_CHILD";
32
33const DEFAULT_COMMAND: &str = "web";
36
37const DEFAULT_PORT: u16 = 8787;
39
40#[derive(Debug, Args)]
43#[command(args_conflicts_with_subcommands = true)]
44pub struct ServerArgs {
45 #[command(subcommand)]
47 pub command: Option<ServerCommand>,
48
49 pub table: Option<String>,
51
52 #[arg(long, global = true)]
55 pub port: Option<u16>,
56
57 #[arg(long)]
59 pub no_open: bool,
60
61 #[arg(long)]
65 pub restart: bool,
66
67 #[arg(long)]
70 pub api_only: bool,
71}
72
73impl ServerArgs {
74 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
118fn 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
133fn 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
167fn reserved_url_character(name: &str) -> Option<char> {
175 name.chars()
176 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')))
177}
178
179const RESERVED_PARAM_KEYS: [&str; 2] = ["view", "table"];
182
183fn 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,
197}
198
199pub 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 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 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 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 pub fn index_html(mut self, html: &'static str) -> Self {
311 self.index_html = html;
312 self
313 }
314
315 pub fn child_env(mut self, var: &'static str) -> Self {
319 self.child_env = var;
320 self
321 }
322
323 pub fn command(mut self, command: &'static str) -> Self {
326 self.command = command;
327 self
328 }
329
330 pub fn default_port(mut self, port: u16) -> Self {
333 self.default_port = port;
334 self
335 }
336
337 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 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 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 if args.api_only {
388 return self.serve(port, args.api_only);
389 }
390
391 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 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 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 fn port(&self, args: &ServerArgs) -> u16 {
438 args.port.unwrap_or(self.default_port)
439 }
440
441 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 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
487fn 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 #[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 #[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 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 #[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 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 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 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 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 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 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 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 #[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 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 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 server
1044 .run(parse(&["library", "web", "stop", "--port", "1"]))
1045 .unwrap();
1046 assert_eq!(ran.load(Ordering::Relaxed), 0);
1047 }
1048}