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