use std::error::Error;
use std::path::Path;
use std::{fmt, io};
use serde::Deserialize;
const DEFAULT_TEMPLATE: &str = "\
# cargo-bench-history configuration.
#
# Stored at .cargo/bench_history.toml. Uncomment and edit as needed.
#
# Local filesystem storage is NOT configured here, because a local path is
# machine-dependent and this file is shared (version-controlled). Select local
# storage at run time instead, on any command:
#
# cargo bench-history collect --local=./bench-history
# cargo bench-history collect --local # path from CARGO_BENCH_HISTORY_STORAGE
# [project]
# id = \"my-project\" # defaults to the workspace directory name
# default_branch = \"main\" # base branch for `analyze`; auto-detected by default
# To store results in Azure Blob Storage, configure the cloud backend here.
# Authentication is always Microsoft Entra ID (OAuth): the endpoint must be
# HTTPS and the identity running the tool is granted data-plane access to the
# container. In CI, GitHub Actions federates into Azure without a stored secret;
# for setup, see
# https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-azure
#
# [storage.azure]
# account = \"mystorageaccount\"
# container = \"bench-history\"
# endpoint = \"https://mystorageaccount.blob.core.windows.net\" # optional
";
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct Config {
#[serde(default)]
pub project: ProjectConfig,
#[serde(default)]
pub storage: Option<CloudStorageConfig>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct ProjectConfig {
pub id: Option<String>,
pub default_branch: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum CloudStorageConfig {
Azure(AzureStorageConfig),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AzureStorageConfig {
pub account: String,
pub container: String,
#[serde(default)]
pub endpoint: Option<String>,
}
pub fn parse_config(text: &str) -> Result<Config, ConfigError> {
toml::from_str(text)
.map_err(|error| ConfigError::new(format!("failed to parse configuration: {error}")))
}
pub async fn load_config(path: &Path, explicit: bool) -> Result<Config, ConfigError> {
let text = match tokio::fs::read_to_string(path).await {
Ok(text) => text,
Err(error) if error.kind() == io::ErrorKind::NotFound && !explicit => {
return Ok(Config::default());
}
Err(error) => {
return Err(ConfigError::new(format!(
"failed to read configuration at {}: {error}",
path.display()
)));
}
};
parse_config(&text)
}
#[must_use]
pub fn default_template() -> &'static str {
DEFAULT_TEMPLATE
}
#[derive(Debug)]
pub struct ConfigError {
message: String,
}
impl ConfigError {
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl Error for ConfigError {}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
#[test]
fn default_template_has_no_engines_section() {
assert!(!default_template().contains("[engines"));
}
#[test]
fn default_template_documents_runtime_local_storage_selection() {
let template = default_template();
assert!(template.contains("--local"));
assert!(template.contains("CARGO_BENCH_HISTORY_STORAGE"));
}
#[test]
fn default_template_configures_no_active_storage() {
let config = parse_config(default_template()).unwrap();
assert_eq!(config.storage, None);
}
#[test]
fn config_without_storage_parses_with_none() {
let config = parse_config("[project]\nid = \"x\"\n").unwrap();
assert_eq!(config.storage, None);
}
#[test]
fn azure_storage_parses_account_container_and_endpoint() {
let text = "\
[storage.azure]
account = \"devstoreaccount1\"
container = \"bench-history\"
endpoint = \"https://127.0.0.1:10000/devstoreaccount1\"
";
let config = parse_config(text).unwrap();
let Some(CloudStorageConfig::Azure(azure)) = config.storage else {
panic!("expected azure storage, got {:?}", config.storage);
};
assert_eq!(azure.account, "devstoreaccount1");
assert_eq!(azure.container, "bench-history");
assert_eq!(
azure.endpoint.as_deref(),
Some("https://127.0.0.1:10000/devstoreaccount1")
);
}
#[test]
fn azure_storage_defaults_optional_fields_to_none() {
let text = "\
[storage.azure]
account = \"prod\"
container = \"history\"
";
let config = parse_config(text).unwrap();
let Some(CloudStorageConfig::Azure(azure)) = config.storage else {
panic!("expected azure storage, got {:?}", config.storage);
};
assert_eq!(azure.endpoint, None);
}
#[test]
fn azure_storage_rejects_a_legacy_account_key_field() {
let error = parse_config(
"[storage.azure]\naccount = \"a\"\ncontainer = \"c\"\naccount_key = \"a2V5\"\n",
)
.unwrap_err();
let message = error.to_string();
assert!(
message.contains("account_key"),
"unexpected parse error: {message}"
);
}
#[test]
fn azure_storage_rejects_a_legacy_sas_token_field() {
let error = parse_config(
"[storage.azure]\naccount = \"a\"\ncontainer = \"c\"\nsas_token = \"sig=x\"\n",
)
.unwrap_err();
let message = error.to_string();
assert!(
message.contains("sas_token"),
"unexpected parse error: {message}"
);
}
#[test]
fn project_id_defaults_to_none() {
let config = parse_config(default_template()).unwrap();
assert_eq!(config.project.id, None);
}
#[test]
fn explicit_project_id_is_parsed() {
let text = "\
[project]
id = \"folo\"
";
let config = parse_config(text).unwrap();
assert_eq!(config.project.id.as_deref(), Some("folo"));
}
#[test]
fn project_default_branch_is_parsed_and_defaults_to_none() {
assert_eq!(
parse_config(default_template())
.unwrap()
.project
.default_branch,
None
);
let text = "\
[project]
default_branch = \"trunk\"
";
let config = parse_config(text).unwrap();
assert_eq!(config.project.default_branch.as_deref(), Some("trunk"));
}
#[test]
fn unknown_section_is_ignored() {
let text = "\
[project]
id = \"x\"
[machine]
key = \"ci-pool-a\"
";
let config = parse_config(text).unwrap();
assert_eq!(config.storage, None);
}
#[test]
fn leftover_storage_local_table_is_rejected() {
let error = parse_config("[storage.local]\npath = \"x\"\n").unwrap_err();
let message = error.to_string();
assert!(
message.contains("unknown variant `local`"),
"unexpected parse error: {message}"
);
}
#[test]
fn config_error_display_is_the_message() {
let error = ConfigError::new("boom");
assert_eq!(error.to_string(), "boom");
}
#[tokio::test]
#[cfg_attr(miri, ignore)] async fn load_config_reads_and_parses() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bench_history.toml");
std::fs::write(
&path,
"[project]\nid = \"folo\"\n\n[storage.azure]\naccount = \"a\"\ncontainer = \"c\"\n",
)
.unwrap();
let config = load_config(&path, true).await.unwrap();
assert!(matches!(config.storage, Some(CloudStorageConfig::Azure(_))));
assert_eq!(config.project.id.as_deref(), Some("folo"));
}
#[tokio::test]
#[cfg_attr(miri, ignore)] async fn load_config_missing_explicit_file_is_read_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent.toml");
let error = load_config(&path, true).await.unwrap_err();
let message = error.to_string();
assert!(
message.contains("failed to read configuration"),
"{message}"
);
assert!(message.contains("absent.toml"), "{message}");
}
#[tokio::test]
#[cfg_attr(miri, ignore)] async fn load_config_missing_default_file_yields_empty_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent.toml");
let config = load_config(&path, false).await.unwrap();
assert_eq!(config, Config::default());
}
}