use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet, hash_map};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub project: Project,
pub profiles: HashMap<String, Profile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<String, String>>,
}
impl Config {
pub fn validate(&self) -> Result<(), ParseError> {
if self.project.name.is_empty() {
return Err(ParseError::Validation(
"Project name cannot be empty".into(),
));
}
if self.profiles.is_empty() {
return Err(ParseError::Validation(
"At least one profile must be defined".into(),
));
}
for (profile_name, profile) in &self.profiles {
profile.validate().map_err(|e| {
ParseError::Validation(format!("Profile '{}': {}", profile_name, e))
})?;
}
Ok(())
}
pub fn get_profile(&self, name: &str) -> Option<&Profile> {
self.profiles.get(name)
}
pub fn get_profile_mut(&mut self, name: &str) -> Option<&mut Profile> {
self.profiles.get_mut(name)
}
pub fn merge_with(&mut self, other: Config) {
if self.project.require_reason.is_none() {
self.project.require_reason = other.project.require_reason;
}
for (profile_name, profile_config) in other.profiles {
match self.profiles.get_mut(&profile_name) {
Some(existing_profile) => {
existing_profile.merge_with(profile_config);
}
None => {
self.profiles.insert(profile_name, profile_config);
}
}
}
if let Some(other_providers) = other.providers {
let merged = self.providers.get_or_insert_with(HashMap::new);
for (alias, uri) in other_providers {
merged.entry(alias).or_insert(uri);
}
}
}
fn from_path_with_visited(
path: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<Self, ParseError> {
let canonical_path = path.canonicalize().map_err(|e| {
ParseError::Io(io::Error::new(
e.kind(),
format!("Failed to resolve path {}: {}", path.display(), e),
))
})?;
if !visited.insert(canonical_path.clone()) {
return Err(ParseError::CircularDependency(format!(
"Configuration file {} is part of a circular dependency chain",
canonical_path.display()
)));
}
let content = fs::read_to_string(path)?;
Self::from_str_with_visited(&content, Some(path), visited)
}
fn from_str_with_visited(
content: &str,
base_path: Option<&Path>,
visited: &mut HashSet<PathBuf>,
) -> Result<Self, ParseError> {
let mut config: Config = toml::from_str(content)?;
if config.project.revision != "1.0" {
return Err(ParseError::UnsupportedRevision(config.project.revision));
}
if let Some(extends_paths) = config.project.extends.clone()
&& let Some(base) = base_path
{
let base_dir = base.parent().unwrap_or(Path::new("."));
config = Self::merge_extended_configs(config, &extends_paths, base_dir, visited)?;
}
Ok(config)
}
fn merge_extended_configs(
mut base_config: Config,
extends_paths: &[String],
base_dir: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<Config, ParseError> {
for extend_path in extends_paths {
let joined_path = base_dir.join(extend_path);
let full_path = if extend_path.ends_with(".toml") {
joined_path
} else {
joined_path.join("secretspec.toml")
};
if !full_path.exists() {
return Err(ParseError::ExtendedConfigNotFound(
full_path.display().to_string(),
));
}
let extended_config = Self::from_path_with_visited(&full_path, visited)?;
base_config.merge_with(extended_config);
}
Ok(base_config)
}
}
impl FromStr for Config {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut visited = HashSet::new();
Self::from_str_with_visited(s, None, &mut visited)
}
}
impl TryFrom<&Path> for Config {
type Error = ParseError;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
let mut visited = HashSet::new();
Self::from_path_with_visited(path, &mut visited)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RequireReason {
Never,
#[default]
Agents,
Always,
}
impl Serialize for RequireReason {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
RequireReason::Never => serializer.serialize_bool(false),
RequireReason::Always => serializer.serialize_bool(true),
RequireReason::Agents => serializer.serialize_str("agents"),
}
}
}
impl<'de> Deserialize<'de> for RequireReason {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct RequireReasonVisitor;
impl serde::de::Visitor<'_> for RequireReasonVisitor {
type Value = RequireReason;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(r#"a boolean or the string "agents""#)
}
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<RequireReason, E> {
Ok(if v {
RequireReason::Always
} else {
RequireReason::Never
})
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<RequireReason, E> {
match v {
"agents" => Ok(RequireReason::Agents),
other => Err(E::custom(format!(
"invalid require_reason value '{other}': expected true, false, or \"agents\""
))),
}
}
}
deserializer.deserialize_any(RequireReasonVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub name: String,
pub revision: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub extends: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_reason: Option<RequireReason>,
}
impl Default for Project {
fn default() -> Self {
Self {
name: String::new(),
revision: "1.0".to_string(),
extends: None,
require_reason: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AuditConfig {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
pub max_size_bytes: u64,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: true,
path: None,
max_size_bytes: 1_048_576,
}
}
}
impl AuditConfig {
pub fn resolved_path(&self) -> Option<PathBuf> {
match self.path.clone() {
Some(path) => Some(expand_tilde(path)).filter(|p| p.is_absolute()),
None => default_audit_path(),
}
}
pub fn has_relative_path(&self) -> bool {
self.path
.as_ref()
.is_some_and(|p| !expand_tilde(p.clone()).is_absolute())
}
}
fn app_strategy_args() -> etcetera::app_strategy::AppStrategyArgs {
etcetera::app_strategy::AppStrategyArgs {
top_level_domain: String::new(),
author: String::new(),
app_name: "secretspec".into(),
}
}
fn default_audit_path() -> Option<PathBuf> {
use etcetera::app_strategy::{AppStrategy, choose_app_strategy};
let strategy = choose_app_strategy(app_strategy_args()).ok()?;
let dir = strategy.state_dir().unwrap_or_else(|| strategy.data_dir());
Some(dir.join("audit.log"))
}
fn expand_tilde(path: PathBuf) -> PathBuf {
let Ok(rest) = path.strip_prefix("~") else {
return path;
};
let Some(home) = home_dir() else {
return path;
};
home.join(rest)
}
fn home_dir() -> Option<PathBuf> {
etcetera::home_dir()
.ok()
.or_else(|| std::env::var_os("HOME").map(PathBuf::from))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
#[serde(skip_serializing_if = "Option::is_none")]
pub defaults: Option<ProfileDefaults>,
#[serde(flatten)]
pub secrets: HashMap<String, Secret>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileDefaults {
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<Vec<String>>,
}
impl Profile {
pub fn new() -> Self {
Self {
defaults: None,
secrets: HashMap::new(),
}
}
pub fn validate(&self) -> Result<(), String> {
if self.secrets.is_empty() {
return Err("Profile must define at least one secret".into());
}
for (name, secret) in &self.secrets {
if !is_valid_identifier(name) {
return Err(format!(
"Invalid secret name '{}': must be a valid identifier (alphanumeric and underscores, not starting with a number)",
name
));
}
secret
.validate()
.map_err(|e| format!("Secret '{}': {}", name, e))?;
}
Ok(())
}
pub fn merge_with(&mut self, other: Profile) {
for (secret_name, secret_config) in other.secrets {
self.secrets.entry(secret_name).or_insert(secret_config);
}
}
pub fn iter(&self) -> hash_map::Iter<'_, String, Secret> {
self.secrets.iter()
}
}
impl Default for Profile {
fn default() -> Self {
Self::new()
}
}
impl<'a> IntoIterator for &'a Profile {
type Item = (&'a String, &'a Secret);
type IntoIter = hash_map::Iter<'a, String, Secret>;
#[inline]
fn into_iter(self) -> Self::IntoIter {
self.secrets.iter()
}
}
impl IntoIterator for Profile {
type Item = (String, Secret);
type IntoIter = hash_map::IntoIter<String, Secret>;
#[inline]
fn into_iter(self) -> Self::IntoIter {
self.secrets.into_iter()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum GenerateConfig {
Bool(bool),
Options(GenerateOptions),
}
impl GenerateConfig {
pub fn is_enabled(&self) -> bool {
match self {
GenerateConfig::Bool(b) => *b,
GenerateConfig::Options(_) => true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GenerateOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub length: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub charset: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bits: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Secret {
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub as_path: Option<bool>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub secret_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generate: Option<GenerateConfig>,
}
impl Secret {
pub fn validate(&self) -> Result<(), String> {
if let Some(desc) = &self.description {
if desc.is_empty() {
return Err("description cannot be empty".into());
}
} else {
return Err("missing description".into());
}
if self.required == Some(true) && self.default.is_some() {
return Err("Required secrets cannot have default values".into());
}
if let Some(ref gen_config) = self.generate
&& gen_config.is_enabled()
{
if self.secret_type.is_none() {
return Err(
"'generate' requires 'type' to be set (e.g., type = \"password\")".into(),
);
}
if self.default.is_some() {
return Err("'generate' and 'default' cannot both be set".into());
}
if self.secret_type.as_deref() == Some("command") {
match gen_config {
GenerateConfig::Bool(true) => {
return Err(
"type = \"command\" requires generate = { command = \"...\" }".into(),
);
}
GenerateConfig::Options(opts) if opts.command.is_none() => {
return Err(
"type = \"command\" requires generate = { command = \"...\" }".into(),
);
}
_ => {}
}
}
if let Some(ref t) = self.secret_type {
match t.as_str() {
"password" | "hex" | "base64" | "uuid" | "command" | "rsa_private_key" => {}
unknown => {
return Err(format!("unknown secret type '{}'", unknown));
}
}
}
}
if let Some(ref t) = self.secret_type
&& (self.generate.is_none() || self.generate.as_ref().is_some_and(|g| !g.is_enabled()))
{
match t.as_str() {
"password" | "hex" | "base64" | "uuid" | "command" | "rsa_private_key" => {}
unknown => {
return Err(format!("unknown secret type '{}'", unknown));
}
}
}
Ok(())
}
}
fn is_valid_identifier(s: &str) -> bool {
if s.is_empty() {
return false;
}
let mut chars = s.chars();
if let Some(first) = chars.next()
&& !first.is_alphabetic()
&& first != '_'
{
return false;
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[doc(hidden)]
pub struct GlobalConfig {
#[serde(default)]
pub defaults: GlobalDefaults,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit: Option<AuditConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[doc(hidden)]
pub struct GlobalDefaults {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<String, String>>,
}
impl GlobalConfig {
pub fn path() -> Result<PathBuf, io::Error> {
use etcetera::app_strategy::{AppStrategy, choose_app_strategy};
let strategy = choose_app_strategy(app_strategy_args())
.map_err(|e| io::Error::new(io::ErrorKind::NotFound, e.to_string()))?;
Ok(strategy.config_dir().join("config.toml"))
}
pub fn load() -> Result<Option<Self>, ParseError> {
let config_path = Self::path().map_err(ParseError::Io)?;
#[cfg(target_os = "macos")]
let config_path = Self::migrate_macos_config(&config_path).map_err(ParseError::Io)?;
if !config_path.try_exists().map_err(ParseError::Io)? {
return Ok(None);
}
let content = std::fs::read_to_string(&config_path).map_err(ParseError::Io)?;
toml::from_str(&content).map(Some).map_err(ParseError::Toml)
}
pub fn save(&self) -> Result<(), io::Error> {
let config_path = Self::path()?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
std::fs::write(&config_path, content)?;
Ok(())
}
#[cfg(target_os = "macos")]
fn migrate_macos_config(new_path: &Path) -> Result<PathBuf, io::Error> {
match new_path.try_exists() {
Ok(true) => return Ok(new_path.to_path_buf()),
Ok(false) => {}
Err(err) => {
if let Ok(home) = etcetera::home_dir() {
let old_path = home
.join("Library/Application Support/secretspec")
.join("config.toml");
if old_path.exists() {
return Ok(old_path);
}
}
return Err(err);
}
}
let old_path = match etcetera::home_dir() {
Ok(home) => home
.join("Library/Application Support/secretspec")
.join("config.toml"),
Err(_) => return Ok(new_path.to_path_buf()),
};
match old_path.try_exists() {
Ok(true) => {}
Ok(false) => return Ok(new_path.to_path_buf()),
Err(err) => {
eprintln!(
"Warning: failed to check legacy config path {}: {}. Continuing to use legacy path.",
old_path.display(),
err
);
return Ok(old_path);
}
}
if let Some(parent) = new_path.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
eprintln!(
"Warning: failed to create config directory {} while migrating from {}: {}. Continuing to use legacy config path.",
parent.display(),
old_path.display(),
err
);
return Ok(old_path);
}
}
if let Err(err) = std::fs::copy(&old_path, new_path) {
eprintln!(
"Warning: failed to migrate config from {} to {}: {}. Continuing to use legacy config path.",
old_path.display(),
new_path.display(),
err
);
return Ok(old_path);
}
let old_backup = old_path.with_extension("toml.old");
if let Err(err) = std::fs::rename(&old_path, &old_backup) {
eprintln!(
"Warning: migrated config to {}, but failed to back up {} to {}: {}",
new_path.display(),
old_path.display(),
old_backup.display(),
err
);
}
eprintln!(
"Migrated config from {} to {}",
old_path.display(),
new_path.display()
);
Ok(new_path.to_path_buf())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resolved<T> {
pub secrets: T,
pub provider: String,
pub profile: String,
}
impl<T> Resolved<T> {
pub fn new(secrets: T, provider: String, profile: String) -> Self {
Self {
secrets,
provider,
profile,
}
}
}
#[derive(Debug)]
pub enum ParseError {
Io(io::Error),
Toml(toml::de::Error),
UnsupportedRevision(String),
CircularDependency(String),
Validation(String),
ExtendedConfigNotFound(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Io(e) => write!(f, "I/O error: {}", e),
ParseError::Toml(e) => write!(f, "TOML parsing error: {}", e),
ParseError::UnsupportedRevision(rev) => {
write!(
f,
"Unsupported revision '{}'. Only '1.0' is supported.",
rev
)
}
ParseError::CircularDependency(msg) => {
write!(f, "Circular dependency detected: {}", msg)
}
ParseError::Validation(msg) => write!(f, "Validation error: {}", msg),
ParseError::ExtendedConfigNotFound(path) => {
write!(f, "Extended config file not found: {}", path)
}
}
}
}
impl std::error::Error for ParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ParseError::Io(e) => Some(e),
ParseError::Toml(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for ParseError {
fn from(e: io::Error) -> Self {
ParseError::Io(e)
}
}
impl From<toml::de::Error> for ParseError {
fn from(e: toml::de::Error) -> Self {
ParseError::Toml(e)
}
}
#[cfg(test)]
mod require_reason_tests {
use super::*;
fn parse(line: &str) -> Option<RequireReason> {
let toml = format!("name = \"t\"\nrevision = \"1.0\"\n{line}");
toml::from_str::<Project>(&toml).unwrap().require_reason
}
#[test]
fn accepts_bool_and_agents_string() {
assert_eq!(parse("require_reason = true"), Some(RequireReason::Always));
assert_eq!(parse("require_reason = false"), Some(RequireReason::Never));
assert_eq!(
parse("require_reason = \"agents\""),
Some(RequireReason::Agents)
);
}
#[test]
fn unspecified_require_reason_is_none_and_resolves_to_agents() {
assert_eq!(parse(""), None);
assert_eq!(parse("").unwrap_or_default(), RequireReason::Agents);
}
#[test]
fn extends_inherits_parent_require_reason_when_unspecified() {
use std::collections::HashMap;
let cfg = |rr: Option<RequireReason>| Config {
project: Project {
name: "t".to_string(),
require_reason: rr,
..Default::default()
},
profiles: HashMap::new(),
providers: None,
};
let mut child = cfg(None);
child.merge_with(cfg(Some(RequireReason::Always)));
assert_eq!(child.project.require_reason, Some(RequireReason::Always));
let mut child = cfg(Some(RequireReason::Never));
child.merge_with(cfg(Some(RequireReason::Always)));
assert_eq!(child.project.require_reason, Some(RequireReason::Never));
}
#[test]
fn rejects_unknown_or_wrong_typed_values() {
let base = "name = \"t\"\nrevision = \"1.0\"\n";
let err = toml::from_str::<Project>(&format!("{base}require_reason = \"nope\""))
.unwrap_err()
.to_string();
assert!(
err.contains("expected true, false, or \"agents\""),
"unexpected error: {err}"
);
let err = toml::from_str::<Project>(&format!("{base}require_reason = 1"))
.unwrap_err()
.to_string();
assert!(
err.contains("invalid type") && err.contains("boolean or the string"),
"unexpected error: {err}"
);
}
#[test]
fn round_trips_through_serialize() {
let toml = toml::to_string(&Project {
name: "t".to_string(),
revision: "1.0".to_string(),
extends: None,
require_reason: None,
})
.unwrap();
assert!(!toml.contains("require_reason"));
let toml = toml::to_string(&Project {
name: "t".to_string(),
revision: "1.0".to_string(),
extends: None,
require_reason: Some(RequireReason::Always),
})
.unwrap();
assert_eq!(
toml::from_str::<Project>(&toml).unwrap().require_reason,
Some(RequireReason::Always)
);
}
}
#[cfg(test)]
mod audit_config_tests {
use super::*;
fn with_path(path: &str) -> AuditConfig {
AuditConfig {
path: Some(PathBuf::from(path)),
..Default::default()
}
}
#[test]
fn resolved_path_keeps_absolute_and_rejects_relative() {
let abs = with_path("/var/log/secretspec/audit.log");
assert_eq!(
abs.resolved_path(),
Some(PathBuf::from("/var/log/secretspec/audit.log"))
);
assert!(!abs.has_relative_path());
for rel in ["audit.log", "logs/audit.log", "./audit.log"] {
let cfg = with_path(rel);
assert_eq!(
cfg.resolved_path(),
None,
"relative path {rel:?} must reject"
);
assert!(
cfg.has_relative_path(),
"{rel:?} should be flagged relative"
);
}
}
#[test]
fn unset_path_is_not_flagged_relative() {
let cfg = AuditConfig::default();
assert!(!cfg.has_relative_path());
}
#[test]
fn expand_tilde_expands_leading_tilde_only() {
assert_eq!(
expand_tilde(PathBuf::from("/abs/path")),
PathBuf::from("/abs/path")
);
assert_eq!(
expand_tilde(PathBuf::from("relative/path")),
PathBuf::from("relative/path")
);
assert_eq!(
expand_tilde(PathBuf::from("/a/~/b")),
PathBuf::from("/a/~/b")
);
if let Some(home) = home_dir() {
assert_eq!(
expand_tilde(PathBuf::from("~/.local/state/secretspec/audit.log")),
home.join(".local/state/secretspec/audit.log")
);
}
}
#[test]
fn audit_config_omitted_fields_default_to_on() {
let cfg: AuditConfig = toml::from_str("").unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.path, None);
assert_eq!(cfg.max_size_bytes, 1_048_576);
}
#[test]
fn global_config_wires_audit_table() {
let g: GlobalConfig =
toml::from_str("[defaults]\nprovider = \"keyring\"\n\n[audit]\nenabled = false\n")
.unwrap();
assert_eq!(g.audit.map(|a| a.enabled), Some(false));
let g: GlobalConfig = toml::from_str("[defaults]\nprovider = \"keyring\"\n").unwrap();
assert!(g.audit.is_none());
}
}
#[cfg(test)]
mod validation_tests {
use super::*;
fn secret(description: Option<&str>) -> Secret {
Secret {
description: description.map(String::from),
..Default::default()
}
}
fn config_with(name: &str, profiles: Vec<(&str, Vec<(&str, Secret)>)>) -> Config {
let profiles = profiles
.into_iter()
.map(|(pname, secrets)| {
let secrets = secrets
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
(
pname.to_string(),
Profile {
defaults: None,
secrets,
},
)
})
.collect();
Config {
project: Project {
name: name.to_string(),
..Default::default()
},
profiles,
providers: None,
}
}
#[test]
fn is_valid_identifier_accepts_and_rejects() {
for ok in ["ok", "_ok", "VALID_NAME9", "a"] {
assert!(is_valid_identifier(ok), "expected valid: {ok}");
}
for bad in ["", "1abc", "a-b", "has space", "a.b"] {
assert!(!is_valid_identifier(bad), "expected invalid: {bad}");
}
}
#[test]
fn config_validate_rejects_empty_name() {
let err = config_with("", vec![("default", vec![("A", secret(Some("d")))])])
.validate()
.unwrap_err();
assert!(matches!(err, ParseError::Validation(_)));
assert!(err.to_string().contains("name cannot be empty"));
}
#[test]
fn config_validate_rejects_no_profiles() {
let err = config_with("proj", vec![]).validate().unwrap_err();
assert!(err.to_string().contains("At least one profile"));
}
#[test]
fn config_validate_rejects_empty_profile() {
let err = config_with("proj", vec![("default", vec![])])
.validate()
.unwrap_err();
assert!(err.to_string().contains("at least one secret"));
}
#[test]
fn config_validate_rejects_invalid_secret_name() {
let err = config_with("proj", vec![("default", vec![("1BAD", secret(Some("d")))])])
.validate()
.unwrap_err();
assert!(err.to_string().contains("Invalid secret name"));
}
#[test]
fn config_validate_accepts_valid_config() {
assert!(
config_with(
"proj",
vec![("default", vec![("API_KEY", secret(Some("d")))])]
)
.validate()
.is_ok()
);
}
#[test]
fn secret_validate_requires_nonempty_description() {
assert_eq!(secret(None).validate().unwrap_err(), "missing description");
assert_eq!(
secret(Some("")).validate().unwrap_err(),
"description cannot be empty"
);
}
#[test]
fn secret_validate_rejects_required_with_default() {
let s = Secret {
description: Some("d".to_string()),
required: Some(true),
default: Some("v".to_string()),
..Default::default()
};
assert!(
s.validate()
.unwrap_err()
.contains("Required secrets cannot have default")
);
}
#[test]
fn secret_validate_generate_requires_type() {
let s = Secret {
description: Some("d".to_string()),
generate: Some(GenerateConfig::Bool(true)),
..Default::default()
};
assert!(s.validate().unwrap_err().contains("requires 'type'"));
}
#[test]
fn secret_validate_rejects_unknown_type() {
let s = Secret {
description: Some("d".to_string()),
secret_type: Some("banana".to_string()),
..Default::default()
};
assert!(s.validate().unwrap_err().contains("unknown secret type"));
}
#[test]
fn secret_validate_command_type_requires_command() {
let s = Secret {
description: Some("d".to_string()),
secret_type: Some("command".to_string()),
generate: Some(GenerateConfig::Bool(true)),
..Default::default()
};
assert!(
s.validate()
.unwrap_err()
.contains("requires generate = { command")
);
}
#[test]
fn generate_config_is_enabled() {
assert!(!GenerateConfig::Bool(false).is_enabled());
assert!(GenerateConfig::Bool(true).is_enabled());
assert!(GenerateConfig::Options(GenerateOptions::default()).is_enabled());
}
}