pub mod settings;
pub mod usages;
pub mod v1;
pub use settings::{Settings, SettingsLayer};
use codespan_reporting::diagnostic::{Diagnostic, Label};
use globetrotter_model::diagnostics::{DiagnosticExt, Span, ToDiagnostics};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use yaml_spanned::Value;
#[derive(
Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize, Default,
)]
pub enum Version {
#[serde(rename = "1", alias = "v1", alias = "V1")]
V1,
#[serde(rename = "latest")]
#[default]
Latest,
}
pub fn config_file_names() -> impl Iterator<Item = &'static str> {
[".globetrotter.yaml", "globetrotter.yaml"].into_iter()
}
pub async fn find_config_file(dir: &Path) -> std::io::Result<Option<PathBuf>> {
for path in config_file_names().map(|name| dir.join(name)) {
match tokio::fs::canonicalize(&path).await {
Ok(path) => return Ok(Some(path)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
}
Err(err) => return Err(err),
}
}
Ok(None)
}
pub fn from_str<F: Copy + PartialEq>(
raw_config: &str,
config_dir: &Path,
file_id: F,
strict: Option<bool>,
diagnostics: &mut Vec<Diagnostic<F>>,
) -> Result<v1::Configs<F>, ConfigError> {
let value = yaml_spanned::from_str(raw_config).map_err(ConfigError::YAML)?;
let version = parse_version(&value, file_id, strict, diagnostics)?;
match version {
Version::Latest | Version::V1 => {
v1::parse_configs(&value, config_dir, file_id, strict, diagnostics)
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("{message}")]
MissingKey {
key: String,
message: String,
span: Span,
},
#[error("{message}")]
UnexpectedType {
message: String,
expected: Vec<yaml_spanned::value::Kind>,
found: yaml_spanned::value::Kind,
span: Span,
},
#[error("invalid `allow` entry `{entry}`: {source}")]
InvalidAllowEntry {
entry: String,
#[source]
source: globetrotter_model::lint::ParseAllowEntryError,
span: Span,
},
#[error("{source}")]
Serde {
#[source]
source: yaml_spanned::error::SerdeError,
span: Span,
},
#[error(transparent)]
YAML(#[from] yaml_spanned::Error),
}
impl ToDiagnostics for ConfigError {
fn to_diagnostics<F: Copy + PartialEq>(&self, file_id: F) -> Vec<Diagnostic<F>> {
match self {
Self::MissingKey {
message, key, span, ..
} => vec![
Diagnostic::error()
.with_message(format!("missing required key `{key}`"))
.with_labels(vec![
Label::secondary(file_id, span.clone()).with_message(message),
]),
],
Self::UnexpectedType {
expected,
found,
span,
..
} => {
let expected = expected
.iter()
.map(|ty| format!("`{ty:?}`"))
.collect::<Vec<_>>()
.join(", or ");
let diagnostic = Diagnostic::error()
.with_message(self.to_string())
.with_labels(vec![
Label::primary(file_id, span.clone())
.with_message(format!("expected {expected}")),
])
.with_notes(vec![indoc::formatdoc!(
"
expected type {expected}
found type `{found:?}`
"
)]);
vec![diagnostic]
}
Self::InvalidAllowEntry {
entry,
source,
span,
} => vec![
Diagnostic::error()
.with_message(self.to_string())
.with_labels(vec![
Label::primary(file_id, span.clone()).with_message(source.to_string()),
])
.with_notes(vec![source.note(entry)]),
],
Self::Serde { source, span } => vec![
Diagnostic::error()
.with_message(self.to_string())
.with_labels(vec![
Label::primary(file_id, span.clone()).with_message(source.to_string()),
]),
],
Self::YAML(source) => {
use yaml_spanned::error::ToDiagnostics;
source.to_diagnostics(file_id)
}
}
}
}
pub fn parse_version<F>(
value: &yaml_spanned::Spanned<Value>,
file_id: F,
strict: Option<bool>,
diagnostics: &mut Vec<Diagnostic<F>>,
) -> Result<Version, ConfigError> {
match value.get("version") {
None => {
let diagnostic = Diagnostic::warning_or_error(strict.unwrap_or(false))
.with_message("missing version")
.with_labels(vec![
Label::primary(file_id, value.span)
.with_message("no version is specified - assuming version 1"),
]);
diagnostics.push(diagnostic);
Ok(Version::Latest)
}
Some(yaml_spanned::Spanned {
inner: Value::Number(n),
..
}) if n.as_f64() == Some(1.0) => Ok(Version::V1),
Some(value) => {
let version = v1::parse::<Version>(value)?;
Ok(version.into_inner())
}
}
}
#[cfg(test)]
mod tests {
use super::ConfigError;
use color_eyre::eyre;
use similar_asserts::assert_eq as sim_assert_eq;
use yaml_spanned::{Spanned, Value};
#[test_util::test]
fn parses_settings_keys_and_aliases() -> eyre::Result<()> {
use globetrotter_model::{TemplateEngine, diagnostics::Spanned};
let raw = indoc::indoc! {r#"
version: 1
config:
languages: ["en"]
engine: handlebars
strict: true
check_templates: false
dry_run: true
absolute: true
inputs:
- ./translations/a.toml
outputs:
json:
- ./out/{{language}}.json
"#};
let mut diagnostics = vec![];
let configs = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics)?;
sim_assert_eq!(
have: configs[0].config.settings,
want: super::SettingsLayer {
strict: Some(true),
check_templates: Some(false),
dry_run: Some(true),
print_absolute_paths: Some(true),
template_engine: Some(Spanned::dummy(TemplateEngine::Handlebars)),
}
);
Ok(())
}
#[test_util::test]
fn parses_config_allow_list() -> eyre::Result<()> {
use globetrotter_model::lint::{AllowEntry, LintCode};
let raw = indoc::indoc! {r#"
version: 1
config:
languages: ["en"]
allow: ["lint:duplicate", "lint:llm-drift"]
inputs:
- ./translations/a.toml
outputs:
json:
- ./out/{{language}}.json
"#};
let mut diagnostics = vec![];
let configs = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics)?;
sim_assert_eq!(
have: configs[0].config.allow,
want: [
AllowEntry::Code(LintCode::Duplicate),
AllowEntry::Code(LintCode::LlmDrift),
]
.into_iter()
.collect()
);
Ok(())
}
#[test_util::test]
fn warns_about_missing_configurations() -> eyre::Result<()> {
use codespan_reporting::diagnostic::Severity;
let raw = indoc::indoc! {"
version: 1
configuration: {}
"};
let mut diagnostics = vec![];
let configs = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics)?;
assert!(configs.is_empty());
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(diagnostics[0].severity, Severity::Warning);
assert_eq!(diagnostics[0].message, "empty configurations");
Ok(())
}
#[test_util::test]
fn parses_typed_output_paths() -> eyre::Result<()> {
let raw = indoc::indoc! {"
version: 1
config:
languages: [en]
inputs: [./translations.toml]
outputs:
json: ./out/{{language}}.json
rust: ./out/translations.rs
golang: [./out/a.go, ./out/b.go]
python: ./out/translations.py
"};
let mut diagnostics = vec![];
let configs = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics)?;
let outputs = &configs[0].config.outputs;
#[cfg(feature = "rust")]
sim_assert_eq!(
have: outputs.rust.as_ref().map(|rust| rust.output_paths.clone()),
want: Some(vec![std::path::PathBuf::from("./out/translations.rs")])
);
#[cfg(feature = "golang")]
sim_assert_eq!(
have: outputs.golang.as_ref().map(|go| go.output_paths.clone()),
want: Some(vec![
std::path::PathBuf::from("./out/a.go"),
std::path::PathBuf::from("./out/b.go"),
])
);
#[cfg(feature = "python")]
sim_assert_eq!(
have: outputs.python.as_ref().map(|python| python.output_paths.clone()),
want: Some(vec![std::path::PathBuf::from("./out/translations.py")])
);
assert!(!outputs.is_empty());
Ok(())
}
#[test_util::test]
fn parses_exclude_as_string_or_sequence() -> eyre::Result<()> {
let raw = indoc::indoc! {"
version: 1
configs:
app:
languages: [en]
inputs:
- path: ./translations/**/*.toml
exclude: ./translations/drafts/*.toml
- path: ./translations/**/*.toml
exclude:
- ./translations/drafts/*.toml
- ./translations/legacy.toml
outputs:
json: ./out/{{language}}.json
"};
let mut diagnostics = vec![];
let configs = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics)?;
let excludes: Vec<Vec<&str>> = configs[0]
.config
.inputs
.iter()
.map(|input| {
input
.exclude
.iter()
.map(|pattern| pattern.as_ref().as_str())
.collect()
})
.collect();
sim_assert_eq!(
have: excludes,
want: vec![
vec!["./translations/drafts/*.toml"],
vec!["./translations/drafts/*.toml", "./translations/legacy.toml"],
]
);
Ok(())
}
#[test_util::test]
fn rejects_unprefixed_config_allow_entry() {
use globetrotter_model::lint::ParseAllowEntryError;
let raw = indoc::indoc! {r#"
version: 1
config:
languages: ["en"]
allow: ["duplicate"]
"#};
let mut diagnostics = vec![];
let result = super::from_str(raw, std::path::Path::new("."), (), None, &mut diagnostics);
assert!(
matches!(
&result,
Err(ConfigError::InvalidAllowEntry {
entry,
source: ParseAllowEntryError::MissingPrefix,
..
}) if entry == "duplicate"
),
"{result:?}"
);
}
#[test_util::test]
fn test_parse_version() -> eyre::Result<()> {
fn parse_version_wrapper(
value: impl Into<Spanned<Value>>,
strict: bool,
) -> Result<super::Version, ConfigError> {
let mut diagnostics = vec![];
super::parse_version(&value.into(), (), Some(strict), &mut diagnostics)
}
let have = parse_version_wrapper(
Value::Mapping([("version".into(), 1.into())].into_iter().collect()),
true,
)?;
sim_assert_eq!(
have: have,
want: super::Version::V1
);
let have = parse_version_wrapper(
Value::Mapping([("version".into(), "1".into())].into_iter().collect()),
true,
)?;
sim_assert_eq!(
have: have,
want: super::Version::V1
);
let have = parse_version_wrapper(
Value::Mapping([("version".into(), "v1".into())].into_iter().collect()),
true,
)?;
sim_assert_eq!(
have: have,
want: super::Version::V1
);
Ok(())
}
}