1use crate::errors::CliError;
2use crate::util::{contains_protocol, host_or_url_to_host_and_protocol};
3use anyhow::Context;
4use jsonwebtoken::DecodingKey;
5use spacetimedb_fs_utils::atomic_write;
6use spacetimedb_paths::cli::CliTomlPath;
7use std::collections::HashMap;
8use std::io;
9use std::path::Path;
10use toml_edit::ArrayOfTables;
11
12const DEFAULT_SERVER_KEY: &str = "default_server";
13const WEB_SESSION_TOKEN_KEY: &str = "web_session_token";
14const SPACETIMEDB_TOKEN_KEY: &str = "spacetimedb_token";
15const SERVER_CONFIGS_KEY: &str = "server_configs";
16const NICKNAME_KEY: &str = "nickname";
17const HOST_KEY: &str = "host";
18const PROTOCOL_KEY: &str = "protocol";
19const ECDSA_PUBLIC_KEY: &str = "ecdsa_public_key";
20
21#[derive(Clone, Debug)]
22pub struct ServerConfig {
23 pub nickname: Option<String>,
24 pub host: String,
25 pub protocol: String,
26 pub ecdsa_public_key: Option<String>,
27}
28
29impl ServerConfig {
30 pub fn as_table(&self) -> toml_edit::Table {
32 let mut table = toml_edit::Table::new();
33 Self::update_table(&mut table, self);
34 table
35 }
36
37 pub fn update_table(edit: &mut toml_edit::Table, from: &ServerConfig) {
39 set_table_opt_value(edit, NICKNAME_KEY, from.nickname.as_deref());
40 set_table_opt_value(edit, HOST_KEY, Some(&from.host));
41 set_table_opt_value(edit, PROTOCOL_KEY, Some(&from.protocol));
42 set_table_opt_value(edit, ECDSA_PUBLIC_KEY, from.ecdsa_public_key.as_deref());
43 }
44
45 fn nick_or_host(&self) -> &str {
46 if let Some(nick) = &self.nickname {
47 nick
48 } else {
49 &self.host
50 }
51 }
52 pub fn get_host_url(&self) -> String {
53 format!("{}://{}", self.protocol, self.host)
54 }
55
56 pub fn nick_or_host_or_url_is(&self, name: &str) -> bool {
57 self.nickname.as_deref() == Some(name) || self.host == name || {
58 let (host, _) = host_or_url_to_host_and_protocol(name);
59 self.host == host
60 }
61 }
62}
63
64fn read_table<'a>(table: &'a toml_edit::Table, key: &'a str) -> Result<Option<&'a ArrayOfTables>, CliError> {
65 if let Some(value) = table.get(key) {
66 if value.is_array_of_tables() {
67 Ok(value.as_array_of_tables())
68 } else {
69 Err(CliError::ConfigType {
70 key: key.to_string(),
71 kind: "table array",
72 found: Box::new(value.clone()),
73 })
74 }
75 } else {
76 Ok(None)
77 }
78}
79
80fn read_opt_str(table: &toml_edit::Table, key: &str) -> Result<Option<String>, CliError> {
81 if let Some(value) = table.get(key) {
82 if value.is_str() {
83 Ok(value.as_str().map(String::from))
84 } else {
85 Err(CliError::ConfigType {
86 key: key.to_string(),
87 kind: "string",
88 found: Box::new(value.clone()),
89 })
90 }
91 } else {
92 Ok(None)
93 }
94}
95
96fn read_str(table: &toml_edit::Table, key: &str) -> Result<String, CliError> {
97 read_opt_str(table, key)?.ok_or_else(|| CliError::Config { key: key.to_string() })
98}
99
100impl TryFrom<&toml_edit::Table> for ServerConfig {
101 type Error = CliError;
102
103 fn try_from(table: &toml_edit::Table) -> Result<Self, Self::Error> {
104 let nickname = read_opt_str(table, NICKNAME_KEY)?;
105 let host = read_str(table, HOST_KEY)?;
106 let protocol = read_str(table, PROTOCOL_KEY)?;
107 let ecdsa_public_key = read_opt_str(table, ECDSA_PUBLIC_KEY)?;
108 Ok(ServerConfig {
109 nickname,
110 host,
111 protocol,
112 ecdsa_public_key,
113 })
114 }
115}
116
117#[derive(Default, Debug, Clone)]
120pub struct RawConfig {
121 default_server: Option<String>,
122 server_configs: Vec<ServerConfig>,
123 web_session_token: Option<String>,
126 spacetimedb_token: Option<String>,
127}
128
129#[derive(Debug, Clone)]
130pub struct Config {
131 home: RawConfig,
132 home_path: CliTomlPath,
133 doc: toml_edit::DocumentMut,
137}
138
139const NO_DEFAULT_SERVER_ERROR_MESSAGE: &str = "No default server configuration.
140Set an existing server as the default with:
141\tspacetime server set-default <server>
142Or add a new server which will become the default:
143\tspacetime server add {server} <url> --default";
144
145fn no_such_server_error(server: &str) -> anyhow::Error {
146 anyhow::anyhow!(
147 "No such saved server configuration: {server}
148Add a new server configuration with:
149\tspacetime server add {server} --url <url>",
150 )
151}
152
153fn hanging_default_server_context(server: &str) -> String {
154 format!("Default server does not refer to a saved server configuration: {server}")
155}
156
157impl RawConfig {
158 fn new_with_localhost() -> Self {
159 let local = ServerConfig {
160 host: "127.0.0.1:3000".to_string(),
161 protocol: "http".to_string(),
162 nickname: Some("local".to_string()),
163 ecdsa_public_key: None,
164 };
165 let maincloud = ServerConfig {
166 host: "maincloud.spacetimedb.com".to_string(),
167 protocol: "https".to_string(),
168 nickname: Some("maincloud".to_string()),
169 ecdsa_public_key: None,
170 };
171 RawConfig {
172 default_server: local.nickname.clone(),
173 server_configs: vec![local, maincloud],
174 web_session_token: None,
175 spacetimedb_token: None,
176 }
177 }
178
179 fn find_server(&self, name_or_host: &str) -> anyhow::Result<&ServerConfig> {
180 for cfg in &self.server_configs {
181 if cfg.nickname.as_deref() == Some(name_or_host) || cfg.host == name_or_host {
182 return Ok(cfg);
183 }
184 }
185 Err(no_such_server_error(name_or_host))
186 }
187
188 fn find_server_mut(&mut self, name_or_host: &str) -> anyhow::Result<&mut ServerConfig> {
189 for cfg in &mut self.server_configs {
190 if cfg.nickname.as_deref() == Some(name_or_host) || cfg.host == name_or_host {
191 return Ok(cfg);
192 }
193 }
194 Err(no_such_server_error(name_or_host))
195 }
196
197 fn default_server(&self) -> anyhow::Result<&ServerConfig> {
198 if let Some(default_server) = self.default_server.as_ref() {
199 self.find_server(default_server)
200 .with_context(|| hanging_default_server_context(default_server))
201 } else {
202 Err(anyhow::anyhow!(NO_DEFAULT_SERVER_ERROR_MESSAGE))
203 }
204 }
205
206 fn default_server_mut(&mut self) -> anyhow::Result<&mut ServerConfig> {
207 if let Some(default_server) = self.default_server.as_ref() {
208 let default = default_server.to_string();
209 self.find_server_mut(&default)
210 .with_context(|| hanging_default_server_context(&default))
211 } else {
212 Err(anyhow::anyhow!(NO_DEFAULT_SERVER_ERROR_MESSAGE))
213 }
214 }
215
216 fn add_server(
217 &mut self,
218 host: String,
219 protocol: String,
220 ecdsa_public_key: Option<String>,
221 nickname: Option<String>,
222 ) -> anyhow::Result<()> {
223 if let Some(nickname) = &nickname {
224 if let Ok(cfg) = self.find_server(nickname) {
225 anyhow::bail!(
226 "Server nickname {} already in use: {}://{}",
227 nickname,
228 cfg.protocol,
229 cfg.host,
230 );
231 }
232 }
233
234 if let Ok(cfg) = self.find_server(&host) {
235 if let Some(nick) = &cfg.nickname {
236 if nick == &host {
237 anyhow::bail!("Server host name is ambiguous with existing server nickname: {}", nick);
238 }
239 }
240 anyhow::bail!("Server already configured for host: {}", host);
241 }
242
243 self.server_configs.push(ServerConfig {
244 nickname,
245 host,
246 protocol,
247 ecdsa_public_key,
248 });
249 Ok(())
250 }
251
252 fn host(&self, server: &str) -> anyhow::Result<&str> {
253 self.find_server(server)
254 .map(|cfg| cfg.host.as_ref())
255 .with_context(|| format!("Cannot find hostname for unknown server: {server}"))
256 }
257
258 fn default_host(&self) -> anyhow::Result<&str> {
259 self.default_server()
260 .with_context(|| "Cannot find hostname for default server")
261 .map(|cfg| cfg.host.as_ref())
262 }
263
264 fn protocol(&self, server: &str) -> anyhow::Result<&str> {
265 self.find_server(server).map(|cfg| cfg.protocol.as_ref())
266 }
267
268 fn default_protocol(&self) -> anyhow::Result<&str> {
269 self.default_server()
270 .with_context(|| "Cannot find protocol for default server")
271 .map(|cfg| cfg.protocol.as_ref())
272 }
273
274 fn set_default_server(&mut self, server: &str) -> anyhow::Result<()> {
275 self.find_server(server)
277 .with_context(|| format!("Cannot set default server to unknown server {server}"))?;
278
279 self.default_server = Some(server.to_string());
280
281 Ok(())
282 }
283
284 fn remove_server(&mut self, server: &str) -> anyhow::Result<()> {
286 if let Some(idx) = self
289 .server_configs
290 .iter()
291 .position(|cfg| cfg.nick_or_host_or_url_is(server))
292 {
293 let cfg = self.server_configs.remove(idx);
295
296 if let Some(default_server) = &self.default_server {
299 if cfg.nick_or_host_or_url_is(default_server) {
300 self.default_server = None;
301 }
302 }
303
304 return Ok(());
305 }
306 Err(no_such_server_error(server))
307 }
308
309 fn server_fingerprint(&self, server: &str) -> anyhow::Result<Option<&str>> {
314 self.find_server(server)
315 .with_context(|| {
316 format!(
317 "No saved fingerprint for server: {server}
318Fetch the server's fingerprint with:
319\tspacetime server fingerprint -s {server}"
320 )
321 })
322 .map(|cfg| cfg.ecdsa_public_key.as_deref())
323 }
324
325 fn default_server_fingerprint(&self) -> anyhow::Result<Option<&str>> {
330 if let Some(server) = &self.default_server {
331 self.server_fingerprint(server)
332 } else {
333 Err(anyhow::anyhow!(NO_DEFAULT_SERVER_ERROR_MESSAGE))
334 }
335 }
336
337 fn set_server_fingerprint(&mut self, server: &str, ecdsa_public_key: String) -> anyhow::Result<()> {
342 let cfg = self.find_server_mut(server)?;
343 cfg.ecdsa_public_key = Some(ecdsa_public_key);
344 Ok(())
345 }
346
347 fn set_default_server_fingerprint(&mut self, ecdsa_public_key: String) -> anyhow::Result<()> {
352 let cfg = self.default_server_mut()?;
353 cfg.ecdsa_public_key = Some(ecdsa_public_key);
354 Ok(())
355 }
356
357 pub fn edit_server(
365 &mut self,
366 server: &str,
367 new_nickname: Option<&str>,
368 new_host: Option<&str>,
369 new_protocol: Option<&str>,
370 ) -> anyhow::Result<(Option<String>, Option<String>, Option<String>)> {
371 if let Some(new_nick) = new_nickname {
374 if let Ok(other_server) = self.find_server(new_nick) {
375 anyhow::bail!(
376 "Nickname {} conflicts with saved configuration for server {}: {}://{}",
377 new_nick,
378 other_server.nick_or_host(),
379 other_server.protocol,
380 other_server.host
381 );
382 }
383 }
384 if let Some(new_host) = new_host {
385 if let Ok(other_server) = self.find_server(new_host) {
386 anyhow::bail!(
387 "Host {} conflicts with saved configuration for server {}: {}://{}",
388 new_host,
389 other_server.nick_or_host(),
390 other_server.protocol,
391 other_server.host
392 );
393 }
394 }
395
396 let cfg = self.find_server_mut(server)?;
397 let old_nickname = if let Some(new_nickname) = new_nickname {
398 cfg.nickname.replace(new_nickname.to_string())
399 } else {
400 None
401 };
402 let old_host = if let Some(new_host) = new_host {
403 Some(std::mem::replace(&mut cfg.host, new_host.to_string()))
404 } else {
405 None
406 };
407 let old_protocol = if let Some(new_protocol) = new_protocol {
408 Some(std::mem::replace(&mut cfg.protocol, new_protocol.to_string()))
409 } else {
410 None
411 };
412
413 if let Some(default_server) = &mut self.default_server {
417 if let Some(old_host) = &old_host {
418 if default_server == old_host {
419 *default_server = new_host.unwrap().to_string();
420 }
421 } else if let Some(old_nick) = &old_nickname {
422 if default_server == old_nick {
423 *default_server = new_nickname.unwrap().to_string();
424 }
425 }
426 }
427
428 Ok((old_nickname, old_host, old_protocol))
429 }
430
431 pub fn delete_server_fingerprint(&mut self, server: &str) -> anyhow::Result<()> {
432 let cfg = self.find_server_mut(server)?;
433 cfg.ecdsa_public_key = None;
434 Ok(())
435 }
436
437 pub fn delete_default_server_fingerprint(&mut self) -> anyhow::Result<()> {
438 let cfg = self.default_server_mut()?;
439 cfg.ecdsa_public_key = None;
440 Ok(())
441 }
442
443 pub fn set_web_session_token(&mut self, token: String) {
444 self.web_session_token = Some(token);
445 }
446
447 pub fn set_spacetimedb_token(&mut self, token: String) {
448 self.spacetimedb_token = Some(token);
449 }
450
451 pub fn clear_login_tokens(&mut self) {
452 self.web_session_token = None;
453 self.spacetimedb_token = None;
454 }
455}
456
457impl TryFrom<&toml_edit::DocumentMut> for RawConfig {
458 type Error = CliError;
459
460 fn try_from(value: &toml_edit::DocumentMut) -> Result<Self, Self::Error> {
461 let default_server = read_opt_str(value, DEFAULT_SERVER_KEY)?;
462 let web_session_token = read_opt_str(value, WEB_SESSION_TOKEN_KEY)?;
463 let spacetimedb_token = read_opt_str(value, SPACETIMEDB_TOKEN_KEY)?;
464
465 let mut server_configs = Vec::new();
466 if let Some(arr) = read_table(value, SERVER_CONFIGS_KEY)? {
467 for table in arr {
468 server_configs.push(ServerConfig::try_from(table)?);
469 }
470 }
471
472 Ok(RawConfig {
473 default_server,
474 server_configs,
475 web_session_token,
476 spacetimedb_token,
477 })
478 }
479}
480
481impl Config {
482 pub fn default_server_name(&self) -> Option<&str> {
483 self.home.default_server.as_deref()
484 }
485
486 pub fn add_server(
494 &mut self,
495 host: String,
496 protocol: String,
497 ecdsa_public_key: Option<String>,
498 nickname: Option<String>,
499 ) -> anyhow::Result<()> {
500 self.home.add_server(host, protocol, ecdsa_public_key, nickname)
501 }
502
503 pub fn set_default_server(&mut self, nickname_or_host_or_url: &str) -> anyhow::Result<()> {
512 let (host, _) = host_or_url_to_host_and_protocol(nickname_or_host_or_url);
513 self.home.set_default_server(host)
514 }
515
516 pub fn remove_server(&mut self, nickname_or_host_or_url: &str) -> anyhow::Result<()> {
525 let (host, _) = host_or_url_to_host_and_protocol(nickname_or_host_or_url);
526 self.home.remove_server(host)
527 }
528
529 pub fn get_host_url(&self, server: Option<&str>) -> anyhow::Result<String> {
543 Ok(format!("{}://{}", self.protocol(server)?, self.host(server)?))
544 }
545
546 pub fn host<'a>(&'a self, server: Option<&'a str>) -> anyhow::Result<&'a str> {
561 if let Some(server) = server {
562 if contains_protocol(server) {
563 Ok(host_or_url_to_host_and_protocol(server).0)
564 } else {
565 self.home.host(server)
566 }
567 } else {
568 self.home.default_host()
569 }
570 }
571
572 pub fn protocol<'a>(&'a self, server: Option<&'a str>) -> anyhow::Result<&'a str> {
587 if let Some(server) = server {
588 if contains_protocol(server) {
589 Ok(host_or_url_to_host_and_protocol(server).1.unwrap())
590 } else {
591 self.home.protocol(server)
592 }
593 } else {
594 self.home.default_protocol()
595 }
596 }
597
598 pub fn server_configs(&self) -> &[ServerConfig] {
599 &self.home.server_configs
600 }
601
602 fn parse_config(path: &Path) -> anyhow::Result<Option<(toml_edit::DocumentMut, RawConfig)>> {
606 match std::fs::read_to_string(path) {
607 Ok(contents) => {
608 let doc = contents.parse::<toml_edit::DocumentMut>()?;
609 let config = RawConfig::try_from(&doc)?;
610 Ok(Some((doc, config)))
611 }
612 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
613 Err(e) => Err(e.into()),
614 }
615 }
616
617 pub fn load(home_path: CliTomlPath) -> anyhow::Result<Self> {
618 let home = Self::parse_config(home_path.as_ref())
619 .with_context(|| format!("config file {} is invalid", home_path.display()))?;
620 Ok(match home {
621 Some((doc, home)) => Self { home, home_path, doc },
622 None => {
623 let config = Self {
624 home: RawConfig::new_with_localhost(),
625 home_path,
626 doc: Default::default(),
627 };
628 config.save();
629 config
630 }
631 })
632 }
633
634 #[doc(hidden)]
635 pub fn new_with_localhost(home_path: CliTomlPath) -> Self {
637 Self {
638 home: RawConfig::new_with_localhost(),
639 home_path,
640 doc: Default::default(),
641 }
642 }
643
644 fn doc(&self) -> toml_edit::DocumentMut {
646 let mut doc = self.doc.clone();
647
648 let mut set_value = |key: &str, value: Option<&str>| {
649 set_opt_value(&mut doc, key, value);
650 };
651 let RawConfig {
653 default_server,
654 server_configs: old_server_configs,
655 web_session_token,
656 spacetimedb_token,
657 } = &self.home;
658
659 set_value(DEFAULT_SERVER_KEY, default_server.as_deref());
660 set_value(WEB_SESSION_TOKEN_KEY, web_session_token.as_deref());
661 set_value(SPACETIMEDB_TOKEN_KEY, spacetimedb_token.as_deref());
662
663 if old_server_configs.is_empty() {
665 doc.remove(SERVER_CONFIGS_KEY);
666 return doc;
667 }
668 let new_server_configs = if let Some(cfg) = doc
670 .get_mut(SERVER_CONFIGS_KEY)
671 .and_then(toml_edit::Item::as_array_of_tables_mut)
672 {
673 cfg
674 } else {
675 doc[SERVER_CONFIGS_KEY] =
676 toml_edit::Item::ArrayOfTables(old_server_configs.iter().map(ServerConfig::as_table).collect());
677 return doc;
678 };
679
680 let mut new_configs = self
681 .home
682 .server_configs
683 .iter()
684 .map(|cfg| (cfg.nick_or_host(), cfg))
685 .collect::<HashMap<_, _>>();
686
687 let mut new_vec = Vec::with_capacity(new_server_configs.len());
692 for old_config in new_server_configs.iter_mut() {
693 let nick_or_host = old_config
694 .get(NICKNAME_KEY)
695 .or_else(|| old_config.get(HOST_KEY))
696 .and_then(|v| v.as_str())
697 .unwrap();
698
699 if let Some(new_config) = new_configs.remove(nick_or_host) {
700 ServerConfig::update_table(old_config, new_config);
701 new_vec.push(old_config.clone());
702 }
703 }
704
705 new_vec.extend(new_configs.values().cloned().map(ServerConfig::as_table));
708 *new_server_configs = toml_edit::ArrayOfTables::from_iter(new_vec);
709
710 doc
711 }
712
713 pub fn save(&self) {
714 let home_path = &self.home_path;
715 home_path.create_parent().unwrap();
717
718 let config = self.doc().to_string();
719
720 eprintln!("Saving config to {}.", home_path.display());
721 if let Err(e) = atomic_write(&home_path.0, config) {
732 eprintln!("Could not save config file: {e}")
733 }
734 }
735
736 pub fn server_decoding_key(&self, server: Option<&str>) -> anyhow::Result<DecodingKey> {
737 self.server_fingerprint(server).and_then(|fing| {
738 if let Some(fing) = fing {
739 DecodingKey::from_ec_pem(fing.as_bytes()).with_context(|| {
740 format!(
741 "Unable to parse invalid saved server fingerprint as ECDSA public key.
742Update the server's fingerprint with:
743\tspacetime server fingerprint {}",
744 server.unwrap_or("")
745 )
746 })
747 } else {
748 Err(anyhow::anyhow!(
749 "No fingerprint saved for server: {}",
750 self.server_nick_or_host(server)?,
751 ))
752 }
753 })
754 }
755
756 pub fn server_nick_or_host<'a>(&'a self, server: Option<&'a str>) -> anyhow::Result<&'a str> {
757 if let Some(server) = server {
758 let (host, _) = host_or_url_to_host_and_protocol(server);
759 Ok(host)
760 } else {
761 self.home.default_server().map(ServerConfig::nick_or_host)
762 }
763 }
764
765 pub fn server_fingerprint(&self, server: Option<&str>) -> anyhow::Result<Option<&str>> {
766 if let Some(server) = server {
767 let (host, _) = host_or_url_to_host_and_protocol(server);
768 self.home.server_fingerprint(host)
769 } else {
770 self.home.default_server_fingerprint()
771 }
772 }
773
774 pub fn set_server_fingerprint(&mut self, server: Option<&str>, new_fingerprint: String) -> anyhow::Result<()> {
775 if let Some(server) = server {
776 let (host, _) = host_or_url_to_host_and_protocol(server);
777 self.home.set_server_fingerprint(host, new_fingerprint)
778 } else {
779 self.home.set_default_server_fingerprint(new_fingerprint)
780 }
781 }
782
783 pub fn edit_server(
784 &mut self,
785 server: &str,
786 new_nickname: Option<&str>,
787 new_host: Option<&str>,
788 new_protocol: Option<&str>,
789 ) -> anyhow::Result<(Option<String>, Option<String>, Option<String>)> {
790 let (host, _) = host_or_url_to_host_and_protocol(server);
791 self.home.edit_server(host, new_nickname, new_host, new_protocol)
792 }
793
794 pub fn delete_server_fingerprint(&mut self, server: Option<&str>) -> anyhow::Result<()> {
795 if let Some(server) = server {
796 let (host, _) = host_or_url_to_host_and_protocol(server);
797 self.home.delete_server_fingerprint(host)
798 } else {
799 self.home.delete_default_server_fingerprint()
800 }
801 }
802
803 pub fn set_web_session_token(&mut self, token: String) {
804 self.home.set_web_session_token(token);
805 }
806
807 pub fn set_spacetimedb_token(&mut self, token: String) {
808 self.home.set_spacetimedb_token(token);
809 }
810
811 pub fn clear_login_tokens(&mut self) {
812 self.home.clear_login_tokens();
813 }
814
815 pub fn web_session_token(&self) -> Option<&String> {
816 self.home.web_session_token.as_ref()
817 }
818
819 pub fn spacetimedb_token(&self) -> Option<&String> {
820 self.home.spacetimedb_token.as_ref()
821 }
822}
823
824fn copy_value_with_decor(old_value: Option<&toml_edit::Item>, new_value: &str) -> toml_edit::Item {
834 match old_value {
835 Some(toml_edit::Item::Value(toml_edit::Value::String(old_value))) => {
836 let mut new = toml_edit::Value::String(toml_edit::Formatted::new(new_value.to_string()));
838 let decor = new.decor_mut();
839 *decor = old_value.decor().clone();
841 new.into()
842 }
843 _ => new_value.into(),
844 }
845}
846
847pub fn set_opt_value(doc: &mut toml_edit::DocumentMut, key: &str, value: Option<&str>) {
851 let old_value = doc.get(key);
852 if let Some(new) = value {
853 doc[key] = copy_value_with_decor(old_value, new);
854 } else {
855 doc.remove(key);
856 }
857}
858
859pub fn set_table_opt_value(table: &mut toml_edit::Table, key: &str, value: Option<&str>) {
863 let old_value = table.get(key);
864 if let Some(new) = value {
865 table[key] = copy_value_with_decor(old_value, new);
866 } else {
867 table.remove(key);
868 }
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874 use spacetimedb_lib::error::ResultTest;
875 use spacetimedb_paths::cli::CliTomlPath;
876 use spacetimedb_paths::FromPathUnchecked;
877 use std::fs;
878 use std::thread;
879
880 const CONFIG_FULL: &str = r#"default_server = "local"
881web_session_token = "web_session"
882spacetimedb_token = "26ac38857c2bd6c5b60ec557ecd4f9add918fef577dc92c01ca96ff08af5b84d"
883
884# comment on table
885[[server_configs]]
886nickname = "local"
887host = "127.0.0.1:3000"
888protocol = "http"
889
890# comment on table
891[[server_configs]]
892# comment on table
893nickname = "testnet" # Comment nickname
894host = "testnet.spacetimedb.com" # Comment host
895# Comment protocol
896protocol = "https"
897
898# Comment end
899"#;
900 const CONFIG_FULL_NO_COMMENT: &str = r#"default_server = "local"
901web_session_token = "web_session"
902spacetimedb_token = "26ac38857c2bd6c5b60ec557ecd4f9add918fef577dc92c01ca96ff08af5b84d"
903
904[[server_configs]]
905nickname = "local"
906host = "127.0.0.1:3000"
907protocol = "http"
908
909[[server_configs]]
910nickname = "testnet"
911host = "testnet.spacetimedb.com"
912protocol = "https"
913
914# Comment end
915"#;
916 const CONFIG_CHANGE_SERVER: &str = r#"default_server = "local"
917web_session_token = "web_session"
918spacetimedb_token = "26ac38857c2bd6c5b60ec557ecd4f9add918fef577dc92c01ca96ff08af5b84d"
919
920# comment on table
921[[server_configs]]
922# comment on table
923nickname = "testnet" # Comment nickname
924host = "prod.spacetimedb.com" # Comment host
925# Comment protocol
926protocol = "https"
927
928# Comment end
929"#;
930 const CONFIG_EMPTY: &str = r#"
931# Comment end
932"#;
933 const CONFIG_INVALID_START: &str = r#"
934this="not a valid key"
935"#;
936 const CONFIG_INVALID_END: &str = r#"
937this="not a valid key"
938default_server = "local"
939"#;
940
941 fn check_invalid(contents: &str, expect: CliError) -> ResultTest<()> {
942 let doc = contents.parse::<toml_edit::DocumentMut>()?;
943 let err = RawConfig::try_from(&doc);
944 assert_eq!(err.unwrap_err().to_string(), expect.to_string());
945
946 Ok(())
947 }
948
949 fn check_config<F>(input: &str, output: &str, f: F) -> ResultTest<()>
950 where
951 F: FnOnce(&mut Config) -> ResultTest<()>,
952 {
953 let tmp = tempfile::tempdir()?;
954 let config_path = CliTomlPath::from_path_unchecked(tmp.path().join("config.toml"));
955
956 fs::write(&config_path, input)?;
957
958 let mut config = Config::load(config_path.clone()).unwrap();
959 f(&mut config)?;
960 config.save();
961
962 let contents = fs::read_to_string(&config_path)?;
963
964 assert_eq!(contents, output);
965
966 Ok(())
967 }
968
969 #[test]
971 fn test_config_edits() -> ResultTest<()> {
972 check_config(CONFIG_FULL, CONFIG_EMPTY, |config| {
973 config.home.default_server = None;
974 config.home.server_configs.clear();
975 config.home.spacetimedb_token = None;
976 config.home.web_session_token = None;
977
978 Ok(())
979 })?;
980
981 check_config(CONFIG_FULL, CONFIG_CHANGE_SERVER, |config| {
982 config.home.server_configs.remove(0);
983 config.home.server_configs[0].host = "prod.spacetimedb.com".to_string();
984 Ok(())
985 })?;
986
987 Ok(())
988 }
989
990 #[test]
992 fn test_config_adds() -> ResultTest<()> {
993 check_config(CONFIG_FULL, CONFIG_FULL, |_| Ok(()))?;
994 check_config(CONFIG_EMPTY, CONFIG_EMPTY, |_| Ok(()))?;
995
996 check_config(CONFIG_EMPTY, CONFIG_FULL_NO_COMMENT, |config| {
997 config.home.default_server = Some("local".to_string());
998 config.home.server_configs = vec![
999 ServerConfig {
1000 nickname: Some("local".to_string()),
1001 host: "127.0.0.1:3000".to_string(),
1002 protocol: "http".to_string(),
1003 ecdsa_public_key: None,
1004 },
1005 ServerConfig {
1006 nickname: Some("testnet".to_string()),
1007 host: "testnet.spacetimedb.com".to_string(),
1008 protocol: "https".to_string(),
1009 ecdsa_public_key: None,
1010 },
1011 ];
1012 config.home.spacetimedb_token =
1013 Some("26ac38857c2bd6c5b60ec557ecd4f9add918fef577dc92c01ca96ff08af5b84d".to_string());
1014 config.home.web_session_token = Some("web_session".to_string());
1015
1016 Ok(())
1017 })?;
1018
1019 Ok(())
1020 }
1021
1022 #[test]
1024 fn test_config_invalid_mut() -> ResultTest<()> {
1025 check_config(CONFIG_INVALID_START, CONFIG_INVALID_END, |config| {
1026 config.home.default_server = Some("local".to_string());
1027 Ok(())
1028 })?;
1029
1030 Ok(())
1031 }
1032
1033 #[test]
1035 fn test_config_invalid() -> ResultTest<()> {
1036 check_invalid(
1037 r#"default_server =1"#,
1038 CliError::ConfigType {
1039 key: "default_server".to_string(),
1040 kind: "string",
1041 found: Box::new(toml_edit::value(1)),
1042 },
1043 )?;
1044 check_invalid(
1045 r#"web_session_token =1"#,
1046 CliError::ConfigType {
1047 key: "web_session_token".to_string(),
1048 kind: "string",
1049 found: Box::new(toml_edit::value(1)),
1050 },
1051 )?;
1052 check_invalid(
1053 r#"spacetimedb_token =1"#,
1054 CliError::ConfigType {
1055 key: "spacetimedb_token".to_string(),
1056 kind: "string",
1057 found: Box::new(toml_edit::value(1)),
1058 },
1059 )?;
1060 check_invalid(
1061 r#"
1062[server_configs]
1063"#,
1064 CliError::ConfigType {
1065 key: "server_configs".to_string(),
1066 kind: "table array",
1067 found: Box::new(toml_edit::table()),
1068 },
1069 )?;
1070 check_invalid(
1071 r#"
1072[[server_configs]]
1073nickname =1
1074"#,
1075 CliError::ConfigType {
1076 key: "nickname".to_string(),
1077 kind: "string",
1078 found: Box::new(toml_edit::value(1)),
1079 },
1080 )?;
1081 check_invalid(
1082 r#"
1083[[server_configs]]
1084host =1
1085"#,
1086 CliError::ConfigType {
1087 key: "host".to_string(),
1088 kind: "string",
1089 found: Box::new(toml_edit::value(1)),
1090 },
1091 )?;
1092
1093 check_invalid(
1094 r#"
1095[[server_configs]]
1096host = "127.0.0.1:3000"
1097protocol =1
1098"#,
1099 CliError::ConfigType {
1100 key: "protocol".to_string(),
1101 kind: "string",
1102 found: Box::new(toml_edit::value(1)),
1103 },
1104 )?;
1105 Ok(())
1106 }
1107
1108 #[test]
1112 fn test_config_concurrent() -> ResultTest<()> {
1113 let tmp = tempfile::tempdir()?;
1114 let config_path = CliTomlPath::from_path_unchecked(tmp.path().join("config.toml"));
1115
1116 let mut local = Config::load(config_path.clone()).unwrap();
1117 let mut testnet = Config::load(config_path.clone()).unwrap();
1118
1119 local.home.default_server = Some("local".to_string());
1120 testnet.home.default_server = Some("testnet".to_string());
1121
1122 let mut handles = vec![];
1123 let total_threads: usize = 8;
1124
1125 for i in 0..total_threads {
1127 let local = local.clone();
1128 let testnet = testnet.clone();
1129 handles.push(thread::spawn(move || {
1130 if i % 2 == 0 {
1131 local.save();
1132 local
1133 } else {
1134 testnet.save();
1135 testnet
1136 }
1137 .doc()
1138 .to_string()
1139 }));
1140 }
1141
1142 for _ in 0..total_threads {
1144 let config_path = config_path.clone();
1145 handles.push(thread::spawn(move || {
1146 let config = Config::load(config_path).unwrap();
1147 config.doc().to_string()
1148 }));
1149 }
1150
1151 let mut results = vec![];
1152 for handle in handles {
1153 results.push(handle.join().unwrap());
1154 }
1155 let local = local.doc().to_string();
1156 let testnet = testnet.doc().to_string();
1157
1158 assert!(results
1160 .iter()
1161 .all(|r| r.trim() == local.trim() || r.trim() == testnet.trim()));
1162 Ok(())
1163 }
1164}