use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::errors::{SafeError, SafeResult};
#[derive(Debug, Deserialize)]
pub struct PushConfig {
pub pushes: Vec<PushSource>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "source")]
pub enum PushSource {
#[serde(rename = "akv")]
Kv {
#[serde(default)]
name: Option<String>,
vault_url: String,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
delete_missing: bool,
},
#[serde(rename = "aws")]
Aws {
#[serde(default)]
name: Option<String>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
delete_missing: bool,
},
#[serde(rename = "ssm")]
Ssm {
#[serde(default)]
name: Option<String>,
#[serde(default)]
region: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
delete_missing: bool,
},
#[serde(rename = "gcp")]
Gcp {
#[serde(default)]
name: Option<String>,
#[serde(default)]
project: Option<String>,
#[serde(default)]
prefix: Option<String>,
#[serde(default)]
delete_missing: bool,
},
}
impl PushSource {
pub fn name(&self) -> Option<&str> {
match self {
PushSource::Kv { name, .. }
| PushSource::Aws { name, .. }
| PushSource::Ssm { name, .. }
| PushSource::Gcp { name, .. } => name.as_deref(),
}
}
pub fn provider_type(&self) -> &'static str {
match self {
PushSource::Kv { .. } => "akv",
PushSource::Aws { .. } => "aws",
PushSource::Ssm { .. } => "ssm",
PushSource::Gcp { .. } => "gcp",
}
}
pub fn delete_missing(&self) -> bool {
match self {
PushSource::Kv { delete_missing, .. }
| PushSource::Aws { delete_missing, .. }
| PushSource::Ssm { delete_missing, .. }
| PushSource::Gcp { delete_missing, .. } => *delete_missing,
}
}
}
pub fn load(path: &Path) -> SafeResult<PushConfig> {
let content = std::fs::read_to_string(path)?;
let is_json = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e == "json")
.unwrap_or(false);
if is_json {
serde_json::from_str(&content).map_err(|e| SafeError::InvalidVault {
reason: format!("invalid push config JSON: {e}"),
})
} else {
serde_yaml::from_str(&content).map_err(|e| SafeError::InvalidVault {
reason: format!("invalid push config YAML: {e}"),
})
}
}
pub fn find_config(start: &Path) -> Option<PathBuf> {
let mut dir = start.to_path_buf();
loop {
let yml = dir.join(".tsafe.yml");
if yml.exists() {
return Some(yml);
}
let json = dir.join(".tsafe.json");
if json.exists() {
return Some(json);
}
if !dir.pop() {
return None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parse_yaml_push_config_all_providers() {
let yaml = r#"
pushes:
- source: akv
vault_url: https://myvault.vault.azure.net
prefix: MYAPP_
delete_missing: false
- source: aws
region: us-east-1
prefix: myapp/
delete_missing: true
- source: ssm
region: us-east-1
path: /myapp/prod/
- source: gcp
project: my-gcp-project
prefix: myapp-
"#;
let cfg: PushConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.pushes.len(), 4);
match &cfg.pushes[0] {
PushSource::Kv {
vault_url,
prefix,
delete_missing,
..
} => {
assert_eq!(vault_url, "https://myvault.vault.azure.net");
assert_eq!(prefix.as_deref(), Some("MYAPP_"));
assert!(!delete_missing);
}
other => panic!("expected Kv, got {other:?}"),
}
match &cfg.pushes[1] {
PushSource::Aws { delete_missing, .. } => {
assert!(delete_missing);
}
other => panic!("expected Aws, got {other:?}"),
}
}
#[test]
fn parse_json_push_config() {
let json = r#"{"pushes": [{"source": "akv", "vault_url": "https://v.vault.azure.net"}]}"#;
let cfg: PushConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.pushes.len(), 1);
}
#[test]
fn missing_pushes_key_returns_error() {
let yaml = r#"
pulls:
- source: akv
vault_url: https://myvault.vault.azure.net
"#;
let result: Result<PushConfig, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "expected error when pushes: key is absent");
}
#[test]
fn push_source_name_accessor() {
let named = PushSource::Kv {
name: Some("prod-akv".into()),
vault_url: "https://prod.vault.azure.net".into(),
prefix: None,
delete_missing: false,
};
assert_eq!(named.name(), Some("prod-akv"));
assert_eq!(named.provider_type(), "akv");
let unnamed = PushSource::Aws {
name: None,
region: Some("us-east-1".into()),
prefix: None,
delete_missing: false,
};
assert_eq!(unnamed.name(), None);
assert_eq!(unnamed.provider_type(), "aws");
}
#[test]
fn delete_missing_defaults_to_false() {
let yaml = r#"
pushes:
- source: akv
vault_url: https://myvault.vault.azure.net
- source: ssm
region: us-east-1
"#;
let cfg: PushConfig = serde_yaml::from_str(yaml).unwrap();
for source in &cfg.pushes {
assert!(
!source.delete_missing(),
"expected delete_missing=false for {:?}",
source.provider_type()
);
}
}
#[test]
fn find_config_walks_up() {
let dir = tempdir().unwrap();
let child = dir.path().join("a/b/c");
std::fs::create_dir_all(&child).unwrap();
let cfg_path = dir.path().join(".tsafe.yml");
std::fs::write(&cfg_path, "pushes: []").unwrap();
let found = find_config(&child).unwrap();
assert_eq!(found, cfg_path);
}
#[test]
fn find_config_returns_none_when_absent() {
let dir = tempdir().unwrap();
assert!(find_config(dir.path()).is_none());
}
#[test]
fn source_filter_selects_named_sources_only() {
let sources = vec![
PushSource::Kv {
name: Some("prod-akv".into()),
vault_url: "https://prod.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
PushSource::Kv {
name: Some("staging-akv".into()),
vault_url: "https://staging.vault.azure.net".into(),
prefix: None,
delete_missing: false,
},
PushSource::Aws {
name: None,
region: Some("us-east-1".into()),
prefix: None,
delete_missing: false,
},
];
let filter = ["prod-akv".to_string()];
let filtered: Vec<&PushSource> = sources
.iter()
.filter(|s| {
s.name()
.map(|n| filter.iter().any(|f| f == n))
.unwrap_or(false)
})
.collect();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].name(), Some("prod-akv"));
}
}