pub mod config;
pub mod import;
pub mod ts;
use crate::error::CliError;
use crate::store::{
self, Index, PROGRAM, Store, TenantFileRead, shown, unsafe_dir_error, unsafe_file_error,
};
use config::{ContentError, TenantConfig};
use std::ffi::OsString;
use std::path::PathBuf;
pub use crate::store::valid_tenant_name;
#[derive(Clone, Debug, Default)]
pub struct Environment {
pub config_dir: Option<OsString>,
pub xdg_config_home: Option<OsString>,
pub tenants_path: Option<OsString>,
pub xdg_state_home: Option<OsString>,
pub home: Option<PathBuf>,
pub cwd: Option<PathBuf>,
}
impl Environment {
pub fn capture() -> Self {
let non_empty = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty());
Self {
config_dir: non_empty("AIRS_CLI_CONFIG_DIR"),
xdg_config_home: non_empty("XDG_CONFIG_HOME"),
tenants_path: non_empty("PRISMA_AIRS_TENANTS_PATH"),
xdg_state_home: non_empty("XDG_STATE_HOME"),
home: std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(std::env::home_dir),
cwd: std::env::current_dir().ok(),
}
}
pub fn store(&self) -> Result<Store, CliError> {
Store::locate(
self.config_dir.as_deref(),
self.xdg_config_home.as_deref(),
self.home.as_deref(),
)
}
}
pub const NAME_RULE: &str = "Tenant name must be 1–64 letters, digits, hyphens or underscores and start with a letter/digit";
pub fn validate_tenant_flag(name: Option<&str>) -> Result<(), CliError> {
match name {
Some(name) if !valid_tenant_name(name) => Err(CliError::usage(NAME_RULE)),
_ => Ok(()),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Selected {
pub name: String,
pub tsg_id: String,
pub by_flag: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ConfigLoadError {
NotFound,
Unsafe(PathBuf),
Unreadable,
Content(ContentError),
TsgMismatch,
}
#[derive(Clone, Debug)]
pub struct StoreView {
pub store: Store,
pub index: Index,
pub ts_registry: Option<PathBuf>,
}
impl StoreView {
pub fn open(env: &Environment) -> Result<Self, CliError> {
let store = env.store()?;
let index = store.read_index()?;
let ts_registry = if index.exists {
None
} else {
ts::registry_exists(env)
};
Ok(Self {
store,
index,
ts_registry,
})
}
pub fn import_suffix(&self) -> String {
match &self.ts_registry {
Some(path) => format!(
"; a TypeScript tenant registry exists at {}: run '{PROGRAM} tenant import' to copy it",
shown(path)
),
None => String::new(),
}
}
fn selected(&self, name: &str, by_flag: bool) -> Option<Selected> {
self.index.tsg(name).map(|tsg| Selected {
name: name.to_owned(),
tsg_id: tsg.to_owned(),
by_flag,
})
}
pub fn select(&self, flag: Option<&str>) -> Result<Option<Selected>, CliError> {
if let Some(name) = flag {
return self
.selected(name, true)
.map(Some)
.ok_or_else(|| self.tenant_not_found(name));
}
Ok(self
.index
.active
.as_deref()
.and_then(|name| self.selected(name, false)))
}
pub fn tenant_not_found(&self, name: &str) -> CliError {
CliError::operational(format!(
"Tenant not found: {name} {}",
self.index.registered_suffix()
))
}
pub fn no_tenant_selected(&self) -> CliError {
if self.index.tenants.is_empty() {
CliError::operational(format!(
"No tenant selected. Run '{PROGRAM} tenant create <name>' and then '{PROGRAM} tenant switch <name>'{}",
self.import_suffix()
))
} else {
CliError::operational(format!(
"No tenant selected. Run '{PROGRAM} tenant switch <name>' (registered: {})",
self.index.names().join(", ")
))
}
}
pub fn resolve(&self, name: Option<&str>, flag: Option<&str>) -> Result<Selected, CliError> {
match name {
Some("") => {}
Some(name) => {
return self
.selected(name, false)
.ok_or_else(|| CliError::operational("Tenant not found"));
}
None => {
if let Some(selected) = self.select(flag)? {
return Ok(selected);
}
}
}
if self.index.tenants.is_empty() {
Err(CliError::operational(format!(
"No tenant selected; run '{PROGRAM} tenant create <name>' first{}",
self.import_suffix()
)))
} else {
Err(CliError::operational(format!(
"No tenant selected; pass a name or run '{PROGRAM} tenant switch <name>' (registered: {})",
self.index.names().join(", ")
)))
}
}
pub fn tenant_path(&self, name: &str) -> PathBuf {
self.store.tenant_path(name)
}
pub fn load_checked(&self, selected: &Selected) -> Result<TenantConfig, ConfigLoadError> {
self.load_raw(selected, true).map(|(config, _)| config)
}
fn load_raw(
&self,
selected: &Selected,
pin: bool,
) -> Result<(TenantConfig, Vec<u8>), ConfigLoadError> {
let bytes = self
.store
.read_tenant_bytes(&selected.name)
.map_err(|error| match error {
TenantFileRead::NotFound => ConfigLoadError::NotFound,
TenantFileRead::Unsafe(path) => ConfigLoadError::Unsafe(path),
TenantFileRead::Unreadable => ConfigLoadError::Unreadable,
})?;
let config = TenantConfig::parse(&bytes).map_err(ConfigLoadError::Content)?;
if pin && config.effective_str("mgmtTsgId") != Some(selected.tsg_id.as_str()) {
return Err(ConfigLoadError::TsgMismatch);
}
Ok((config, bytes))
}
pub fn load_config(&self, selected: &Selected) -> Result<TenantConfig, CliError> {
self.load_checked(selected)
.map_err(|error| self.load_error(selected, error))
}
pub fn load_for_write(
&self,
selected: &Selected,
pin: bool,
) -> Result<(TenantConfig, Vec<u8>), CliError> {
self.load_raw(selected, pin)
.map_err(|error| self.load_error(selected, error))
}
fn load_error(&self, selected: &Selected, error: ConfigLoadError) -> CliError {
match error {
ConfigLoadError::TsgMismatch => CliError::operational(
"Tenant config TSG differs from its registered identity; register it under a new name",
),
ConfigLoadError::Unsafe(path) if path == self.store.tenants_dir() => {
unsafe_dir_error(&path)
}
ConfigLoadError::Unsafe(path) => unsafe_file_error(&path),
ConfigLoadError::NotFound
| ConfigLoadError::Unreadable
| ConfigLoadError::Content(_) => {
store::tenant_file_error(&self.tenant_path(&selected.name))
}
}
}
}
#[derive(Clone, Debug)]
pub struct TenantContext {
pub selected: Selected,
pub config: TenantConfig,
}
impl TenantContext {
pub fn name(&self) -> &str {
&self.selected.name
}
}
pub fn load_selected(env: &Environment, flag: Option<&str>) -> Result<TenantContext, CliError> {
let view = StoreView::open(env)?;
let selected = view
.select(flag)?
.ok_or_else(|| view.no_tenant_selected())?;
let config = view.load_config(&selected)?;
Ok(TenantContext { selected, config })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::testdir::TestDir;
fn env(dir: &TestDir) -> Environment {
Environment {
config_dir: Some(dir.path().join("D").into_os_string()),
tenants_path: Some(dir.path().join("ts/tenants.json").into_os_string()),
home: Some(PathBuf::from("/nonexistent-home")),
cwd: Some(dir.path().to_path_buf()),
..Environment::default()
}
}
const INDEX: &[u8] =
b"version = 1\n\n[tenants.bad]\ntsg_id = \"100\"\n\n[tenants.dev]\ntsg_id = \"100\"\n";
#[test]
fn tenant_flag_names_are_usage_checked() {
assert!(validate_tenant_flag(Some("-bad")).is_err());
assert!(validate_tenant_flag(Some("ok")).is_ok());
assert!(validate_tenant_flag(None).is_ok());
}
#[test]
fn selection_and_messages() {
let dir = TestDir::new("tenant-select");
dir.write("D/config.toml", INDEX, 0o600);
dir.write(
"D/tenants/dev.toml",
b"mgmtTsgId = \"100\"\nmgmtClientId = \"c\"\nmgmtClientSecret = \"s\"\n",
0o600,
);
dir.write("D/tenants/bad.toml", b"mgmtTsgId = \"200\"\n", 0o600);
let view = StoreView::open(&env(&dir)).unwrap();
assert_eq!(view.import_suffix(), "");
assert_eq!(
view.no_tenant_selected().message(),
"No tenant selected. Run 'airs-cli-rs tenant switch <name>' (registered: bad, dev)"
);
assert_eq!(view.select(None).unwrap(), None);
let selected = view.select(Some("dev")).unwrap().unwrap();
assert!(selected.by_flag);
assert_eq!(selected.tsg_id, "100");
assert_eq!(
view.select(Some("ghost")).unwrap_err().message(),
"Tenant not found: ghost (registered: bad, dev)"
);
assert!(view.load_config(&selected).is_ok());
let bad = view.resolve(Some("bad"), None).unwrap();
assert_eq!(
view.load_config(&bad).unwrap_err().message(),
"Tenant config TSG differs from its registered identity; register it under a new name"
);
assert_eq!(
view.resolve(Some("Dev"), None).unwrap_err().message(),
"Tenant not found",
"exact lookups"
);
assert_eq!(
view.resolve(Some(""), Some("dev")).unwrap_err().message(),
"No tenant selected; pass a name or run 'airs-cli-rs tenant switch <name>' (registered: bad, dev)"
);
assert_eq!(view.resolve(None, Some("dev")).unwrap().name, "dev");
let context = load_selected(&env(&dir), Some("dev")).unwrap();
assert_eq!(context.name(), "dev");
assert!(load_selected(&env(&dir), None).is_err());
}
#[test]
fn an_empty_store_and_the_import_suffix() {
let dir = TestDir::new("tenant-empty");
let view = StoreView::open(&env(&dir)).unwrap();
assert!(!view.index.exists);
assert_eq!(
view.no_tenant_selected().message(),
"No tenant selected. Run 'airs-cli-rs tenant create <name>' and then 'airs-cli-rs tenant switch <name>'"
);
assert_eq!(
view.select(Some("ghost")).unwrap_err().message(),
"Tenant not found: ghost (no tenants registered)"
);
let registry = dir.write("ts/tenants.json", b"{}", 0o600);
let view = StoreView::open(&env(&dir)).unwrap();
let suffix = format!(
"; a TypeScript tenant registry exists at {}: run 'airs-cli-rs tenant import' to copy it",
registry.display()
);
assert_eq!(
view.no_tenant_selected().message(),
format!(
"No tenant selected. Run 'airs-cli-rs tenant create <name>' and then 'airs-cli-rs tenant switch <name>'{suffix}"
)
);
assert_eq!(
view.resolve(None, None).unwrap_err().message(),
format!("No tenant selected; run 'airs-cli-rs tenant create <name>' first{suffix}")
);
dir.write("D/config.toml", b"version = 1\n", 0o600);
let view = StoreView::open(&env(&dir)).unwrap();
assert_eq!(view.import_suffix(), "");
}
#[test]
fn tenant_file_failures_collapse_for_commands() {
let dir = TestDir::new("tenant-files");
dir.write("D/config.toml", INDEX, 0o600);
let view = StoreView::open(&env(&dir)).unwrap();
let dev = view.resolve(Some("dev"), None).unwrap();
let path = dir.path().join("D/tenants/dev.toml");
let generic = format!("Cannot read a valid tenant config at {}", path.display());
assert_eq!(view.load_checked(&dev), Err(ConfigLoadError::NotFound));
assert_eq!(view.load_config(&dev).unwrap_err().message(), generic);
for bytes in [
&b"mgmtTsgId = \"100\"\naiGwDataEndpoint = \"x\"\n"[..],
b"mgmtTsgId = \"100\"\ndefaultOutput = \"markdown\"\n",
b"mgmtTsgId = \"100\"\nmgmtClientSecret = \"SUPER-SECRET\"\nnope",
] {
dir.write("D/tenants/dev.toml", bytes, 0o600);
let message = view.load_config(&dev).unwrap_err().message();
assert_eq!(message, generic);
assert!(!message.contains("SUPER-SECRET"));
}
dir.write("D/tenants/dev.toml", b"mgmtTsgId = \"100\"\n", 0o640);
assert!(
view.load_config(&dev)
.unwrap_err()
.message()
.starts_with(&format!(
"Refusing to use {}: it must be a regular file",
path.display()
))
);
dir.write("D/tenants/dev.toml", b"mgmtTsgId = \"100\"\n", 0o600);
assert!(view.load_config(&dev).is_ok());
dir.write("D/tenants/dev.toml", b"", 0o600);
assert_eq!(
view.load_checked(&dev),
Err(ConfigLoadError::TsgMismatch),
"a file without mgmtTsgId mismatches"
);
}
}