use crate::cli::OutputFormat;
use anyhow::{anyhow, Result};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
pub const DEFAULT_INCLUDE_CROSS_REPO_DEPS: bool = true;
fn default_include_cross_repo_deps() -> bool {
DEFAULT_INCLUDE_CROSS_REPO_DEPS
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct ReportingConfig {
#[serde(default)]
pub format: Option<OutputFormat>,
#[serde(default = "default_include_cross_repo_deps")]
pub include_cross_repo_deps: bool,
#[serde(default)]
pub exclude_patterns: Vec<String>,
}
impl Default for ReportingConfig {
fn default() -> Self {
Self {
format: None,
include_cross_repo_deps: DEFAULT_INCLUDE_CROSS_REPO_DEPS,
exclude_patterns: Vec::new(),
}
}
}
impl ReportingConfig {
pub fn merged_ignore(&self, extra_ignore: &[String]) -> Vec<String> {
let mut merged = Vec::with_capacity(extra_ignore.len() + self.exclude_patterns.len());
merged.extend_from_slice(extra_ignore);
merged.extend(self.exclude_patterns.iter().cloned());
merged
}
}
pub fn resolve_format(cli_format: Option<OutputFormat>, config: &ReportingConfig) -> OutputFormat {
cli_format
.or_else(|| config.format.clone())
.unwrap_or(OutputFormat::Terminal)
}
pub fn parse(section: Option<&toml::Value>) -> Result<ReportingConfig> {
let Some(section) = section else {
return Ok(ReportingConfig::default());
};
let table = section
.as_table()
.ok_or_else(|| anyhow!("[reporting] must be a table, got {}", section.type_str()))?;
let format = match table.get("format") {
None => None,
Some(value) => {
let name = value.as_str().ok_or_else(|| {
anyhow!(
"reporting.format must be a string, got {}",
value.type_str()
)
})?;
Some(parse_format(name)?)
}
};
let include_cross_repo_deps = match table.get("include_cross_repo_deps") {
None => DEFAULT_INCLUDE_CROSS_REPO_DEPS,
Some(value) => value.as_bool().ok_or_else(|| {
anyhow!(
"reporting.include_cross_repo_deps must be a boolean, got {}",
value.type_str()
)
})?,
};
let exclude_patterns = match table.get("exclude_patterns") {
None => Vec::new(),
Some(value) => {
let array = value.as_array().ok_or_else(|| {
anyhow!(
"reporting.exclude_patterns must be an array of strings, got {}",
value.type_str()
)
})?;
array
.iter()
.enumerate()
.map(|(idx, item)| {
item.as_str().map(String::from).ok_or_else(|| {
anyhow!(
"reporting.exclude_patterns[{}] must be a string, got {}",
idx,
item.type_str()
)
})
})
.collect::<Result<Vec<String>>>()?
}
};
Ok(ReportingConfig {
format,
include_cross_repo_deps,
exclude_patterns,
})
}
fn parse_format(name: &str) -> Result<OutputFormat> {
OutputFormat::from_str(name, true).map_err(|_| {
anyhow!(
"unknown reporting.format '{}' (valid: {})",
name,
valid_format_names().join(", ")
)
})
}
fn valid_format_names() -> Vec<String> {
OutputFormat::value_variants()
.iter()
.filter_map(|variant| {
variant
.to_possible_value()
.map(|value| value.get_name().to_string())
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn section(toml_text: &str) -> toml::Value {
toml_text.parse::<toml::Value>().unwrap()
}
fn parse_reporting(toml_text: &str) -> Result<ReportingConfig> {
let doc = section(toml_text);
parse(doc.get("reporting"))
}
#[test]
fn absent_section_yields_defaults() {
let config = parse_reporting("other = 1").unwrap();
assert_eq!(config, ReportingConfig::default());
assert_eq!(config.format, None);
assert!(config.include_cross_repo_deps);
assert!(config.exclude_patterns.is_empty());
}
#[test]
fn empty_section_yields_defaults() {
let config = parse_reporting("[reporting]").unwrap();
assert_eq!(config, ReportingConfig::default());
}
#[test]
fn parses_every_key() {
let config = parse_reporting(
r#"
[reporting]
format = "json"
include_cross_repo_deps = false
exclude_patterns = ["vendor/*", "examples/*"]
"#,
)
.unwrap();
assert_eq!(config.format, Some(OutputFormat::Json));
assert!(!config.include_cross_repo_deps);
assert_eq!(config.exclude_patterns, vec!["vendor/*", "examples/*"]);
}
#[test]
fn omitted_keys_fall_back_individually() {
let config = parse_reporting(
r#"
[reporting]
format = "markdown"
"#,
)
.unwrap();
assert_eq!(config.format, Some(OutputFormat::Markdown));
assert!(config.include_cross_repo_deps);
assert!(config.exclude_patterns.is_empty());
}
#[test]
fn unknown_keys_are_ignored() {
let config = parse_reporting(
r#"
[reporting]
format = "json"
future_option = "ignored"
nested = { also = "ignored" }
"#,
)
.unwrap();
assert_eq!(config.format, Some(OutputFormat::Json));
assert!(config.include_cross_repo_deps);
}
#[test]
fn unknown_format_value_is_rejected_and_lists_valid_names() {
let err = parse_reporting(
r#"
[reporting]
format = "jsonn"
"#,
)
.unwrap_err()
.to_string();
assert!(
err.contains("jsonn"),
"error should quote the bad value: {err}"
);
assert!(
err.contains("json"),
"error should list valid formats: {err}"
);
assert!(
err.contains("markdown"),
"error should list valid formats: {err}"
);
}
#[test]
fn format_must_be_a_string() {
let err = parse_reporting(
r#"
[reporting]
format = 42
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("reporting.format"), "{err}");
}
#[test]
fn include_cross_repo_deps_must_be_a_boolean() {
let err = parse_reporting(
r#"
[reporting]
include_cross_repo_deps = "yes"
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("reporting.include_cross_repo_deps"), "{err}");
}
#[test]
fn exclude_patterns_must_be_an_array_of_strings() {
let err = parse_reporting(
r#"
[reporting]
exclude_patterns = "vendor/*"
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("reporting.exclude_patterns"), "{err}");
let err = parse_reporting(
r#"
[reporting]
exclude_patterns = ["ok", 7]
"#,
)
.unwrap_err()
.to_string();
assert!(err.contains("reporting.exclude_patterns[1]"), "{err}");
}
#[test]
fn reporting_must_be_a_table() {
let err = parse_reporting(r#"reporting = "nope""#)
.unwrap_err()
.to_string();
assert!(err.contains("[reporting]"), "{err}");
}
#[test]
fn every_cli_format_value_is_accepted_in_config() {
for name in valid_format_names() {
let text = format!("[reporting]\nformat = \"{name}\"\n");
assert!(
parse_reporting(&text).is_ok(),
"config should accept --format value '{name}'"
);
}
}
#[test]
fn format_precedence_cli_beats_config_beats_default() {
let configured = ReportingConfig {
format: Some(OutputFormat::Json),
..ReportingConfig::default()
};
let unset = ReportingConfig::default();
assert_eq!(
resolve_format(Some(OutputFormat::Markdown), &configured),
OutputFormat::Markdown
);
assert_eq!(resolve_format(None, &configured), OutputFormat::Json);
assert_eq!(resolve_format(None, &unset), OutputFormat::Terminal);
assert_eq!(
resolve_format(Some(OutputFormat::Csv), &unset),
OutputFormat::Csv
);
}
#[test]
fn cli_can_select_the_default_format_over_a_config_value() {
let configured = ReportingConfig {
format: Some(OutputFormat::Json),
..ReportingConfig::default()
};
assert_eq!(
resolve_format(Some(OutputFormat::Terminal), &configured),
OutputFormat::Terminal
);
}
#[test]
fn merged_ignore_appends_to_existing_globs() {
let config = ReportingConfig {
exclude_patterns: vec!["vendor/*".to_string()],
..ReportingConfig::default()
};
let merged = config.merged_ignore(&["examples/*".to_string()]);
assert_eq!(merged, vec!["examples/*", "vendor/*"]);
}
#[test]
fn merged_ignore_is_additive_not_overriding() {
let config = ReportingConfig {
exclude_patterns: vec!["a/*".to_string(), "b/*".to_string()],
..ReportingConfig::default()
};
let merged = config.merged_ignore(&["cli/*".to_string()]);
assert!(merged.contains(&"cli/*".to_string()));
assert!(merged.contains(&"a/*".to_string()));
assert!(merged.contains(&"b/*".to_string()));
assert_eq!(merged.len(), 3);
}
#[test]
fn merged_ignore_handles_empty_sides() {
let empty = ReportingConfig::default();
assert!(empty.merged_ignore(&[]).is_empty());
assert_eq!(empty.merged_ignore(&["x".to_string()]), vec!["x"]);
let config = ReportingConfig {
exclude_patterns: vec!["y".to_string()],
..ReportingConfig::default()
};
assert_eq!(config.merged_ignore(&[]), vec!["y"]);
}
#[test]
fn merged_ignore_keeps_duplicates_verbatim() {
let config = ReportingConfig {
exclude_patterns: vec!["vendor/*".to_string()],
..ReportingConfig::default()
};
let merged = config.merged_ignore(&["vendor/*".to_string()]);
assert_eq!(merged, vec!["vendor/*", "vendor/*"]);
}
}