use std::net::{TcpStream, ToSocketAddrs};
use std::path::Path;
use std::time::Duration;
use anyhow::{Result, bail};
use crate::apps::Env;
use crate::commands::build;
use crate::{project, runtime};
#[derive(Default)]
struct Report {
problems: usize,
}
impl Report {
fn ok(&self, what: &str, detail: &str) {
println!(" ✓ {what:<22} {detail}");
}
fn fail(&mut self, what: &str, detail: &str) {
self.problems += 1;
println!(" ✗ {what:<22} {detail}");
}
fn note(&self, what: &str, detail: &str) {
println!(" ! {what:<22} {detail}");
}
}
pub fn run() -> Result<()> {
let root = project::find_root()?;
let mut report = Report::default();
println!("\n{}\n", project::package_name(&root)?);
if runtime::is_present(&root) {
report.ok("runtime", "runtime/ serves the backend and the apps");
} else {
report.fail(
"runtime",
"runtime/ is missing (projects before slave 0.9); copy it from a new project",
);
}
if root.join(".env").is_file() {
report.ok(".env", "found");
} else {
report.fail(".env", "missing; copy .env.example to .env");
}
let env = Env::load(&root)?;
println!("\nDatabase");
check_database(&mut report, &env);
let migrations = std::fs::read_dir(root.join("database/migrations"))
.map(|dir| dir.flatten().count())
.unwrap_or(0);
report.note(
"migrations",
&format!("{migrations} in database/migrations; apply them with `slave migrate`"),
);
println!("\nSign-in (email codes and passkeys)");
match env.get("SMTP_URL").filter(|url| !url.trim().is_empty()) {
Some(_)
if env
.get("MAIL_FROM")
.is_none_or(|from| from.trim().is_empty()) =>
{
report.fail("MAIL_FROM", "empty; set the sender of sign-in codes")
}
Some(_) => report.ok("SMTP_URL", "set; sign-in codes are emailed"),
None => report.note(
"SMTP_URL",
"empty; debug builds log sign-in codes instead of emailing them",
),
}
let rp_id = env
.get("PASSKEY_RP_ID")
.filter(|id| !id.trim().is_empty())
.unwrap_or_else(|| "localhost".to_owned());
report.ok("PASSKEY_RP_ID", &format!("{rp_id} (and its subdomains)"));
println!("\nBuild");
check_build(&mut report, &root);
println!();
if report.problems == 0 {
println!("All set: run `slave serve`.");
Ok(())
} else {
bail!("{} problem(s) found", report.problems)
}
}
fn check_database(report: &mut Report, env: &Env) {
let url = env
.get("SURREAL_URL")
.filter(|url| !url.trim().is_empty())
.unwrap_or_else(|| "ws://127.0.0.1:8000".to_owned());
let Some(address) = surreal_address(&url) else {
report.fail(
"SURREAL_URL",
&format!("`{url}` is not a ws:// or wss:// URL (e.g. ws://127.0.0.1:8000)"),
);
return;
};
let namespace = env.get("SURREAL_NS").unwrap_or_else(|| "main".to_owned());
let database = env.get("SURREAL_DB").unwrap_or_else(|| "main".to_owned());
let shown = format!("{url} {namespace}/{database}");
if reachable(&address) {
report.ok("SURREAL_URL", &format!("{shown} (reachable)"));
} else {
report.fail(
"SURREAL_URL",
&format!(
"{shown}: cannot reach {address}; start SurrealDB with \
`surreal start --user root --pass root`"
),
);
}
match (env.get("SURREAL_USER"), env.get("SURREAL_PASS")) {
(Some(user), Some(_)) if !user.trim().is_empty() => {
let level = env
.get("SURREAL_AUTH_LEVEL")
.unwrap_or_else(|| "root".to_owned());
report.ok("SURREAL_USER", &format!("{user} ({level} user)"));
}
(Some(user), None) if !user.trim().is_empty() => {
report.fail("SURREAL_PASS", "empty; SURREAL_USER needs a password");
}
_ => report.note(
"SURREAL_USER",
"empty; connecting without signing in (only for `surreal start --unauthenticated`)",
),
}
}
fn check_build(report: &mut Report, root: &Path) {
let Ok(target) = build::target_dir(root) else {
report.note("build", "could not ask Cargo for the target directory");
return;
};
let runtime =
target
.join("debug")
.join(format!("{}{}", runtime::DIR, std::env::consts::EXE_SUFFIX));
if runtime.is_file() {
report.ok("runtime binary", "built (debug)");
} else {
report.fail("runtime binary", "not built yet; run `slave build`");
}
}
fn surreal_address(url: &str) -> Option<String> {
let (scheme, rest) = url.split_once("://")?;
let default_port = match scheme {
"ws" | "http" => 80,
"wss" | "https" => 443,
_ => return None,
};
let host_port = rest.split(['/', '?']).next()?;
if host_port.is_empty() {
return None;
}
Some(if host_port.contains(':') && !host_port.ends_with(']') {
host_port.to_owned()
} else {
format!("{host_port}:{default_port}")
})
}
fn reachable(address: &str) -> bool {
address
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.next())
.is_some_and(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(3)).is_ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_database_urls() {
assert_eq!(
surreal_address("ws://127.0.0.1:8000").as_deref(),
Some("127.0.0.1:8000")
);
assert_eq!(
surreal_address("wss://demo.aws-use1.surreal.cloud").as_deref(),
Some("demo.aws-use1.surreal.cloud:443")
);
assert_eq!(surreal_address("postgresql://x"), None);
}
}