prodigy/config/
diagnostics.rs1use super::tracing::{SourceType, TracedProdigyConfig};
24use serde::{Deserialize, Serialize};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum IssueSeverity {
30 Info,
32 Warning,
34 Error,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ConfigIssue {
41 #[serde(rename = "type")]
43 pub issue_type: IssueType,
44
45 pub path: String,
47
48 pub severity: IssueSeverity,
50
51 pub message: String,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub suggestion: Option<String>,
57}
58
59impl ConfigIssue {
60 pub fn display(&self) -> String {
62 let severity_icon = match self.severity {
63 IssueSeverity::Info => "ℹ",
64 IssueSeverity::Warning => "⚠",
65 IssueSeverity::Error => "✗",
66 };
67
68 let mut output = format!("{} {}", severity_icon, self.message);
69
70 if let Some(ref suggestion) = self.suggestion {
71 output.push_str(&format!("\n Suggestion: {}", suggestion));
72 }
73
74 output
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum IssueType {
82 EmptyEnvVar,
84
85 MultipleOverrides,
87
88 EnvOverridesFile,
90
91 DefaultValue,
93
94 RelativePathAmbiguity,
96}
97
98pub fn detect_issues(traced: &TracedProdigyConfig) -> Vec<ConfigIssue> {
100 let mut issues = Vec::new();
101
102 for (path, trace) in traced.all_traces() {
103 if let SourceType::Environment = trace.final_source.source_type {
105 if trace.final_value == serde_json::Value::String(String::new()) {
106 issues.push(ConfigIssue {
107 issue_type: IssueType::EmptyEnvVar,
108 path: path.clone(),
109 severity: IssueSeverity::Warning,
110 message: format!(
111 "{} is set but empty from environment variable {}",
112 path,
113 trace.final_source.display()
114 ),
115 suggestion: Some(format!(
116 "Unset the variable or provide a value: unset {}",
117 trace
118 .final_source
119 .source
120 .strip_prefix("env:")
121 .unwrap_or(&trace.final_source.source)
122 )),
123 });
124 }
125 }
126
127 if trace.source_count() > 2 {
129 let sources: Vec<String> = trace.history.iter().map(|h| h.source.display()).collect();
130
131 issues.push(ConfigIssue {
132 issue_type: IssueType::MultipleOverrides,
133 path: path.clone(),
134 severity: IssueSeverity::Info,
135 message: format!(
136 "\"{}\" was set in {} places: {}",
137 path,
138 trace.source_count(),
139 sources.join(" → ")
140 ),
141 suggestion: Some("Review if all overrides are intentional".to_string()),
142 });
143 }
144
145 if trace.was_overridden() {
147 let has_file_source = trace
148 .history
149 .iter()
150 .any(|h| h.source.source_type == SourceType::File && h.overridden);
151 let final_is_env = trace.final_source.source_type == SourceType::Environment;
152
153 if has_file_source && final_is_env {
154 issues.push(ConfigIssue {
155 issue_type: IssueType::EnvOverridesFile,
156 path: path.clone(),
157 severity: IssueSeverity::Info,
158 message: format!(
159 "\"{}\" is set in config file but overridden by {}",
160 path,
161 trace.final_source.display()
162 ),
163 suggestion: None,
164 });
165 }
166 }
167 }
168
169 issues
170}
171
172pub fn format_issues(issues: &[ConfigIssue]) -> String {
174 if issues.is_empty() {
175 return "No configuration issues detected.".to_string();
176 }
177
178 let mut output = String::from("Configuration issues detected:\n\n");
179
180 for issue in issues {
181 output.push_str(&issue.display());
182 output.push_str("\n\n");
183 }
184
185 output
186}
187
188pub fn format_issues_json(issues: &[ConfigIssue]) -> String {
190 serde_json::to_string_pretty(issues).unwrap_or_else(|_| "[]".to_string())
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::config::prodigy_config::global_config_path;
197 use crate::config::tracing::trace_config_with;
198 use premortem::prelude::*;
199
200 #[test]
201 fn test_detect_empty_env_var() {
202 let global_path = global_config_path();
204 let env = MockEnv::new()
205 .with_file(
206 global_path.to_string_lossy().to_string(),
207 "default_editor: vim",
208 )
209 .with_env("PRODIGY__DEFAULT_EDITOR", "");
210
211 let traced = trace_config_with(&env).unwrap();
212 let issues = detect_issues(&traced);
213
214 let empty_env_issues: Vec<_> = issues
215 .iter()
216 .filter(|i| i.issue_type == IssueType::EmptyEnvVar)
217 .collect();
218
219 assert!(
220 !empty_env_issues.is_empty(),
221 "Should detect empty env var issue"
222 );
223 }
224
225 #[test]
226 fn test_detect_multiple_overrides() {
227 let global_path = global_config_path();
228 let project_path = crate::config::prodigy_config::project_config_path();
229
230 let env = MockEnv::new()
231 .with_file(global_path.to_string_lossy().to_string(), "log_level: info")
232 .with_file(
233 project_path.to_string_lossy().to_string(),
234 "log_level: debug",
235 )
236 .with_env("PRODIGY__LOG_LEVEL", "warn");
237
238 let traced = trace_config_with(&env).unwrap();
239 let issues = detect_issues(&traced);
240
241 let multi_override_issues: Vec<_> = issues
242 .iter()
243 .filter(|i| i.issue_type == IssueType::MultipleOverrides)
244 .collect();
245
246 assert!(
247 !multi_override_issues.is_empty(),
248 "Should detect multiple override issue"
249 );
250 }
251
252 #[test]
253 fn test_issue_display() {
254 let issue = ConfigIssue {
255 issue_type: IssueType::EmptyEnvVar,
256 path: "log_level".to_string(),
257 severity: IssueSeverity::Warning,
258 message: "log_level is set but empty from $PRODIGY_LOG_LEVEL".to_string(),
259 suggestion: Some("Unset the variable or provide a value".to_string()),
260 };
261
262 let display = issue.display();
263 assert!(display.contains("⚠"));
264 assert!(display.contains("log_level"));
265 assert!(display.contains("Suggestion:"));
266 }
267
268 #[test]
269 fn test_no_issues_for_clean_config() {
270 let env = MockEnv::new();
271 let traced = trace_config_with(&env).unwrap();
272 let issues = detect_issues(&traced);
273
274 let warnings: Vec<_> = issues
276 .iter()
277 .filter(|i| i.severity == IssueSeverity::Warning || i.severity == IssueSeverity::Error)
278 .collect();
279
280 assert!(
281 warnings.is_empty(),
282 "Clean config should have no warnings/errors"
283 );
284 }
285
286 #[test]
287 fn test_format_issues_empty() {
288 let output = format_issues(&[]);
289 assert!(output.contains("No configuration issues detected"));
290 }
291
292 #[test]
293 fn test_format_issues_json() {
294 let issues = vec![ConfigIssue {
295 issue_type: IssueType::EmptyEnvVar,
296 path: "test".to_string(),
297 severity: IssueSeverity::Warning,
298 message: "test message".to_string(),
299 suggestion: None,
300 }];
301
302 let json = format_issues_json(&issues);
303 assert!(json.contains("\"type\": \"empty_env_var\""));
304 assert!(json.contains("\"path\": \"test\""));
305 }
306}