use crate::state::{Framework, Park, State, Vhost};
use std::fs;
use std::path::Path;
const INDEX_MARKERS: [&str; 3] = ["index.php", "index.html", "index.htm"];
fn detect_framework(dir: &Path) -> Framework {
if dir.join("public/index.php").is_file() {
if dir.join("artisan").is_file() {
return Framework::Laravel;
}
if dir.join("bin/console").is_file() || dir.join("symfony.lock").is_file() {
return Framework::Symfony;
}
return Framework::Laravel;
}
if dir.join("web/index.php").is_file() {
return Framework::Drupal;
}
if dir.join("wp-config.php").is_file() || dir.join("wp-load.php").is_file() {
return Framework::Wordpress;
}
if dir.join("system/defines.php").is_file() && dir.join("user").is_dir() {
return Framework::Grav;
}
Framework::Generic
}
fn host_label(name: &str) -> Option<String> {
let mut out = String::with_capacity(name.len());
let mut prev_sep = true; for ch in name.chars() {
let c = ch.to_ascii_lowercase();
if c.is_ascii_alphanumeric() {
out.push(c);
prev_sep = false;
} else if c == '.' {
if !prev_sep {
out.push('.');
prev_sep = true;
}
} else if !prev_sep {
out.push('-');
prev_sep = true;
}
}
while out.ends_with('-') || out.ends_with('.') {
out.pop();
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn is_web_project(dir: &Path) -> bool {
if INDEX_MARKERS.iter().any(|m| dir.join(m).is_file()) {
return true;
}
for sub in ["public", "web"] {
if INDEX_MARKERS
.iter()
.any(|m| dir.join(sub).join(m).is_file())
{
return true;
}
}
detect_framework(dir) != Framework::Generic
}
pub fn expand(park: &Park) -> Vec<Vhost> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let Ok(entries) = fs::read_dir(&park.root) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
if !is_web_project(&path) {
continue;
}
let Some(label) = host_label(name) else {
continue;
};
let server_name = format!("{label}.{}", park.tld);
if !seen.insert(server_name.clone()) {
continue;
}
out.push(Vhost {
server_name,
server: park.server.clone(),
docroot: path.display().to_string(),
php_version: park.php_version.clone(),
ssl: park.ssl,
preset: detect_framework(&path),
proxy_target: None,
});
}
out.sort_by(|a, b| a.server_name.cmp(&b.server_name));
out
}
pub fn expand_all(state: &State) -> Vec<Vhost> {
let declared: std::collections::HashSet<&str> = state
.vhosts
.iter()
.map(|v| v.server_name.as_str())
.collect();
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for park in &state.parks {
for v in expand(park) {
if declared.contains(v.server_name.as_str()) {
continue;
}
if seen.insert(v.server_name.clone()) {
out.push(v);
}
}
}
out
}
pub fn effective_vhosts_for(state: &State, server: &str) -> Vec<Vhost> {
let mut out: Vec<Vhost> = state
.vhosts
.iter()
.filter(|v| v.server == server)
.cloned()
.collect();
for v in expand_all(state) {
if v.server == server {
out.push(v);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_label_sanitizes_and_skips() {
assert_eq!(
host_label("grav-helios 2").as_deref(),
Some("grav-helios-2")
);
assert_eq!(host_label("My_Project").as_deref(), Some("my-project"));
assert_eq!(host_label("admin.bak").as_deref(), Some("admin.bak"));
assert_eq!(host_label(" spaced ").as_deref(), Some("spaced"));
assert_eq!(host_label("café").as_deref(), Some("caf")); assert_eq!(host_label(" ").as_deref(), None); assert_eq!(host_label("...").as_deref(), None);
}
#[test]
fn expand_picks_web_projects_and_detects_laravel() {
let base = std::env::temp_dir().join(format!("reeve-park-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(base.join("blog")).unwrap();
fs::write(base.join("blog/index.php"), "<?php").unwrap();
fs::create_dir_all(base.join("shop/public")).unwrap();
fs::write(base.join("shop/public/index.php"), "<?php").unwrap();
fs::write(base.join("shop/artisan"), "").unwrap();
fs::create_dir_all(base.join("docs")).unwrap();
fs::create_dir_all(base.join(".hidden")).unwrap();
fs::write(base.join(".hidden/index.php"), "<?php").unwrap();
let park = Park {
root: base.display().to_string(),
server: "caddy".into(),
php_version: "8.3".into(),
tld: "test".into(),
ssl: true,
};
let sites = expand(&park);
let names: Vec<&str> = sites.iter().map(|v| v.server_name.as_str()).collect();
assert_eq!(names, vec!["blog.test", "shop.test"]);
let shop = sites.iter().find(|v| v.server_name == "shop.test").unwrap();
assert_eq!(shop.preset, Framework::Laravel);
assert_eq!(
shop.effective_docroot(),
format!("{}/shop/public", base.display())
);
assert!(shop.ssl);
let _ = fs::remove_dir_all(&base);
}
}