pub mod params;
pub mod results;
pub mod traits;
use std::collections::BTreeMap;
use std::time::Duration;
use crate::model::error::{ErrorCode, ObzError};
pub use params::{
ExtensionParams, LabelValuesParams, LogSearchParams, MetricInfoParams, MetricMetadataParams,
MetricQueryParams, TraceGetParams, TraceSearchParams,
};
pub use results::{
ExtensionResult, LogSearchResult, MetricQueryResult, MetricResultType, ProviderResult,
TraceSearchResult,
};
pub use traits::{ExtensionProvider, LogProvider, MetricProvider, TraceProvider};
#[derive(Clone)]
pub struct ProviderConfig {
values: BTreeMap<String, String>,
auth: BTreeMap<String, String>,
headers: BTreeMap<String, String>,
verbose: bool,
timeout: Option<Duration>,
}
impl ProviderConfig {
pub fn new() -> Self {
Self {
values: BTreeMap::new(),
auth: BTreeMap::new(),
headers: BTreeMap::new(),
verbose: false,
timeout: None,
}
}
pub fn set(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
self.values.insert(key.to_string(), value.into());
self
}
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
pub fn get_owned(&self, key: &str) -> Option<String> {
self.values.get(key).cloned()
}
pub fn require(&self, key: &str) -> Result<&str, ObzError> {
self.get(key).ok_or_else(|| ObzError::InvalidArgument {
code: ErrorCode::MissingRequired,
message: format!("--{key} is required"),
suggestion: None,
})
}
pub fn require_config(&self, key: &str) -> Result<&str, ObzError> {
self.get(key).ok_or_else(|| ObzError::InvalidArgument {
code: ErrorCode::MissingRequired,
message: format!(
"--{key} is required. Set it in config.yaml under your provider's config block"
),
suggestion: None,
})
}
pub fn set_auth(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
self.auth.insert(key.to_string(), value.into());
self
}
pub fn auth_get(&self, key: &str) -> Option<&str> {
self.auth
.get(key)
.map(String::as_str)
.filter(|v| !v.is_empty())
}
pub fn auth_get_owned(&self, key: &str) -> Option<String> {
self.auth_get(key).map(str::to_string)
}
pub fn auth_require(&self, key: &str) -> Result<&str, ObzError> {
self.auth_get(key)
.ok_or_else(|| auth_missing_error(key, ""))
}
pub fn bearer_token(&self) -> Option<String> {
self.auth_get_owned("token")
}
pub fn basic_auth(&self) -> Option<(String, String)> {
match (self.auth_get("username"), self.auth_get("password")) {
(Some(u), Some(p)) => Some((u.to_string(), p.to_string())),
_ => None,
}
}
pub fn set_header(&mut self, key: &str, value: impl Into<String>) -> &mut Self {
self.headers.insert(key.to_ascii_lowercase(), value.into());
self
}
pub fn custom_headers(&self) -> &BTreeMap<String, String> {
&self.headers
}
pub fn set_verbose(&mut self, verbose: bool) {
self.verbose = verbose;
}
pub fn verbose(&self) -> bool {
self.verbose
}
pub fn set_timeout(&mut self, timeout: Duration) {
self.timeout = Some(timeout);
}
pub fn timeout(&self) -> Option<Duration> {
self.timeout
}
}
impl Default for ProviderConfig {
fn default() -> Self {
Self::new()
}
}
pub fn is_sensitive_key(key: &str) -> bool {
let k = key.to_ascii_lowercase();
k.contains("token")
|| k.contains("password")
|| k.contains("secret")
|| k == "key"
|| k.ends_with("-key")
}
pub fn auth_missing_error(key: &str, provider_type: &str) -> ObzError {
let hint = if provider_type.is_empty() {
format!("Set it in config.yaml: providers.<name>.auth.{key}")
} else {
format!(
"Set it in config.yaml: providers.<name>.auth.{key} (provider type: {provider_type})"
)
};
ObzError::InvalidArgument {
code: ErrorCode::MissingRequired,
message: format!("{key} is required. {hint}"),
suggestion: None,
}
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("ProviderConfig");
let redacted_values: BTreeMap<&str, &str> = self
.values
.iter()
.map(|(k, v)| {
if is_sensitive_key(k) {
(k.as_str(), "[REDACTED]")
} else {
(k.as_str(), v.as_str())
}
})
.collect();
s.field("values", &redacted_values);
if self.auth.is_empty() {
s.field("auth", &"{}");
} else {
s.field("auth", &"[REDACTED]");
}
let redacted_headers: BTreeMap<&str, &str> = self
.headers
.iter()
.map(|(k, v)| {
if is_sensitive_key(k) {
(k.as_str(), "[REDACTED]")
} else {
(k.as_str(), v.as_str())
}
})
.collect();
s.field("headers", &redacted_headers);
s.field("verbose", &self.verbose);
s.field("timeout", &self.timeout);
s.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Signal {
Metric,
Log,
Trace,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_returns_value_when_key_exists() {
let mut config = ProviderConfig::new();
config.set("endpoint", "http://localhost:8428");
assert_eq!(config.get("endpoint"), Some("http://localhost:8428"));
}
#[test]
fn test_get_returns_none_when_key_missing() {
let config = ProviderConfig::new();
assert_eq!(config.get("endpoint"), None);
}
#[test]
fn test_get_owned_returns_cloned_value() {
let mut config = ProviderConfig::new();
config.set("project", "abc");
assert_eq!(config.get_owned("project"), Some("abc".to_string()));
assert_eq!(config.get_owned("missing"), None);
}
#[test]
fn test_require_returns_value_when_key_exists() {
let mut config = ProviderConfig::new();
config.set("endpoint", "http://localhost:8428");
assert_eq!(config.require("endpoint").unwrap(), "http://localhost:8428");
}
#[test]
fn test_require_returns_error_when_key_missing() {
let config = ProviderConfig::new();
let err = config.require("endpoint").unwrap_err();
match err {
ObzError::InvalidArgument { code, message, .. } => {
assert_eq!(code, ErrorCode::MissingRequired);
assert!(message.contains("--endpoint"));
}
_ => panic!("expected InvalidArgument, got {err:?}"),
}
}
#[test]
fn test_require_config_returns_value_when_key_exists() {
let mut config = ProviderConfig::new();
config.set("endpoint", "http://localhost:8428");
assert_eq!(
config.require_config("endpoint").unwrap(),
"http://localhost:8428"
);
}
#[test]
fn test_require_config_error_includes_config_hint() {
let config = ProviderConfig::new();
let err = config.require_config("endpoint").unwrap_err();
match err {
ObzError::InvalidArgument { code, message, .. } => {
assert_eq!(code, ErrorCode::MissingRequired);
assert!(message.contains("--endpoint"));
assert!(
message.contains("config.yaml"),
"require_config error should mention config.yaml, got: {message}"
);
}
_ => panic!("expected InvalidArgument, got {err:?}"),
}
}
#[test]
fn test_require_error_does_not_mention_config() {
let config = ProviderConfig::new();
let err = config.require("query").unwrap_err();
match err {
ObzError::InvalidArgument { message, .. } => {
assert!(
!message.contains("config.yaml"),
"require() should not mention config.yaml, got: {message}"
);
}
_ => panic!("expected InvalidArgument, got {err:?}"),
}
}
#[test]
fn test_set_overwrites_existing_value() {
let mut config = ProviderConfig::new();
config.set("endpoint", "http://old");
config.set("endpoint", "http://new");
assert_eq!(config.get("endpoint"), Some("http://new"));
}
#[test]
fn test_set_supports_chaining() {
let mut config = ProviderConfig::new();
config
.set("endpoint", "http://localhost")
.set("project", "abc");
assert_eq!(config.get("endpoint"), Some("http://localhost"));
assert_eq!(config.get("project"), Some("abc"));
}
#[test]
fn test_auth_get_returns_value() {
let mut config = ProviderConfig::new();
config.set_auth("token", "my-token");
assert_eq!(config.auth_get("token"), Some("my-token"));
}
#[test]
fn test_auth_get_returns_none_when_missing() {
let config = ProviderConfig::new();
assert_eq!(config.auth_get("token"), None);
}
#[test]
fn test_auth_get_returns_none_for_empty_string() {
let mut config = ProviderConfig::new();
config.set_auth("token", "");
assert_eq!(config.auth_get("token"), None);
}
#[test]
fn test_auth_get_owned_returns_cloned_value() {
let mut config = ProviderConfig::new();
config.set_auth("api-key", "abc123");
assert_eq!(config.auth_get_owned("api-key"), Some("abc123".to_string()));
assert_eq!(config.auth_get_owned("missing"), None);
}
#[test]
fn test_auth_require_returns_value() {
let mut config = ProviderConfig::new();
config.set_auth("token", "secret");
assert_eq!(config.auth_require("token").unwrap(), "secret");
}
#[test]
fn test_auth_require_returns_error_when_missing() {
let config = ProviderConfig::new();
let err = config.auth_require("token").unwrap_err();
match err {
ObzError::InvalidArgument { code, message, .. } => {
assert_eq!(code, ErrorCode::MissingRequired);
assert!(message.contains("token"));
assert!(message.contains("config.yaml"));
}
_ => panic!("expected InvalidArgument, got {err:?}"),
}
}
#[test]
fn test_auth_require_returns_error_for_empty_string() {
let mut config = ProviderConfig::new();
config.set_auth("token", "");
assert!(config.auth_require("token").is_err());
}
#[test]
fn test_bearer_token_returns_token_from_auth() {
let mut config = ProviderConfig::new();
config.set_auth("token", "bearer-secret");
assert_eq!(config.bearer_token(), Some("bearer-secret".to_string()));
}
#[test]
fn test_bearer_token_returns_none_when_missing() {
let config = ProviderConfig::new();
assert_eq!(config.bearer_token(), None);
}
#[test]
fn test_basic_auth_returns_credentials_when_both_present() {
let mut config = ProviderConfig::new();
config.set_auth("username", "admin");
config.set_auth("password", "secret");
assert_eq!(
config.basic_auth(),
Some(("admin".to_string(), "secret".to_string()))
);
}
#[test]
fn test_basic_auth_returns_none_when_username_missing() {
let mut config = ProviderConfig::new();
config.set_auth("password", "secret");
assert_eq!(config.basic_auth(), None);
}
#[test]
fn test_basic_auth_returns_none_when_password_missing() {
let mut config = ProviderConfig::new();
config.set_auth("username", "admin");
assert_eq!(config.basic_auth(), None);
}
#[test]
fn test_basic_auth_returns_none_when_both_missing() {
let config = ProviderConfig::new();
assert_eq!(config.basic_auth(), None);
}
#[test]
fn test_basic_auth_returns_none_when_empty_strings() {
let mut config = ProviderConfig::new();
config.set_auth("username", "");
config.set_auth("password", "secret");
assert_eq!(config.basic_auth(), None);
}
#[test]
fn test_set_header_lowercases_key() {
let mut config = ProviderConfig::new();
config.set_header("X-Scope-OrgID", "my-tenant");
assert_eq!(
config.custom_headers().get("x-scope-orgid"),
Some(&"my-tenant".to_string())
);
}
#[test]
fn test_custom_headers_returns_all_headers() {
let mut config = ProviderConfig::new();
config.set_header("x-custom", "value1");
config.set_header("x-other", "value2");
assert_eq!(config.custom_headers().len(), 2);
}
#[test]
fn test_verbose_returns_true_when_set() {
let mut config = ProviderConfig::new();
config.set_verbose(true);
assert!(config.verbose());
}
#[test]
fn test_verbose_returns_false_by_default() {
let config = ProviderConfig::new();
assert!(!config.verbose());
}
#[test]
fn test_timeout_returns_none_by_default() {
let config = ProviderConfig::new();
assert_eq!(config.timeout(), None);
}
#[test]
fn test_timeout_returns_value_when_set() {
let mut config = ProviderConfig::new();
config.set_timeout(Duration::from_secs(30));
assert_eq!(config.timeout(), Some(Duration::from_secs(30)));
}
#[test]
fn test_debug_redacts_auth_entirely() {
let mut config = ProviderConfig::new();
config.set("endpoint", "http://localhost");
config.set_auth("token", "super-secret-token");
config.set_auth("password", "hunter2");
let debug = format!("{config:?}");
assert!(!debug.contains("super-secret-token"));
assert!(!debug.contains("hunter2"));
assert!(debug.contains("[REDACTED]"));
assert!(debug.contains("http://localhost"));
}
#[test]
fn test_debug_redacts_sensitive_value_keys() {
let mut config = ProviderConfig::new();
config.set("endpoint", "http://localhost");
config.set("access-key-secret", "should-be-redacted");
let debug = format!("{config:?}");
assert!(!debug.contains("should-be-redacted"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn test_debug_preserves_non_sensitive_values() {
let mut config = ProviderConfig::new();
config.set("endpoint", "http://localhost");
config.set("project", "my-project");
config.set("region", "cn-hangzhou");
let debug = format!("{config:?}");
assert!(debug.contains("http://localhost"));
assert!(debug.contains("my-project"));
assert!(debug.contains("cn-hangzhou"));
}
#[test]
fn test_debug_redacts_sensitive_header_values() {
let mut config = ProviderConfig::new();
config.set_header("x-auth-token", "secret-header-val");
config.set_header("x-custom", "visible-val");
let debug = format!("{config:?}");
assert!(!debug.contains("secret-header-val"));
assert!(debug.contains("visible-val"));
}
#[test]
fn test_sensitive_key_detection_avoids_false_positives() {
let mut config = ProviderConfig::new();
config.set("monkey", "banana");
config.set("hotkey-binding", "ctrl+c");
config.set("keyboard", "us-layout");
let debug = format!("{config:?}");
assert!(debug.contains("banana"));
assert!(debug.contains("ctrl+c"));
assert!(debug.contains("us-layout"));
}
#[test]
fn test_default() {
let config = ProviderConfig::default();
assert_eq!(config.get("anything"), None);
assert!(!config.verbose());
assert_eq!(config.timeout(), None);
}
#[test]
fn test_auth_missing_error_without_provider_type() {
let err = auth_missing_error("token", "");
match err {
ObzError::InvalidArgument { code, message, .. } => {
assert_eq!(code, ErrorCode::MissingRequired);
assert!(message.contains("token"));
assert!(message.contains("config.yaml"));
}
_ => panic!("expected InvalidArgument"),
}
}
#[test]
fn test_auth_missing_error_with_provider_type() {
let err = auth_missing_error("api-key", "dd");
match err {
ObzError::InvalidArgument { message, .. } => {
assert!(message.contains("api-key"));
assert!(message.contains("dd"));
}
_ => panic!("expected InvalidArgument"),
}
}
}