use crate::paths;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::fs;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
Caddy,
Apache,
Nginx,
Ols,
}
impl Backend {
pub fn as_str(&self) -> &'static str {
match self {
Backend::Caddy => "caddy",
Backend::Apache => "apache",
Backend::Nginx => "nginx",
Backend::Ols => "ols",
}
}
}
impl fmt::Display for Backend {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.as_str())
}
}
impl FromStr for Backend {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"caddy" => Ok(Backend::Caddy),
"apache" | "httpd" => Ok(Backend::Apache),
"nginx" => Ok(Backend::Nginx),
"ols" | "openlitespeed" | "litespeed" => Ok(Backend::Ols),
other => bail!("Unknown backend '{other}'. Use caddy|apache|nginx|ols."),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Server {
pub name: String,
pub backend: Backend,
pub http_port: u16,
pub https_port: u16,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub default_site: bool,
#[serde(default)]
pub default_preset: Framework,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_root: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub settings: std::collections::BTreeMap<String, String>,
}
impl Server {
pub fn setting<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
self.settings
.get(key)
.map(|s| s.as_str())
.unwrap_or(default)
}
pub fn effective_default_root<'a>(&'a self, sites_root: &'a str) -> &'a str {
self.default_root
.as_deref()
.unwrap_or(sites_root)
.trim_end_matches('/')
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum XdebugMode {
#[default]
Off,
Debug,
Profile,
}
impl XdebugMode {
pub fn as_str(&self) -> &'static str {
match self {
XdebugMode::Off => "off",
XdebugMode::Debug => "debug",
XdebugMode::Profile => "profile",
}
}
pub fn is_off(&self) -> bool {
matches!(self, XdebugMode::Off)
}
pub fn next(&self) -> Self {
match self {
XdebugMode::Off => XdebugMode::Debug,
XdebugMode::Debug => XdebugMode::Profile,
XdebugMode::Profile => XdebugMode::Off,
}
}
}
impl FromStr for XdebugMode {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"off" | "0" | "false" => Ok(XdebugMode::Off),
"debug" | "on" => Ok(XdebugMode::Debug),
"profile" => Ok(XdebugMode::Profile),
other => bail!("Unknown Xdebug mode '{other}'. Use off|debug|profile."),
}
}
}
fn default_xdebug_port() -> u16 {
9003
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhpVersion {
pub version: String,
pub fpm_socket: String,
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub settings: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "XdebugMode::is_off")]
pub xdebug: XdebugMode,
#[serde(default = "default_xdebug_port")]
pub xdebug_port: u16,
}
impl Default for PhpVersion {
fn default() -> Self {
Self {
version: String::new(),
fpm_socket: String::new(),
extensions: Vec::new(),
settings: std::collections::BTreeMap::new(),
xdebug: XdebugMode::Off,
xdebug_port: default_xdebug_port(),
}
}
}
impl PhpVersion {
pub fn setting<'a>(&'a self, key: &str, default: &'a str) -> &'a str {
self.settings
.get(key)
.map(|s| s.as_str())
.unwrap_or(default)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Framework {
#[default]
Generic,
Laravel,
Wordpress,
Symfony,
Grav,
Drupal,
}
impl Framework {
pub fn as_str(&self) -> &'static str {
match self {
Framework::Generic => "generic",
Framework::Laravel => "laravel",
Framework::Wordpress => "wordpress",
Framework::Symfony => "symfony",
Framework::Grav => "grav",
Framework::Drupal => "drupal",
}
}
pub fn is_generic(&self) -> bool {
matches!(self, Framework::Generic)
}
pub fn all() -> [Framework; 6] {
[
Framework::Generic,
Framework::Laravel,
Framework::Wordpress,
Framework::Symfony,
Framework::Grav,
Framework::Drupal,
]
}
pub fn public_subdir(&self) -> &'static str {
match self {
Framework::Laravel | Framework::Symfony => "public",
Framework::Drupal => "web",
_ => "",
}
}
}
impl fmt::Display for Framework {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.as_str())
}
}
impl FromStr for Framework {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"generic" | "" | "none" => Ok(Framework::Generic),
"laravel" => Ok(Framework::Laravel),
"wordpress" | "wp" => Ok(Framework::Wordpress),
"symfony" => Ok(Framework::Symfony),
"grav" => Ok(Framework::Grav),
"drupal" => Ok(Framework::Drupal),
other => bail!(
"Unknown preset '{other}'. Use \
generic|laravel|wordpress|symfony|grav|drupal."
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vhost {
pub server_name: String,
pub server: String,
pub docroot: String,
pub php_version: String,
#[serde(default)]
pub ssl: bool,
#[serde(default, skip_serializing_if = "Framework::is_generic")]
pub preset: Framework,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proxy_target: Option<String>,
}
impl Vhost {
pub fn effective_docroot(&self) -> String {
let sub = self.preset.public_subdir();
if sub.is_empty() {
self.docroot.clone()
} else {
format!("{}/{}", self.docroot.trim_end_matches('/'), sub)
}
}
pub fn is_proxy(&self) -> bool {
self.proxy_target.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceKind {
Mysql,
Mariadb,
Postgres,
Redis,
Memcached,
Mailpit,
}
impl ServiceKind {
pub fn as_str(&self) -> &'static str {
match self {
ServiceKind::Mysql => "mysql",
ServiceKind::Mariadb => "mariadb",
ServiceKind::Postgres => "postgres",
ServiceKind::Redis => "redis",
ServiceKind::Memcached => "memcached",
ServiceKind::Mailpit => "mailpit",
}
}
pub fn all() -> [ServiceKind; 6] {
[
ServiceKind::Mysql,
ServiceKind::Mariadb,
ServiceKind::Postgres,
ServiceKind::Redis,
ServiceKind::Memcached,
ServiceKind::Mailpit,
]
}
}
impl fmt::Display for ServiceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.pad(self.as_str())
}
}
impl FromStr for ServiceKind {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"mysql" => Ok(ServiceKind::Mysql),
"mariadb" => Ok(ServiceKind::Mariadb),
"postgres" | "postgresql" | "pg" => Ok(ServiceKind::Postgres),
"redis" => Ok(ServiceKind::Redis),
"memcached" | "memcache" => Ok(ServiceKind::Memcached),
"mailpit" => Ok(ServiceKind::Mailpit),
other => bail!(
"Unknown service '{other}'. Use \
mysql|mariadb|postgres|redis|memcached|mailpit."
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManagedServiceInstance {
pub kind: ServiceKind,
#[serde(default)]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Park {
pub root: String,
pub server: String,
pub php_version: String,
pub tld: String,
#[serde(default)]
pub ssl: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct State {
#[serde(default)]
pub servers: Vec<Server>,
#[serde(default)]
pub php_versions: Vec<PhpVersion>,
#[serde(default)]
pub vhosts: Vec<Vhost>,
#[serde(default)]
pub services: Vec<ManagedServiceInstance>,
#[serde(default)]
pub parks: Vec<Park>,
}
pub fn version_key(v: &str) -> (u32, u32) {
let mut it = v.split('.');
let major = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let minor = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
(major, minor)
}
impl State {
pub fn get_server(&self, name: &str) -> Option<&Server> {
self.servers.iter().find(|s| s.name == name)
}
pub fn get_service(&self, kind: ServiceKind) -> Option<&ManagedServiceInstance> {
self.services.iter().find(|s| s.kind == kind)
}
pub fn get_php(&self, version: &str) -> Option<&PhpVersion> {
self.php_versions.iter().find(|p| p.version == version)
}
pub fn sort_php(&mut self) {
self.php_versions.sort_by_key(|p| version_key(&p.version));
}
pub fn add_server(&mut self, server: Server) -> Result<()> {
if self.servers.iter().any(|s| s.name == server.name) {
bail!("Server '{}' already exists", server.name);
}
for existing in &self.servers {
if existing.http_port == server.http_port || existing.https_port == server.https_port {
bail!(
"Port conflict with server '{}' ({}/{})",
existing.name,
existing.http_port,
existing.https_port
);
}
}
self.servers.push(server);
Ok(())
}
pub fn add_vhost(&mut self, vhost: Vhost) -> Result<()> {
if self
.vhosts
.iter()
.any(|v| v.server_name == vhost.server_name)
{
bail!("Vhost '{}' already exists", vhost.server_name);
}
if self.get_server(&vhost.server).is_none() {
bail!("Server '{}' does not exist", vhost.server);
}
if !vhost.is_proxy() && self.get_php(&vhost.php_version).is_none() {
bail!(
"PHP {} is not installed. Run `reeve php install {}`.",
vhost.php_version,
vhost.php_version
);
}
self.vhosts.push(vhost);
Ok(())
}
}
pub fn load_state() -> Result<State> {
let path = paths::state_path()?;
if !path.exists() {
return Ok(State::default());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read state from {}", path.display()))?;
toml::from_str(&contents).context("Invalid state.toml")
}
pub fn save_state(state: &State) -> Result<()> {
paths::ensure_dirs()?;
let path = paths::state_path()?;
let contents = toml::to_string_pretty(state).context("Failed to serialize state")?;
fs::write(&path, contents)
.with_context(|| format!("Failed to write state to {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn server(name: &str, http: u16, https: u16) -> Server {
Server {
name: name.into(),
backend: Backend::Caddy,
http_port: http,
https_port: https,
enabled: false,
default_site: false,
default_preset: Framework::Generic,
default_root: None,
settings: Default::default(),
}
}
#[test]
fn default_root_overrides_else_falls_back_to_sites_root() {
let mut s = server("a", 80, 443);
assert_eq!(s.effective_default_root("/Sites"), "/Sites");
assert_eq!(s.effective_default_root("/Sites/"), "/Sites");
s.default_root = Some("/var/www/".into());
assert_eq!(s.effective_default_root("/Sites"), "/var/www");
}
#[test]
fn backend_parse_roundtrip() {
for s in ["caddy", "apache", "nginx", "ols"] {
assert_eq!(s.parse::<Backend>().unwrap().as_str(), s);
}
assert_eq!("httpd".parse::<Backend>().unwrap(), Backend::Apache);
assert_eq!("openlitespeed".parse::<Backend>().unwrap(), Backend::Ols);
assert!("bogus".parse::<Backend>().is_err());
}
#[test]
fn service_kind_parse_roundtrip() {
for k in ServiceKind::all() {
assert_eq!(k.as_str().parse::<ServiceKind>().unwrap(), k);
}
assert_eq!(
"postgresql".parse::<ServiceKind>().unwrap(),
ServiceKind::Postgres
);
assert!("bogus".parse::<ServiceKind>().is_err());
}
#[test]
fn services_survive_toml_roundtrip() {
let mut s = State::default();
s.services.push(ManagedServiceInstance {
kind: ServiceKind::Mailpit,
enabled: true,
});
let toml = toml::to_string_pretty(&s).unwrap();
let back: State = toml::from_str(&toml).unwrap();
assert_eq!(back.services.len(), 1);
assert_eq!(back.services[0].kind, ServiceKind::Mailpit);
assert!(back.services[0].enabled);
}
#[test]
fn state_toml_roundtrip() {
let mut s = State::default();
s.servers.push(server("caddy", 80, 443));
s.php_versions.push(PhpVersion {
version: "8.3".into(),
fpm_socket: "/run/php83.sock".into(),
..Default::default()
});
s.vhosts.push(Vhost {
server_name: "a.test".into(),
server: "caddy".into(),
docroot: "/Sites/a".into(),
php_version: "8.3".into(),
ssl: true,
preset: Framework::Laravel,
proxy_target: None,
});
let toml = toml::to_string_pretty(&s).unwrap();
let back: State = toml::from_str(&toml).unwrap();
assert_eq!(back.servers.len(), 1);
assert_eq!(back.vhosts[0].server_name, "a.test");
assert!(back.vhosts[0].ssl);
assert_eq!(back.vhosts[0].preset, Framework::Laravel);
assert_eq!(back.vhosts[0].effective_docroot(), "/Sites/a/public");
}
#[test]
fn proxy_vhost_skips_php_requirement() {
let mut s = State::default();
s.add_server(server("caddy", 80, 443)).unwrap();
let v = Vhost {
server_name: "vite.test".into(),
server: "caddy".into(),
docroot: String::new(),
php_version: String::new(),
ssl: false,
preset: Framework::Generic,
proxy_target: Some("http://localhost:5173".into()),
};
s.add_vhost(v).unwrap();
assert!(s.vhosts[0].is_proxy());
}
#[test]
fn add_server_rejects_dup_and_port_conflict() {
let mut s = State::default();
s.add_server(server("caddy", 80, 443)).unwrap();
assert!(s.add_server(server("caddy", 8080, 8443)).is_err()); assert!(s.add_server(server("nginx", 80, 9443)).is_err()); s.add_server(server("nginx", 8080, 8443)).unwrap(); assert_eq!(s.servers.len(), 2);
}
#[test]
fn add_vhost_requires_server_and_php() {
let mut s = State::default();
let v = Vhost {
server_name: "a.test".into(),
server: "caddy".into(),
docroot: "/Sites/a".into(),
php_version: "8.3".into(),
ssl: false,
preset: Framework::Generic,
proxy_target: None,
};
assert!(s.add_vhost(v.clone()).is_err()); s.add_server(server("caddy", 80, 443)).unwrap();
assert!(s.add_vhost(v.clone()).is_err()); s.php_versions.push(PhpVersion {
version: "8.3".into(),
fpm_socket: "/run/php83.sock".into(),
..Default::default()
});
s.add_vhost(v).unwrap();
assert_eq!(s.vhosts.len(), 1);
}
}