use super::tracing::{SourceType, TracedProdigyConfig};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IssueSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigIssue {
#[serde(rename = "type")]
pub issue_type: IssueType,
pub path: String,
pub severity: IssueSeverity,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<String>,
}
impl ConfigIssue {
pub fn display(&self) -> String {
let severity_icon = match self.severity {
IssueSeverity::Info => "ℹ",
IssueSeverity::Warning => "⚠",
IssueSeverity::Error => "✗",
};
let mut output = format!("{} {}", severity_icon, self.message);
if let Some(ref suggestion) = self.suggestion {
output.push_str(&format!("\n Suggestion: {}", suggestion));
}
output
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueType {
EmptyEnvVar,
MultipleOverrides,
EnvOverridesFile,
DefaultValue,
RelativePathAmbiguity,
}
pub fn detect_issues(traced: &TracedProdigyConfig) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
for (path, trace) in traced.all_traces() {
if let SourceType::Environment = trace.final_source.source_type {
if trace.final_value == serde_json::Value::String(String::new()) {
issues.push(ConfigIssue {
issue_type: IssueType::EmptyEnvVar,
path: path.clone(),
severity: IssueSeverity::Warning,
message: format!(
"{} is set but empty from environment variable {}",
path,
trace.final_source.display()
),
suggestion: Some(format!(
"Unset the variable or provide a value: unset {}",
trace
.final_source
.source
.strip_prefix("env:")
.unwrap_or(&trace.final_source.source)
)),
});
}
}
if trace.source_count() > 2 {
let sources: Vec<String> = trace.history.iter().map(|h| h.source.display()).collect();
issues.push(ConfigIssue {
issue_type: IssueType::MultipleOverrides,
path: path.clone(),
severity: IssueSeverity::Info,
message: format!(
"\"{}\" was set in {} places: {}",
path,
trace.source_count(),
sources.join(" → ")
),
suggestion: Some("Review if all overrides are intentional".to_string()),
});
}
if trace.was_overridden() {
let has_file_source = trace
.history
.iter()
.any(|h| h.source.source_type == SourceType::File && h.overridden);
let final_is_env = trace.final_source.source_type == SourceType::Environment;
if has_file_source && final_is_env {
issues.push(ConfigIssue {
issue_type: IssueType::EnvOverridesFile,
path: path.clone(),
severity: IssueSeverity::Info,
message: format!(
"\"{}\" is set in config file but overridden by {}",
path,
trace.final_source.display()
),
suggestion: None,
});
}
}
}
issues
}
pub fn format_issues(issues: &[ConfigIssue]) -> String {
if issues.is_empty() {
return "No configuration issues detected.".to_string();
}
let mut output = String::from("Configuration issues detected:\n\n");
for issue in issues {
output.push_str(&issue.display());
output.push_str("\n\n");
}
output
}
pub fn format_issues_json(issues: &[ConfigIssue]) -> String {
serde_json::to_string_pretty(issues).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::prodigy_config::global_config_path;
use crate::config::tracing::trace_config_with;
use premortem::prelude::*;
#[test]
fn test_detect_empty_env_var() {
let global_path = global_config_path();
let env = MockEnv::new()
.with_file(
global_path.to_string_lossy().to_string(),
"default_editor: vim",
)
.with_env("PRODIGY__DEFAULT_EDITOR", "");
let traced = trace_config_with(&env).unwrap();
let issues = detect_issues(&traced);
let empty_env_issues: Vec<_> = issues
.iter()
.filter(|i| i.issue_type == IssueType::EmptyEnvVar)
.collect();
assert!(
!empty_env_issues.is_empty(),
"Should detect empty env var issue"
);
}
#[test]
fn test_detect_multiple_overrides() {
let global_path = global_config_path();
let project_path = crate::config::prodigy_config::project_config_path();
let env = MockEnv::new()
.with_file(global_path.to_string_lossy().to_string(), "log_level: info")
.with_file(
project_path.to_string_lossy().to_string(),
"log_level: debug",
)
.with_env("PRODIGY__LOG_LEVEL", "warn");
let traced = trace_config_with(&env).unwrap();
let issues = detect_issues(&traced);
let multi_override_issues: Vec<_> = issues
.iter()
.filter(|i| i.issue_type == IssueType::MultipleOverrides)
.collect();
assert!(
!multi_override_issues.is_empty(),
"Should detect multiple override issue"
);
}
#[test]
fn test_issue_display() {
let issue = ConfigIssue {
issue_type: IssueType::EmptyEnvVar,
path: "log_level".to_string(),
severity: IssueSeverity::Warning,
message: "log_level is set but empty from $PRODIGY_LOG_LEVEL".to_string(),
suggestion: Some("Unset the variable or provide a value".to_string()),
};
let display = issue.display();
assert!(display.contains("⚠"));
assert!(display.contains("log_level"));
assert!(display.contains("Suggestion:"));
}
#[test]
fn test_no_issues_for_clean_config() {
let env = MockEnv::new();
let traced = trace_config_with(&env).unwrap();
let issues = detect_issues(&traced);
let warnings: Vec<_> = issues
.iter()
.filter(|i| i.severity == IssueSeverity::Warning || i.severity == IssueSeverity::Error)
.collect();
assert!(
warnings.is_empty(),
"Clean config should have no warnings/errors"
);
}
#[test]
fn test_format_issues_empty() {
let output = format_issues(&[]);
assert!(output.contains("No configuration issues detected"));
}
#[test]
fn test_format_issues_json() {
let issues = vec![ConfigIssue {
issue_type: IssueType::EmptyEnvVar,
path: "test".to_string(),
severity: IssueSeverity::Warning,
message: "test message".to_string(),
suggestion: None,
}];
let json = format_issues_json(&issues);
assert!(json.contains("\"type\": \"empty_env_var\""));
assert!(json.contains("\"path\": \"test\""));
}
}