Skip to main content

prodigy/config/
diagnostics.rs

1//! Configuration diagnostics and issue detection.
2//!
3//! This module provides utilities for detecting potential configuration issues
4//! such as empty environment variables, typos in config keys, and other common
5//! problems that can cause confusion.
6//!
7//! # Example
8//!
9//! ```
10//! use prodigy::config::diagnostics::detect_issues;
11//! use prodigy::config::tracing::trace_config_with;
12//! use premortem::MockEnv;
13//!
14//! let env = MockEnv::new();
15//! let traced = trace_config_with(&env).expect("trace failed");
16//! let issues = detect_issues(&traced);
17//!
18//! for issue in &issues {
19//!     println!("Warning: {}", issue.message);
20//! }
21//! ```
22
23use super::tracing::{SourceType, TracedProdigyConfig};
24use serde::{Deserialize, Serialize};
25
26/// Configuration issue severity.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "lowercase")]
29pub enum IssueSeverity {
30    /// Informational - may be intentional
31    Info,
32    /// Warning - likely unintentional
33    Warning,
34    /// Error - definitely wrong
35    Error,
36}
37
38/// A detected configuration issue.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ConfigIssue {
41    /// Issue type
42    #[serde(rename = "type")]
43    pub issue_type: IssueType,
44
45    /// Configuration path affected
46    pub path: String,
47
48    /// Severity of the issue
49    pub severity: IssueSeverity,
50
51    /// Human-readable message
52    pub message: String,
53
54    /// Suggested fix (if any)
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub suggestion: Option<String>,
57}
58
59impl ConfigIssue {
60    /// Get a formatted message for display.
61    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/// Types of configuration issues.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum IssueType {
82    /// Environment variable is empty string
83    EmptyEnvVar,
84
85    /// Multiple sources override the same path
86    MultipleOverrides,
87
88    /// Environment variable overrides file config
89    EnvOverridesFile,
90
91    /// Value is set to default (might be unintentional)
92    DefaultValue,
93
94    /// Relative path might resolve differently
95    RelativePathAmbiguity,
96}
97
98/// Detect potential issues in the configuration.
99pub fn detect_issues(traced: &TracedProdigyConfig) -> Vec<ConfigIssue> {
100    let mut issues = Vec::new();
101
102    for (path, trace) in traced.all_traces() {
103        // Check for empty string values from env vars
104        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        // Check for multiple overrides (potential confusion)
128        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        // Check for env overriding file config (might be unintentional)
146        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
172/// Format issues for terminal output.
173pub 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
188/// Format issues as JSON.
189pub 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        // Use default_editor which is optional and can be empty
203        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        // With defaults only, there shouldn't be any warning-level issues
275        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}