use std::collections::BTreeMap;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use crate::decision::{DecisionKind, Severity};
use crate::plugin::dsl::WhenNode;
pub mod merge;
pub mod repo;
pub mod schema;
pub mod scope;
pub mod yaml;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Enforce,
Monitor,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub mode: Mode,
pub fail_closed: bool,
pub pack_overrides: BTreeMap<String, PackOverride>,
pub rule_overrides: BTreeMap<String, RuleOverride>,
pub allowlists: Vec<Allowlist>,
pub plugin_paths: Vec<PathBuf>,
pub audit: AuditConfig,
pub protected_branches: Vec<String>,
pub additional_workspaces: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
let mut pack_overrides: BTreeMap<String, PackOverride> = BTreeMap::new();
pack_overrides.insert(
"core.project_hygiene".to_string(),
PackOverride {
enabled: Some(false),
},
);
pack_overrides.insert(
"core.workspace".to_string(),
PackOverride {
enabled: Some(false),
},
);
Self {
mode: Mode::default(),
fail_closed: true,
pack_overrides,
rule_overrides: BTreeMap::new(),
allowlists: Vec::new(),
plugin_paths: Vec::new(),
audit: AuditConfig::default(),
protected_branches: vec!["main".into(), "master".into(), "release/*".into()],
additional_workspaces: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PackOverride {
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuleOverride {
pub enabled: Option<bool>,
pub decision: Option<DecisionKind>,
pub severity: Option<Severity>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Allowlist {
pub id: String,
pub rule_ids: Vec<String>,
pub when: Option<WhenNode>,
pub expires_at: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuditConfig {
pub enabled: bool,
pub path: Option<PathBuf>,
pub include_allowed: bool,
pub include_denied: bool,
pub redaction: RedactionMode,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: true,
path: None,
include_allowed: false,
include_denied: true,
redaction: RedactionMode::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RedactionMode {
#[default]
Strict,
Off,
}
pub fn default_audit_path() -> Option<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| home.join(".local/share/ptuf/audit.jsonl"))
}
pub fn resolved_audit_path(config: &Config) -> Option<PathBuf> {
if !config.audit.enabled {
return None;
}
config.audit.path.clone().or_else(default_audit_path)
}
#[derive(Debug)]
pub enum ConfigError {
Io { path: PathBuf, source: io::Error },
Yaml { path: PathBuf, message: String },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io { path, source } => {
write!(f, "failed to read {}: {}", path.display(), source)
},
Self::Yaml { path, message } => {
write!(f, "failed to parse {}: {}", path.display(), message)
},
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Yaml { .. } => None,
}
}
}
pub fn load_for(repo_root: Option<&Path>) -> Result<Config, ConfigError> {
load_with_layout(scope::default_layout(repo_root))
}
pub fn load_with_layout(layout: scope::Layout) -> Result<Config, ConfigError> {
let mut layers = Vec::new();
for path in layout.ordered_paths() {
if !path.is_file() {
continue;
}
let raw = yaml::load_path(&path)?;
layers.push(raw);
}
Ok(merge::merge(layers))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_error_display_io_includes_path_and_source() {
let err = ConfigError::Io {
path: PathBuf::from("/etc/ptuf/policy.yaml"),
source: io::Error::other("nope"),
};
let msg = format!("{err}");
assert!(msg.contains("/etc/ptuf/policy.yaml"));
assert!(msg.contains("nope"));
}
#[test]
fn config_error_display_yaml_includes_path_and_message() {
let err = ConfigError::Yaml {
path: PathBuf::from("/repo/.ptuf.yaml"),
message: "bad indent".into(),
};
let msg = format!("{err}");
assert!(msg.contains("/repo/.ptuf.yaml"));
assert!(msg.contains("bad indent"));
}
#[test]
fn config_error_source_maps_variants() {
let io_err = ConfigError::Io {
path: PathBuf::from("/x"),
source: io::Error::other("boom"),
};
let dyn_io: &dyn std::error::Error = &io_err;
assert!(dyn_io.source().is_some());
let yaml_err = ConfigError::Yaml {
path: PathBuf::from("/x"),
message: "broken".into(),
};
let dyn_yaml: &dyn std::error::Error = &yaml_err;
assert!(dyn_yaml.source().is_none());
}
#[test]
fn load_with_layout_reads_existing_files_and_merges() {
let dir = std::env::temp_dir().join(format!(
"ptuf-config-load-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(&dir).expect("mkdir");
let user = dir.join("user.yaml");
std::fs::write(&user, "mode: monitor\n").expect("write");
let layout = scope::Layout {
system: None,
user: Some(user.clone()),
project: None,
project_local: None,
};
let config = load_with_layout(layout).expect("load");
assert_eq!(config.mode, Mode::Monitor);
let _ = std::fs::remove_file(&user);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn load_with_layout_with_no_files_returns_default_config() {
let layout = scope::Layout {
system: Some(PathBuf::from("/nonexistent/does-not-exist.yaml")),
user: None,
project: None,
project_local: None,
};
let config = load_with_layout(layout).expect("load");
assert_eq!(config, Config::default());
}
}