use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item};
use crate::error::{CliError, CliResult};
const NESTRS_WORKSPACE_MARKERS: &[&str] = &["crates/*", "apps/*"];
pub const DEFAULT_PORT_BASE: u16 = 3000;
pub const DEFAULT_ENV_PREFIX: &str = "NESTRS";
#[derive(Debug, Clone)]
pub struct NestrsWorkspace {
pub root: PathBuf,
pub metadata: Metadata,
}
#[derive(Debug, Clone)]
pub struct Metadata {
pub port_base: u16,
pub env_prefix: String,
}
impl Default for Metadata {
fn default() -> Self {
Self {
port_base: DEFAULT_PORT_BASE,
env_prefix: DEFAULT_ENV_PREFIX.to_owned(),
}
}
}
impl NestrsWorkspace {
pub fn discover(start: &Path) -> CliResult<Option<Self>> {
let mut dir = start.canonicalize().map_err(CliError::Io)?;
loop {
if let Some(ws) = read_workspace(&dir)? {
return Ok(Some(ws));
}
if !dir.pop() {
return Ok(None);
}
}
}
pub fn require(start: &Path) -> CliResult<Self> {
Self::discover(start)?.ok_or(CliError::NotNestrsWorkspace)
}
pub fn features_root(&self) -> PathBuf {
self.root.join("crates/features/src")
}
pub fn features_lib(&self) -> PathBuf {
self.root.join("crates/features/src/lib.rs")
}
pub fn features_cargo(&self) -> PathBuf {
self.root.join("crates/features/Cargo.toml")
}
pub fn apps_root(&self) -> PathBuf {
self.root.join("apps")
}
pub fn migrations_root(&self) -> PathBuf {
self.root.join("crates/migrations/src")
}
pub fn migrations_lib(&self) -> PathBuf {
self.migrations_root().join("lib.rs")
}
pub fn migrations_migrator(&self) -> PathBuf {
self.migrations_root().join("migrator.rs")
}
pub fn feature_root(&self, snake: &str) -> PathBuf {
self.features_root().join(snake)
}
pub fn feature_exists(&self, snake: &str) -> bool {
self.feature_root(snake).is_dir()
}
}
fn read_workspace(dir: &Path) -> CliResult<Option<NestrsWorkspace>> {
let manifest = dir.join("Cargo.toml");
if !manifest.is_file() {
return Ok(None);
}
let source = std::fs::read_to_string(&manifest).map_err(CliError::Io)?;
let doc = source
.parse::<DocumentMut>()
.map_err(|e| CliError::Anyhow(e.into()))?;
let Some(workspace) = doc.get("workspace").and_then(Item::as_table) else {
return Ok(None);
};
let Some(members) = workspace.get("members").and_then(Item::as_array) else {
return Ok(None);
};
let member_strings: Vec<String> = members
.iter()
.filter_map(|value| value.as_str().map(str::to_owned))
.collect();
let is_nestrs = NESTRS_WORKSPACE_MARKERS
.iter()
.all(|marker| member_strings.iter().any(|member| member == marker));
if !is_nestrs {
return Ok(None);
}
let metadata = read_metadata(workspace);
Ok(Some(NestrsWorkspace {
root: dir.to_path_buf(),
metadata,
}))
}
fn read_metadata(container: &toml_edit::Table) -> Metadata {
let mut meta = Metadata::default();
let Some(table) = container
.get("metadata")
.and_then(Item::as_table)
.and_then(|m| m.get("nestrs"))
.and_then(Item::as_table)
else {
return meta;
};
if let Some(port) = table.get("port-base").and_then(|v| v.as_integer())
&& let Ok(port) = u16::try_from(port)
{
meta.port_base = port;
}
if let Some(prefix) = table.get("env-prefix").and_then(|v| v.as_str())
&& validate_env_prefix(prefix).is_ok()
{
meta.env_prefix = prefix.to_owned();
}
meta
}
pub fn package_env_prefix(dir: &Path) -> String {
let Ok(source) = std::fs::read_to_string(dir.join("Cargo.toml")) else {
return DEFAULT_ENV_PREFIX.to_owned();
};
let Ok(doc) = source.parse::<DocumentMut>() else {
return DEFAULT_ENV_PREFIX.to_owned();
};
doc.get("package")
.and_then(Item::as_table)
.map(read_metadata)
.unwrap_or_default()
.env_prefix
}
pub fn var_name(env_prefix: &str, namespace: &str, key: &str) -> String {
format!("{env_prefix}_{namespace}__{key}")
}
pub fn validate_env_prefix(prefix: &str) -> Result<(), String> {
let valid = !prefix.is_empty()
&& prefix.starts_with(|c: char| c.is_ascii_uppercase())
&& !prefix.ends_with('_')
&& prefix
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
if valid {
Ok(())
} else {
Err(format!(
"`{prefix}` is not a usable env prefix: use uppercase ASCII letters, digits and \
underscores, starting with a letter and not ending in `_` (e.g. `ACME`, which \
yields ACME_ENV and ACME_DATABASE__URL)"
))
}
}