use std::fmt;
use super::azdo::AzureDevOpsConfig;
use super::{BitbucketConfig, ClassificationConfig, GithubConfig, JiraConfig, LinearConfig};
use crate::classify::classifier::ClassificationEngineConfig;
pub(super) const REDACTED: &str = "<redacted>";
pub(super) fn mask_required(_value: &str) -> &'static str {
REDACTED
}
pub(super) fn mask(value: Option<&String>) -> Option<&'static str> {
value.map(|v| mask_required(v))
}
impl fmt::Debug for LinearConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
api_key,
team_keys,
fetch_on_reference,
ticket_regex,
} = self;
f.debug_struct("LinearConfig")
.field("api_key", &mask(api_key.as_ref()))
.field("team_keys", team_keys)
.field("fetch_on_reference", fetch_on_reference)
.field("ticket_regex", ticket_regex)
.finish()
}
}
impl fmt::Debug for GithubConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
token,
org,
orgs,
repo,
fetch_prs,
fetch_pr_reviews,
review_fetch_concurrency,
ticket_regex,
fetch_on_reference,
work_items_unavailable,
} = self;
f.debug_struct("GithubConfig")
.field("token", &mask(token.as_ref()))
.field("org", org)
.field("orgs", orgs)
.field("repo", repo)
.field("fetch_prs", fetch_prs)
.field("fetch_pr_reviews", fetch_pr_reviews)
.field("review_fetch_concurrency", review_fetch_concurrency)
.field("ticket_regex", ticket_regex)
.field("fetch_on_reference", fetch_on_reference)
.field("work_items_unavailable", work_items_unavailable)
.finish()
}
}
impl fmt::Debug for BitbucketConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
username,
app_password,
token,
workspace,
workspaces,
repo_slug,
fetch_prs,
api_base_url,
} = self;
f.debug_struct("BitbucketConfig")
.field("username", username)
.field("app_password", &mask(app_password.as_ref()))
.field("token", &mask(token.as_ref()))
.field("workspace", workspace)
.field("workspaces", workspaces)
.field("repo_slug", repo_slug)
.field("fetch_prs", fetch_prs)
.field("api_base_url", api_base_url)
.finish()
}
}
impl fmt::Debug for JiraConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
url,
username,
token,
project_key,
timezone,
jira_project_mappings,
jira_project_mapping_confidence,
ticket_regex,
fetch_on_reference,
} = self;
f.debug_struct("JiraConfig")
.field("url", url)
.field("username", username)
.field("token", &mask(token.as_ref()))
.field("project_key", project_key)
.field("timezone", timezone)
.field("jira_project_mappings", jira_project_mappings)
.field(
"jira_project_mapping_confidence",
jira_project_mapping_confidence,
)
.field("ticket_regex", ticket_regex)
.field("fetch_on_reference", fetch_on_reference)
.finish()
}
}
impl fmt::Debug for AzureDevOpsConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
organization_url,
pat,
project,
projects,
ticket_regex,
team_keys,
fetch_on_reference,
fetch_prs,
} = self;
f.debug_struct("AzureDevOpsConfig")
.field("organization_url", organization_url)
.field("pat", &mask_required(pat))
.field("project", project)
.field("projects", projects)
.field("ticket_regex", ticket_regex)
.field("team_keys", team_keys)
.field("fetch_on_reference", fetch_on_reference)
.field("fetch_prs", fetch_prs)
.finish()
}
}
impl fmt::Debug for ClassificationConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
rules_files,
repo_categories,
use_llm,
llm_model,
llm_provider,
openrouter_api_key,
confidence_threshold,
custom_categories,
min_coverage_pct,
llm_fallback_threshold,
weighted_sum,
llm_fallback_concurrency,
no_external,
checkpoint_every,
sources,
} = self;
f.debug_struct("ClassificationConfig")
.field("rules_files", rules_files)
.field("repo_categories", repo_categories)
.field("use_llm", use_llm)
.field("llm_model", llm_model)
.field("llm_provider", llm_provider)
.field("openrouter_api_key", &mask(openrouter_api_key.as_ref()))
.field("confidence_threshold", confidence_threshold)
.field("custom_categories", custom_categories)
.field("min_coverage_pct", min_coverage_pct)
.field("llm_fallback_threshold", llm_fallback_threshold)
.field("weighted_sum", weighted_sum)
.field("llm_fallback_concurrency", llm_fallback_concurrency)
.field("no_external", no_external)
.field("checkpoint_every", checkpoint_every)
.field("sources", sources)
.finish()
}
}
impl fmt::Debug for ClassificationEngineConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
use_llm,
llm_model,
llm_provider,
openrouter_api_key,
confidence_threshold,
weighted_sum,
} = self;
f.debug_struct("ClassificationEngineConfig")
.field("use_llm", use_llm)
.field("llm_model", llm_model)
.field("llm_provider", llm_provider)
.field("openrouter_api_key", &mask(openrouter_api_key.as_ref()))
.field("confidence_threshold", confidence_threshold)
.field("weighted_sum", weighted_sum)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET_SHAPES: &[(&str, &str)] = &[
(
"9f3Kq7Zt2Wm4Bx8Lv6Nc1Rd5Ph0Sj",
"no prefix: entropy up front",
),
(
"ghp_averyrealisticlookingtoken0123456789",
"a provider-prefixed token",
),
("ab7Q", "exactly a four-character head"),
("x9", "shorter than a head"),
("", "empty — rejected by validation, guarded anyway"),
];
const PROBE: &str = "probe-value-kept-in-the-clear";
fn assert_masked(rendered: &str, secret: &str, why: &str) {
if !secret.is_empty() {
assert!(
!rendered.contains(secret),
"{why}: the whole credential reached Debug output: {rendered}"
);
let head: String = secret.chars().take(4).collect();
assert!(
!rendered.contains(&head),
"{why}: a leading fragment of the credential survived: {rendered}"
);
}
assert!(
rendered.contains(REDACTED),
"{why}: the credential field was not masked: {rendered}"
);
assert!(
rendered.contains(PROBE),
"{why}: redaction must not cost the non-secret fields: {rendered}"
);
}
fn for_each_shape(render: impl Fn(&str) -> String) {
for (secret, why) in SECRET_SHAPES {
assert_masked(&render(secret), secret, why);
}
}
#[test]
fn debug_never_renders_the_linear_api_key() {
for_each_shape(|secret| {
let cfg = LinearConfig {
api_key: Some(secret.to_string()),
ticket_regex: Some(PROBE.to_string()),
..LinearConfig::default()
};
format!("{cfg:?}{cfg:#?}")
});
let unset = LinearConfig {
api_key: None,
ticket_regex: Some(PROBE.to_string()),
..LinearConfig::default()
};
let rendered = format!("{unset:?}");
assert!(
rendered.contains("api_key: None"),
"an unset key must stay visibly unset — that is what the reader came \
for: {rendered}"
);
}
#[test]
fn debug_never_renders_the_github_token() {
for_each_shape(|secret| {
let cfg = GithubConfig {
token: Some(secret.to_string()),
org: Some(PROBE.to_string()),
..GithubConfig::default()
};
format!("{cfg:?}{cfg:#?}")
});
let unset = GithubConfig {
token: None,
org: Some(PROBE.to_string()),
..GithubConfig::default()
};
let rendered = format!("{unset:?}");
assert!(
rendered.contains("token: None"),
"an unset token must stay visibly unset: {rendered}"
);
}
#[test]
fn debug_never_renders_the_jira_token() {
for_each_shape(|secret| {
let cfg = JiraConfig {
token: Some(secret.to_string()),
url: Some(PROBE.to_string()),
..JiraConfig::default()
};
format!("{cfg:?}{cfg:#?}")
});
let unset = JiraConfig {
token: None,
url: Some(PROBE.to_string()),
..JiraConfig::default()
};
let rendered = format!("{unset:?}");
assert!(
rendered.contains("token: None"),
"an unset token must stay visibly unset: {rendered}"
);
}
#[test]
fn debug_never_renders_either_bitbucket_secret() {
for_each_shape(|secret| {
let cfg = BitbucketConfig {
app_password: Some(secret.to_string()),
token: Some(secret.to_string()),
workspace: Some(PROBE.to_string()),
..BitbucketConfig::default()
};
format!("{cfg:?}{cfg:#?}")
});
for (secret, why) in SECRET_SHAPES {
let password_only = BitbucketConfig {
app_password: Some(secret.to_string()),
token: None,
workspace: Some(PROBE.to_string()),
..BitbucketConfig::default()
};
assert_masked(&format!("{password_only:?}"), secret, why);
let token_only = BitbucketConfig {
app_password: None,
token: Some(secret.to_string()),
workspace: Some(PROBE.to_string()),
..BitbucketConfig::default()
};
assert_masked(&format!("{token_only:?}"), secret, why);
}
let unset = BitbucketConfig {
workspace: Some(PROBE.to_string()),
..BitbucketConfig::default()
};
let rendered = format!("{unset:?}");
assert!(
rendered.contains("app_password: None") && rendered.contains("token: None"),
"unset secrets must stay visibly unset: {rendered}"
);
}
#[test]
fn debug_never_renders_the_azdo_pat() {
for_each_shape(|secret| {
let cfg = AzureDevOpsConfig {
organization_url: PROBE.to_string(),
pat: secret.to_string(),
project: None,
projects: Vec::new(),
ticket_regex: r"(?i)\bAB#(\d+)\b".to_string(),
team_keys: Vec::new(),
fetch_on_reference: true,
fetch_prs: false,
};
format!("{cfg:?}{cfg:#?}")
});
}
#[test]
fn debug_never_renders_the_openrouter_key_in_the_config_section() {
for_each_shape(|secret| {
let cfg = ClassificationConfig {
openrouter_api_key: Some(secret.to_string()),
llm_provider: PROBE.to_string(),
..ClassificationConfig::default()
};
format!("{cfg:?}{cfg:#?}")
});
let unset = ClassificationConfig {
openrouter_api_key: None,
llm_provider: PROBE.to_string(),
..ClassificationConfig::default()
};
let rendered = format!("{unset:?}");
assert!(
rendered.contains("openrouter_api_key: None"),
"an unset key must stay visibly unset: {rendered}"
);
}
#[test]
fn debug_never_renders_the_openrouter_key_in_the_engine_config() {
for_each_shape(|secret| {
let cfg = ClassificationEngineConfig {
openrouter_api_key: Some(secret.to_string()),
llm_provider: PROBE.to_string(),
..ClassificationEngineConfig::default()
};
format!("{cfg:?}{cfg:#?}")
});
let unset = ClassificationEngineConfig {
openrouter_api_key: None,
llm_provider: PROBE.to_string(),
..ClassificationEngineConfig::default()
};
let rendered = format!("{unset:?}");
assert!(
rendered.contains("openrouter_api_key: None"),
"an unset key must stay visibly unset: {rendered}"
);
}
}