use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
const CONFIG_DIR_NAME: &str = "netsky";
#[cfg(test)]
const OWNER_FILE_NAME: &str = "owner.toml";
const ACTIVE_HOST_FILE_NAME: &str = "active-host";
const ADDENDUM_FILE_NAME: &str = "addendum.md";
const HOST_ADDENDUM_PREFIX: &str = "addendum.";
const HOST_ADDENDUM_SUFFIX: &str = ".md";
const ENV_MACHINE_TYPE: &str = "MACHINE_TYPE";
const MAX_HOST_LABEL_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub addendum: Addendum,
}
impl Config {
pub fn load() -> Result<Self> {
let host_label = active_host_label()?;
let addendum = Addendum::load(host_label.as_deref())?;
Ok(Self { addendum })
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Addendum {
pub base_path: PathBuf,
pub base: Option<String>,
pub host_label: Option<String>,
pub host_path: Option<PathBuf>,
pub host: Option<String>,
}
impl Addendum {
pub fn load(host_label: Option<&str>) -> Result<Self> {
let base_path = addendum_path();
let host_path = host_label.map(host_addendum_path);
let base = read_optional_string(&base_path)?;
let host = match host_path.as_ref() {
Some(path) => read_optional_string(path)?,
None => None,
};
Ok(Self {
base_path,
base,
host_label: host_label.map(ToOwned::to_owned),
host_path,
host,
})
}
}
pub fn config_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.trim().is_empty()
{
return PathBuf::from(xdg).join(CONFIG_DIR_NAME);
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".config")
.join(CONFIG_DIR_NAME)
}
#[cfg(test)]
pub fn owner_path() -> PathBuf {
config_dir().join(OWNER_FILE_NAME)
}
pub fn active_host_path() -> PathBuf {
config_dir().join(ACTIVE_HOST_FILE_NAME)
}
pub fn addendum_path() -> PathBuf {
config_dir().join(ADDENDUM_FILE_NAME)
}
pub fn host_addendum_path(label: &str) -> PathBuf {
config_dir().join(format!(
"{HOST_ADDENDUM_PREFIX}{label}{HOST_ADDENDUM_SUFFIX}"
))
}
pub fn active_host_label() -> Result<Option<String>> {
if let Ok(label) = std::env::var(ENV_MACHINE_TYPE)
&& !label.trim().is_empty()
{
return Ok(Some(validate_host_label(&label)?.to_string()));
}
let Some(label) = read_optional_string(&active_host_path())? else {
return Ok(None);
};
if label.trim().is_empty() {
return Ok(None);
}
Ok(Some(validate_host_label(&label)?.to_string()))
}
fn validate_host_label(label: &str) -> Result<&str> {
let trimmed = label.trim();
if trimmed.is_empty() {
anyhow::bail!("machine label cannot be empty");
}
if trimmed.len() > MAX_HOST_LABEL_LEN {
anyhow::bail!("machine label must be 64 characters or fewer");
}
if trimmed.starts_with('.') {
anyhow::bail!("machine label cannot start with '.'");
}
if trimmed.contains("..") {
anyhow::bail!("machine label cannot contain '..'");
}
if trimmed
.chars()
.any(|ch| std::path::is_separator(ch) || ch == '\\')
{
anyhow::bail!("machine label cannot contain path separators");
}
Ok(trimmed)
}
fn read_optional_string(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(value) => Ok(Some(value)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("read {}", path.display())),
}
}