use std::path::Path;
use super::error::Error;
use super::identity::AuthorHashKey;
pub(crate) const SECONDS_PER_DAY: i64 = 86_400;
const SECONDS_PER_WEEK: i64 = 7 * SECONDS_PER_DAY;
const SECONDS_PER_MONTH: i64 = 2_629_746;
const SECONDS_PER_YEAR: i64 = 31_556_952;
pub const DEFAULT_LONG_WINDOW: &str = "12mo";
pub const DEFAULT_RECENT_WINDOW: &str = "90d";
const WINDOW_FORMAT_HINT: &str =
"expected <N>d|w|mo|y or an ISO 8601 duration, e.g. 12mo, 90d, or P1Y6M";
pub const DEFAULT_BUS_FACTOR_THRESHOLD: f64 = super::bus_factor::DEFAULT_COVERAGE_THRESHOLD;
pub const DEFAULT_BOT_PATTERN: &str = r"dependabot\[bot\]|renovate\[bot\]|github-actions\[bot\]|pre-commit-ci\[bot\]|mergify\[bot\]|pyup-bot";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RiskFormula {
#[default]
Weighted,
Percentile,
}
impl std::str::FromStr for RiskFormula {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
match s {
"weighted" => Ok(Self::Weighted),
"percentile" => Ok(Self::Percentile),
other => Err(Error::InvalidFormula(other.to_owned())),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum FileTypeScope {
#[default]
Metrics,
All,
Custom(Vec<String>),
}
impl FileTypeScope {
#[must_use]
pub fn includes(&self, path: &Path) -> bool {
match self {
Self::All => true,
Self::Metrics => crate::get_language_for_file(path).is_some(),
Self::Custom(extensions) => path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
extensions
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(ext))
}),
}
}
fn from_extensions(list: &str) -> Result<Self, Error> {
let mut extensions: Vec<String> = Vec::new();
for raw in list.split(',') {
let normalized = raw.trim().trim_start_matches('.').to_lowercase();
if normalized.is_empty() {
continue;
}
if normalized.contains('.') {
return Err(Error::InvalidFileTypeScope(format!(
"{:?} is a multi-dot suffix; `Path::extension()` only \
matches the final component, so it would rank no files",
raw.trim()
)));
}
if !extensions.contains(&normalized) {
extensions.push(normalized);
}
}
if extensions.is_empty() {
return Err(Error::InvalidFileTypeScope(format!(
"{list:?} lists no usable file extensions"
)));
}
Ok(Self::Custom(extensions))
}
}
impl std::str::FromStr for FileTypeScope {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
match s.trim() {
"" => Err(Error::InvalidFileTypeScope("the value is empty".to_owned())),
"metrics" => Ok(Self::Metrics),
"all" => Ok(Self::All),
list => Self::from_extensions(list),
}
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Options {
pub long_window_secs: i64,
pub recent_window_secs: i64,
pub reference: String,
pub full_history: bool,
pub include_merges: bool,
pub follow_renames: bool,
pub exclude_bots: bool,
pub bot_pattern: String,
pub as_of: Option<i64>,
pub risk_formula: RiskFormula,
pub emit_author_details: bool,
pub author_hash_key: Option<AuthorHashKey>,
pub include_deleted: bool,
pub compute_bus_factor: bool,
pub bus_factor_threshold: f64,
pub file_types: FileTypeScope,
}
impl Default for Options {
fn default() -> Self {
Self {
long_window_secs: parse_window(DEFAULT_LONG_WINDOW)
.expect("DEFAULT_LONG_WINDOW parses"),
recent_window_secs: parse_window(DEFAULT_RECENT_WINDOW)
.expect("DEFAULT_RECENT_WINDOW parses"),
reference: "HEAD".to_owned(),
full_history: false,
include_merges: false,
follow_renames: true,
exclude_bots: true,
bot_pattern: DEFAULT_BOT_PATTERN.to_owned(),
as_of: None,
risk_formula: RiskFormula::Weighted,
emit_author_details: false,
author_hash_key: None,
include_deleted: false,
compute_bus_factor: false,
bus_factor_threshold: DEFAULT_BUS_FACTOR_THRESHOLD,
file_types: FileTypeScope::Metrics,
}
}
}
impl Options {
#[must_use]
pub fn long_window_days(&self) -> u32 {
secs_to_days(self.long_window_secs)
}
#[must_use]
pub fn recent_window_days(&self) -> u32 {
secs_to_days(self.recent_window_secs)
}
}
fn secs_to_days(secs: i64) -> u32 {
let days = secs.saturating_add(SECONDS_PER_DAY / 2) / SECONDS_PER_DAY;
u32::try_from(days.max(0)).unwrap_or(u32::MAX)
}
pub fn validate_bus_factor_threshold(threshold: f64) -> Result<f64, Error> {
if threshold.is_finite() && threshold > 0.0 && threshold < 1.0 {
Ok(threshold)
} else {
Err(Error::InvalidBusFactorThreshold(format!(
"{threshold} is not in the open interval (0, 1)"
)))
}
}
pub fn parse_window(spec: &str) -> Result<i64, Error> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return Err(window_error(spec, "is empty"));
}
if let Some(rest) = trimmed.strip_prefix(['P', 'p']) {
return parse_iso8601(rest, spec);
}
let split = trimmed
.find(|c: char| c.is_ascii_alphabetic())
.ok_or_else(|| window_error(spec, "has no unit"))?;
let (number, unit) = trimmed.split_at(split);
let magnitude: i64 = number
.trim()
.parse()
.map_err(|_| window_error(spec, "has a non-numeric magnitude"))?;
let factor = unit_factor(unit)
.ok_or_else(|| window_error(spec, &format!("has an unknown unit {unit:?}")))?;
checked_window(magnitude, factor, spec)
}
fn window_error(spec: &str, problem: &str) -> Error {
Error::InvalidWindow(format!("{spec:?} {problem} ({WINDOW_FORMAT_HINT})"))
}
fn unit_factor(unit: &str) -> Option<i64> {
match unit {
"d" => Some(SECONDS_PER_DAY),
"w" => Some(SECONDS_PER_WEEK),
"mo" => Some(SECONDS_PER_MONTH),
"y" => Some(SECONDS_PER_YEAR),
_ => None,
}
}
fn parse_iso8601(body: &str, original: &str) -> Result<i64, Error> {
if body.is_empty() {
return Err(window_error(original, "has no fields"));
}
let mut total: i64 = 0;
let mut digits = String::new();
for ch in body.chars() {
if ch.is_ascii_digit() {
digits.push(ch);
continue;
}
if digits.is_empty() {
return Err(window_error(
original,
&format!("field {ch:?} has no magnitude"),
));
}
let magnitude: i64 = digits
.parse()
.map_err(|_| window_error(original, "has a non-numeric magnitude"))?;
digits.clear();
let factor = match ch {
'Y' => SECONDS_PER_YEAR,
'M' => SECONDS_PER_MONTH,
'W' => SECONDS_PER_WEEK,
'D' => SECONDS_PER_DAY,
_ => {
return Err(window_error(
original,
&format!("has an unsupported ISO 8601 designator {ch:?}"),
));
}
};
total = total
.checked_add(
magnitude
.checked_mul(factor)
.ok_or_else(|| window_error(original, "overflows"))?,
)
.ok_or_else(|| window_error(original, "overflows"))?;
}
if !digits.is_empty() {
return Err(window_error(
original,
&format!("ends with a magnitude {digits:?} lacking a designator"),
));
}
reject_non_positive(total, original)
}
fn checked_window(magnitude: i64, factor: i64, spec: &str) -> Result<i64, Error> {
if magnitude < 0 {
return Err(window_error(spec, "is negative"));
}
let product = magnitude
.checked_mul(factor)
.ok_or_else(|| window_error(spec, "overflows"))?;
reject_non_positive(product, spec)
}
fn reject_non_positive(seconds: i64, spec: &str) -> Result<i64, Error> {
if seconds <= 0 {
return Err(window_error(spec, "is not a positive duration"));
}
Ok(seconds)
}
#[cfg(test)]
#[path = "options_tests.rs"]
mod tests;