use crate::domain::lock::Lock;
use crate::domain::manifest::Manifest;
use crate::infra::lock::{Error as LockFileError, LOCK_FILE_NAME, Store as LockStore};
use crate::infra::manifest::{Error as ManifestError, MANIFEST_FILE_NAME, parse_lint_config};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::env;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error(transparent)]
Manifest(#[from] ManifestError),
#[error(transparent)]
Lock(#[from] LockFileError), }
#[derive(Debug, Clone, Default)]
pub struct Settings {
pub github_token: Option<GitHubToken>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Level {
Error,
Warn,
Off,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IgnoreTarget {
pub action: Option<String>,
pub workflow: Option<String>,
pub job: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
pub level: Level,
#[serde(default)]
pub ignore: Vec<IgnoreTarget>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Lint {
#[serde(default)]
pub rules: BTreeMap<crate::lint::RuleName, Rule>,
}
impl Lint {
#[must_use]
pub fn get_rule(&self, name: crate::lint::RuleName, default_level: Level) -> Rule {
self.rules.get(&name).cloned().unwrap_or(Rule {
level: default_level,
ignore: Vec::new(),
})
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct GitHubToken(String);
impl GitHubToken {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for GitHubToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("GitHubToken(***)")
}
}
impl From<String> for GitHubToken {
fn from(s: String) -> Self {
Self(s)
}
}
#[derive(Debug)]
pub struct Config {
pub settings: Settings,
pub manifest: Manifest,
pub lock: Lock,
pub lint_config: Lint,
pub manifest_path: PathBuf,
pub lock_path: PathBuf,
pub manifest_migrated: bool,
}
impl Settings {
#[must_use]
pub fn from_env() -> Self {
Self {
github_token: env::var("GITHUB_TOKEN").ok().map(GitHubToken::from),
}
}
}
impl Config {
pub fn load(repo_root: &Path) -> Result<Self, Error> {
let manifest_path = repo_root.join(".github").join(MANIFEST_FILE_NAME);
let lock_path = repo_root.join(".github").join(LOCK_FILE_NAME);
let parsed_manifest = crate::infra::manifest::parse(&manifest_path)?;
let lock_store = LockStore::new(&lock_path);
let lock = lock_store.load()?;
Ok(Self {
settings: Settings::from_env(),
manifest: parsed_manifest.value,
manifest_migrated: parsed_manifest.migrated,
lock,
lint_config: parse_lint_config(&manifest_path)?,
manifest_path,
lock_path,
})
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::{
Config, Deserialize, GitHubToken, IgnoreTarget, Level, Lint, Lock, Manifest, PathBuf, Rule,
Settings,
};
#[derive(Deserialize)]
struct LevelWrapper {
level: Level,
}
#[test]
fn settings_default_has_no_token() {
let settings = Settings::default();
assert!(settings.github_token.is_none());
}
#[test]
fn app_config_can_be_constructed_directly() {
let config = Config {
settings: Settings {
github_token: Some(GitHubToken::from("test_token".to_owned())),
},
manifest: Manifest::default(),
lock: Lock::default(),
lint_config: Lint::default(),
manifest_path: PathBuf::from("gx.toml"),
lock_path: PathBuf::from("gx.lock"),
manifest_migrated: false,
};
assert_eq!(
config
.settings
.github_token
.as_ref()
.map(GitHubToken::as_str),
Some("test_token")
);
}
#[test]
fn app_config_load_returns_defaults_for_missing_files() {
let dir = tempfile::tempdir().unwrap();
let config = Config::load(dir.path()).unwrap();
assert!(config.settings.github_token.is_none() || config.settings.github_token.is_some());
assert!(config.manifest.specs().next().is_none());
assert!(config.lock.is_empty());
assert!(config.manifest_path.ends_with("gx.toml"));
assert!(config.lock_path.ends_with("gx.lock"));
}
#[test]
fn level_deserializes_from_string() {
assert_eq!(
toml::from_str::<LevelWrapper>("level = \"error\"")
.unwrap()
.level,
Level::Error
);
assert_eq!(
toml::from_str::<LevelWrapper>("level = \"warn\"")
.unwrap()
.level,
Level::Warn
);
assert_eq!(
toml::from_str::<LevelWrapper>("level = \"off\"")
.unwrap()
.level,
Level::Off
);
}
#[test]
fn level_rejects_invalid_values() {
assert!(toml::from_str::<LevelWrapper>("level = \"invalid\"").is_err());
}
#[test]
fn rule_config_parses_with_level_only() {
let toml_str = r#"
level = "error"
"#;
let config: Rule = toml::from_str(toml_str).unwrap();
assert_eq!(config.level, Level::Error);
assert!(config.ignore.is_empty());
}
#[test]
fn rule_config_parses_with_ignore_targets() {
let toml_str = r#"
level = "warn"
ignore = [
{ action = "actions/checkout" },
{ workflow = ".github/workflows/ci.yml" },
]
"#;
let config: Rule = toml::from_str(toml_str).unwrap();
assert_eq!(config.level, Level::Warn);
assert_eq!(config.ignore.len(), 2);
assert_eq!(config.ignore[0].action, Some("actions/checkout".to_owned()));
assert_eq!(
config.ignore[1].workflow,
Some(".github/workflows/ci.yml".to_owned())
);
}
#[test]
fn ignore_target_with_intersection() {
let toml_str = r#"
action = "actions/checkout"
workflow = ".github/workflows/ci.yml"
job = "build"
"#;
let target: IgnoreTarget = toml::from_str(toml_str).unwrap();
assert_eq!(target.action, Some("actions/checkout".to_owned()));
assert_eq!(target.workflow, Some(".github/workflows/ci.yml".to_owned()));
assert_eq!(target.job, Some("build".to_owned()));
}
#[test]
fn lint_config_parses_multiple_rules() {
let toml_str = r#"
[rules]
sha-mismatch = { level = "error" }
unpinned = { level = "error", ignore = [{ action = "actions/internal-tool" }] }
stale-comment = { level = "off" }
"#;
let config: Lint = toml::from_str(toml_str).unwrap();
assert_eq!(config.rules.len(), 3);
assert_eq!(
config.rules[&crate::lint::RuleName::ShaMismatch].level,
Level::Error
);
assert_eq!(
config.rules[&crate::lint::RuleName::Unpinned].level,
Level::Error
);
assert_eq!(
config.rules[&crate::lint::RuleName::Unpinned].ignore.len(),
1
);
assert_eq!(
config.rules[&crate::lint::RuleName::StaleComment].level,
Level::Off
);
}
#[test]
fn lint_config_default_is_empty() {
let config = Lint::default();
assert!(config.rules.is_empty());
}
#[test]
fn lint_config_get_rule_uses_default_when_unconfigured() {
let config = Lint::default();
let rule = config.get_rule(crate::lint::RuleName::ShaMismatch, Level::Error);
assert_eq!(rule.level, Level::Error);
assert!(rule.ignore.is_empty());
}
#[test]
fn lint_config_get_rule_returns_configured_value() {
let mut config = Lint::default();
config.rules.insert(
crate::lint::RuleName::Unpinned,
Rule {
level: Level::Warn,
ignore: vec![IgnoreTarget {
action: Some("actions/checkout".to_owned()),
workflow: None,
job: None,
}],
},
);
let rule = config.get_rule(crate::lint::RuleName::Unpinned, Level::Error);
assert_eq!(rule.level, Level::Warn);
assert_eq!(rule.ignore.len(), 1);
}
#[test]
fn lint_config_get_rule_respects_off_level() {
let mut config = Lint::default();
config.rules.insert(
crate::lint::RuleName::StaleComment,
Rule {
level: Level::Off,
ignore: vec![],
},
);
let rule = config.get_rule(crate::lint::RuleName::StaleComment, Level::Warn);
assert_eq!(rule.level, Level::Off);
}
}