netsky-prompts 0.2.0

netsky prompts: prompt rendering and skill material
Documentation
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())),
    }
}