use std::fmt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
pub const DEFAULT_FILE: &str = "bsdt.toml";
pub const GUEST_USER: &str = "bsdt";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub vm: Vm,
#[serde(default)]
pub packages: Packages,
#[serde(default)]
pub sync: Sync,
#[serde(default)]
pub provision: Provision,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Vm {
pub os: Os,
pub version: String,
#[serde(default)]
pub arch: Arch,
#[serde(default = "default_cpus")]
pub cpus: u32,
#[serde(default = "default_memory")]
pub memory: String,
#[serde(default = "default_disk")]
pub disk: String,
#[serde(default)]
pub filesystem: Filesystem,
pub hostname: Option<String>,
#[serde(default)]
pub ports: Vec<Port>,
#[serde(default)]
pub update: bool,
}
fn default_cpus() -> u32 {
2
}
fn default_memory() -> String {
"2G".into()
}
fn default_disk() -> String {
"20G".into()
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Packages {
#[serde(default)]
pub install: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Sync {
pub dest: Option<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Provision {
#[serde(default)]
pub root: Vec<String>,
#[serde(default)]
pub run: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Os {
FreeBSD,
NetBSD,
OpenBSD,
}
impl fmt::Display for Os {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Os::FreeBSD => "freebsd",
Os::NetBSD => "netbsd",
Os::OpenBSD => "openbsd",
})
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Arch {
#[default]
Auto,
#[serde(alias = "x86_64")]
Amd64,
#[serde(alias = "arm64")]
Aarch64,
}
impl Arch {
pub fn host() -> Arch {
match std::env::consts::ARCH {
"aarch64" => Arch::Aarch64,
_ => Arch::Amd64,
}
}
pub fn resolve(self) -> Arch {
match self {
Arch::Auto => Arch::host(),
arch => arch,
}
}
}
impl fmt::Display for Arch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Arch::Auto => "auto",
Arch::Amd64 => "amd64",
Arch::Aarch64 => "aarch64",
})
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Filesystem {
#[default]
Ufs,
Zfs,
}
impl fmt::Display for Filesystem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Filesystem::Ufs => "ufs",
Filesystem::Zfs => "zfs",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(try_from = "String")]
pub struct Port {
pub host: u16,
pub guest: u16,
}
impl TryFrom<String> for Port {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
let parse = |p: &str| p.trim().parse::<u16>().ok().filter(|&n| n != 0);
let port = match s.split_once(':') {
Some((host, guest)) => parse(host).zip(parse(guest)),
None => parse(&s).map(|p| (p, p)),
};
match port {
Some((host, guest)) => Ok(Port { host, guest }),
None => Err(format!("invalid port {s:?}, expected \"HOST:GUEST\" or \"PORT\"")),
}
}
}
impl fmt::Display for Port {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "127.0.0.1:{} -> {}", self.host, self.guest)
}
}
pub struct Project {
pub dir: PathBuf,
pub file: PathBuf,
pub config: Config,
}
impl Project {
pub fn load(file: Option<&Path>) -> Result<Project> {
let file = match file {
Some(file) => file.to_path_buf(),
None => find(&std::env::current_dir()?)?,
};
let file = std::path::absolute(&file)?;
let text = std::fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let config: Config = toml::from_str(&text).with_context(|| format!("parsing {}", file.display()))?;
let dir = file.parent().context("environment file has no parent directory")?.to_path_buf();
Ok(Project { dir, file, config })
}
pub fn name(&self) -> String {
let raw = self.dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
sanitize_hostname(&raw)
}
pub fn hostname(&self) -> String {
match &self.config.vm.hostname {
Some(h) => h.clone(),
None => self.name(),
}
}
pub fn state_dir(&self) -> PathBuf {
let stem = self.file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "bsdt".into());
self.dir.join(".bsdt").join(stem)
}
pub fn dest(&self) -> String {
match &self.config.sync.dest {
Some(dest) => dest.trim_end_matches('/').to_string(),
None => format!("/home/{GUEST_USER}/{}", self.name()),
}
}
pub fn guest_cwd(&self) -> String {
let dest = self.dest();
let rel = std::env::current_dir().ok().and_then(|cwd| cwd.strip_prefix(&self.dir).ok().map(Path::to_path_buf));
match rel {
Some(rel) if !rel.as_os_str().is_empty() => format!("{dest}/{}", rel.to_string_lossy()),
_ => dest,
}
}
}
fn find(start: &Path) -> Result<PathBuf> {
for dir in start.ancestors() {
let candidate = dir.join(DEFAULT_FILE);
if candidate.is_file() {
return Ok(candidate);
}
}
bail!("no {DEFAULT_FILE} in {} or its parents (run `bsdt init` to create one)", start.display())
}
fn sanitize_hostname(raw: &str) -> String {
let mut name: String = raw
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect();
name.truncate(63);
let name = name.trim_matches('-');
if name.is_empty() { "bsdt".into() } else { name.into() }
}
pub const TEMPLATE: &str = r#"# bsdt environment; see bsdt(1).
[vm]
os = "freebsd"
version = "15.1"
# arch = "auto" # auto (match the host), amd64 or aarch64
# cpus = 2
# memory = "2G"
# disk = "20G"
# filesystem = "ufs" # ufs or zfs
# ports = ["8080:80"] # 127.0.0.1:HOST on the host -> GUEST in the VM
# update = false # install OS security updates on first boot
[packages]
install = ["git"]
[sync]
# dest = "/home/bsdt/<project directory>"
exclude = ["target"]
[provision]
# root = ["sysrc foo_enable=YES"]
# run = ["cargo fetch"]
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_parses() {
let config: Config = toml::from_str(TEMPLATE).unwrap();
assert_eq!(config.vm.os, Os::FreeBSD);
assert_eq!(config.vm.arch, Arch::Auto);
assert_eq!(config.vm.cpus, 2);
assert_eq!(config.packages.install, ["git"]);
}
#[test]
fn full_config_parses() {
let config: Config = toml::from_str(
r#"
[vm]
os = "freebsd"
version = "14.5"
arch = "arm64"
filesystem = "zfs"
ports = ["8080:80", "5432"]
[provision]
root = ["sysrc sshd_enable=YES"]
run = ["make"]
"#,
)
.unwrap();
assert_eq!(config.vm.arch, Arch::Aarch64);
assert_eq!(config.vm.filesystem, Filesystem::Zfs);
assert_eq!(config.vm.ports, [Port { host: 8080, guest: 80 }, Port { host: 5432, guest: 5432 }]);
assert_eq!(config.provision.run, ["make"]);
}
#[test]
fn rejects_unknown_keys_and_bad_ports() {
assert!(toml::from_str::<Config>("[vm]\nos = \"freebsd\"\nversion = \"15.1\"\nram = \"2G\"").is_err());
for bad in ["0", "x:80", "80:", "70000:80"] {
assert!(Port::try_from(bad.to_string()).is_err(), "{bad} should be rejected");
}
}
#[test]
fn hostnames_are_sanitized() {
assert_eq!(sanitize_hostname("My_Project.rs"), "my-project-rs");
assert_eq!(sanitize_hostname("..."), "bsdt");
}
}