Skip to main content

xcom_rs/
doctor.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::auth::{AuthStatus, AuthStore};
6use crate::billing::BudgetTracker;
7use crate::context::ExecutionContext;
8
9/// Required OAuth scopes for full functionality
10const REQUIRED_SCOPES: &[&str] = &[
11    "tweet.read",
12    "tweet.write",
13    "users.read",
14    "bookmark.read",
15    "bookmark.write",
16    "like.read",
17    "like.write",
18    "offline.access",
19];
20
21/// Diagnostic information about the system configuration and state
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DoctorDiagnostics {
24    /// Authentication status
25    #[serde(rename = "authStatus")]
26    pub auth_status: AuthStatus,
27
28    /// Authentication storage path resolution (if file-based)
29    #[serde(rename = "authStoragePath", skip_serializing_if = "Option::is_none")]
30    pub auth_storage_path: Option<PathInfo>,
31
32    /// Budget tracker storage path resolution
33    #[serde(rename = "budgetStoragePath", skip_serializing_if = "Option::is_none")]
34    pub budget_storage_path: Option<PathInfo>,
35
36    /// Execution mode settings
37    #[serde(rename = "executionMode")]
38    pub execution_mode: ExecutionMode,
39
40    /// Scope compatibility check result
41    #[serde(rename = "scopeCheck")]
42    pub scope_check: ScopeCheck,
43
44    /// API probe result (always present; status is "skipped" when --probe was not specified)
45    #[serde(rename = "apiProbe")]
46    pub api_probe: ApiProbeResult,
47
48    /// Any warnings encountered during diagnostics
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub warnings: Option<Vec<String>>,
51
52    /// Next steps to resolve issues (populated on failures)
53    #[serde(rename = "nextSteps", skip_serializing_if = "Option::is_none")]
54    pub next_steps: Option<Vec<String>>,
55}
56
57/// Scope compatibility check result
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ScopeCheck {
60    /// Whether all required scopes are present
61    pub ok: bool,
62
63    /// Scopes that are present in the token
64    #[serde(rename = "grantedScopes")]
65    pub granted_scopes: Vec<String>,
66
67    /// Required scopes that are missing
68    #[serde(rename = "missingScopes")]
69    pub missing_scopes: Vec<String>,
70}
71
72impl ScopeCheck {
73    /// Evaluate scope compatibility from the granted scopes list
74    pub fn evaluate(granted_scopes: &[String]) -> Self {
75        let granted: std::collections::HashSet<&str> =
76            granted_scopes.iter().map(|s| s.as_str()).collect();
77        let missing: Vec<String> = REQUIRED_SCOPES
78            .iter()
79            .filter(|&&s| !granted.contains(s))
80            .map(|&s| s.to_string())
81            .collect();
82        Self {
83            ok: missing.is_empty(),
84            granted_scopes: granted_scopes.to_vec(),
85            missing_scopes: missing,
86        }
87    }
88
89    /// Return a scope check for an unauthenticated user (all required scopes missing)
90    pub fn unauthenticated() -> Self {
91        Self {
92            ok: false,
93            granted_scopes: vec![],
94            missing_scopes: REQUIRED_SCOPES.iter().map(|&s| s.to_string()).collect(),
95        }
96    }
97}
98
99/// Status of an API probe
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101#[serde(rename_all = "lowercase")]
102pub enum ProbeStatus {
103    /// Probe was not requested
104    Skipped,
105    /// Probe succeeded
106    Ok,
107    /// Probe failed
108    Failed,
109}
110
111/// Result of an API connectivity probe
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ApiProbeResult {
114    /// Probe status
115    pub status: ProbeStatus,
116
117    /// Duration of the probe in milliseconds (0 when skipped)
118    #[serde(rename = "durationMs")]
119    pub duration_ms: u64,
120
121    /// HTTP status code returned (if probe was executed)
122    #[serde(rename = "httpStatus", skip_serializing_if = "Option::is_none")]
123    pub http_status: Option<u16>,
124
125    /// Human-readable message
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub message: Option<String>,
128}
129
130impl ApiProbeResult {
131    /// Create a skipped probe result
132    pub fn skipped() -> Self {
133        Self {
134            status: ProbeStatus::Skipped,
135            duration_ms: 0,
136            http_status: None,
137            message: Some("Probe not requested; pass --probe to enable".to_string()),
138        }
139    }
140
141    /// Create a successful probe result
142    pub fn ok(http_status: u16, duration_ms: u64) -> Self {
143        Self {
144            status: ProbeStatus::Ok,
145            duration_ms,
146            http_status: Some(http_status),
147            message: Some("API is reachable".to_string()),
148        }
149    }
150
151    /// Create a failed probe result
152    pub fn failed(message: String, duration_ms: u64) -> Self {
153        Self {
154            status: ProbeStatus::Failed,
155            duration_ms,
156            http_status: None,
157            message: Some(message),
158        }
159    }
160
161    /// Create a failed probe result with an HTTP status code
162    pub fn failed_with_status(http_status: u16, message: String, duration_ms: u64) -> Self {
163        Self {
164            status: ProbeStatus::Failed,
165            duration_ms,
166            http_status: Some(http_status),
167            message: Some(message),
168        }
169    }
170}
171
172/// Trait for performing an API connectivity probe.
173/// This abstraction allows test code to inject a mock without touching the network.
174pub trait ApiProber: Send + Sync {
175    /// Execute a lightweight probe against the X API.
176    /// Returns `Ok(ApiProbeResult)` for both successful and failed probes;
177    /// `Err` only for unexpected internal errors.
178    fn probe(&self) -> Result<ApiProbeResult>;
179}
180
181/// Information about a resolved path
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct PathInfo {
184    /// The resolved absolute path
185    pub path: String,
186
187    /// Whether the path exists
188    pub exists: bool,
189
190    /// Whether the path is readable (if it exists)
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub readable: Option<bool>,
193
194    /// Whether the path is writable (if it exists)
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub writable: Option<bool>,
197}
198
199impl PathInfo {
200    /// Create PathInfo from a PathBuf
201    pub fn from_path(path: PathBuf) -> Self {
202        let exists = path.exists();
203        let readable = if exists {
204            Some(
205                path.metadata()
206                    .map(|m| !m.permissions().readonly())
207                    .unwrap_or(false),
208            )
209        } else {
210            None
211        };
212        let writable = readable; // Simplified: same as readable for now
213
214        Self {
215            path: path.to_string_lossy().to_string(),
216            exists,
217            readable,
218            writable,
219        }
220    }
221}
222
223/// Execution mode settings
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ExecutionMode {
226    /// Whether running in non-interactive mode
227    #[serde(rename = "nonInteractive")]
228    pub non_interactive: bool,
229
230    /// Whether running in dry-run mode
231    #[serde(rename = "dryRun")]
232    pub dry_run: bool,
233
234    /// Maximum cost limit in credits (if set)
235    #[serde(rename = "maxCostCredits", skip_serializing_if = "Option::is_none")]
236    pub max_cost_credits: Option<u32>,
237
238    /// Daily budget limit in credits (if set)
239    #[serde(rename = "dailyBudgetCredits", skip_serializing_if = "Option::is_none")]
240    pub daily_budget_credits: Option<u32>,
241}
242
243impl ExecutionMode {
244    /// Create from ExecutionContext
245    pub fn from_context(ctx: &ExecutionContext) -> Self {
246        Self {
247            non_interactive: ctx.non_interactive,
248            dry_run: ctx.dry_run,
249            max_cost_credits: ctx.max_cost_credits,
250            daily_budget_credits: ctx.budget_daily_credits,
251        }
252    }
253}
254
255/// Collect diagnostic information.
256///
257/// When `prober` is `Some`, the API connectivity probe will be executed.
258/// When `prober` is `None`, the probe result will be `skipped`.
259pub fn collect_diagnostics(
260    auth_store: &AuthStore,
261    ctx: &ExecutionContext,
262    prober: Option<&dyn ApiProber>,
263) -> Result<DoctorDiagnostics> {
264    let mut warnings = Vec::new();
265    let mut next_steps: Vec<String> = Vec::new();
266
267    // Get auth status
268    let auth_status = auth_store.status();
269
270    // Scope check
271    let scope_check = if auth_status.authenticated {
272        let granted = auth_status.scopes.clone().unwrap_or_default();
273        let check = ScopeCheck::evaluate(&granted);
274        if !check.ok {
275            warnings.push(format!(
276                "Missing required OAuth scopes: {}",
277                check.missing_scopes.join(", ")
278            ));
279            next_steps
280                .push("Re-authenticate with the required scopes: xcom-rs auth ...".to_string());
281            next_steps.push(format!(
282                "Missing scopes: {}",
283                check.missing_scopes.join(", ")
284            ));
285        }
286        check
287    } else {
288        next_steps.push("Authenticate first: xcom-rs auth ...".to_string());
289        ScopeCheck::unauthenticated()
290    };
291
292    // Try to get auth storage path
293    let auth_storage_path = match AuthStore::default_storage_path() {
294        Ok(path) => Some(PathInfo::from_path(path)),
295        Err(e) => {
296            warnings.push(format!("Failed to resolve auth storage path: {}", e));
297            None
298        }
299    };
300
301    // Try to get budget tracker storage path
302    let budget_storage_path = match BudgetTracker::default_storage_path() {
303        Ok(path) => Some(PathInfo::from_path(path)),
304        Err(e) => {
305            warnings.push(format!("Failed to resolve budget storage path: {}", e));
306            None
307        }
308    };
309
310    // Get execution mode settings
311    let execution_mode = ExecutionMode::from_context(ctx);
312
313    // Run API probe if requested; always include apiProbe in the output
314    let api_probe = match prober {
315        Some(p) => {
316            let result = p.probe()?;
317            if result.status == ProbeStatus::Failed {
318                if let Some(ref msg) = result.message {
319                    warnings.push(format!("API probe failed: {}", msg));
320                }
321                next_steps.push("Check network connectivity to api.twitter.com".to_string());
322                next_steps
323                    .push("Verify that your access token is valid and not expired".to_string());
324            }
325            result
326        }
327        None => {
328            // Probe was not requested; advise the user how to enable it
329            next_steps.push(
330                "To verify API connectivity, re-run with --probe: xcom-rs doctor --probe"
331                    .to_string(),
332            );
333            ApiProbeResult::skipped()
334        }
335    };
336
337    Ok(DoctorDiagnostics {
338        auth_status,
339        auth_storage_path,
340        budget_storage_path,
341        execution_mode,
342        scope_check,
343        api_probe,
344        warnings: if warnings.is_empty() {
345            None
346        } else {
347            Some(warnings)
348        },
349        next_steps: if next_steps.is_empty() {
350            None
351        } else {
352            Some(next_steps)
353        },
354    })
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::auth::AuthToken;
361
362    // ---------------------------------------------------------------------------
363    // Mock prober for testing
364    // ---------------------------------------------------------------------------
365
366    struct MockProber {
367        result: ApiProbeResult,
368    }
369
370    impl MockProber {
371        fn ok() -> Self {
372            Self {
373                result: ApiProbeResult::ok(200, 42),
374            }
375        }
376
377        fn failed(msg: &str) -> Self {
378            Self {
379                result: ApiProbeResult::failed(msg.to_string(), 100),
380            }
381        }
382    }
383
384    impl ApiProber for MockProber {
385        fn probe(&self) -> Result<ApiProbeResult> {
386            Ok(self.result.clone())
387        }
388    }
389
390    // ---------------------------------------------------------------------------
391    // PathInfo tests
392    // ---------------------------------------------------------------------------
393
394    #[test]
395    fn test_path_info_nonexistent() {
396        let path = PathBuf::from("/nonexistent/path/to/file");
397        let info = PathInfo::from_path(path);
398        assert!(!info.exists);
399        assert!(info.readable.is_none());
400        assert!(info.writable.is_none());
401    }
402
403    // ---------------------------------------------------------------------------
404    // ExecutionMode tests
405    // ---------------------------------------------------------------------------
406
407    #[test]
408    fn test_execution_mode_from_context() {
409        let ctx = ExecutionContext::new(true, None, Some(100), Some(500), true);
410        let mode = ExecutionMode::from_context(&ctx);
411        assert!(mode.non_interactive);
412        assert!(mode.dry_run);
413        assert_eq!(mode.max_cost_credits, Some(100));
414        assert_eq!(mode.daily_budget_credits, Some(500));
415    }
416
417    // ---------------------------------------------------------------------------
418    // ScopeCheck tests
419    // ---------------------------------------------------------------------------
420
421    #[test]
422    fn test_scope_check_all_present() {
423        let granted: Vec<String> = REQUIRED_SCOPES.iter().map(|&s| s.to_string()).collect();
424        let check = ScopeCheck::evaluate(&granted);
425        assert!(check.ok);
426        assert!(check.missing_scopes.is_empty());
427    }
428
429    #[test]
430    fn test_scope_check_missing_scopes() {
431        let granted = vec!["tweet.read".to_string(), "users.read".to_string()];
432        let check = ScopeCheck::evaluate(&granted);
433        assert!(!check.ok);
434        assert!(check.missing_scopes.contains(&"tweet.write".to_string()));
435        assert!(!check.missing_scopes.contains(&"tweet.read".to_string()));
436    }
437
438    #[test]
439    fn test_scope_check_unauthenticated() {
440        let check = ScopeCheck::unauthenticated();
441        assert!(!check.ok);
442        assert!(check.granted_scopes.is_empty());
443        assert_eq!(check.missing_scopes.len(), REQUIRED_SCOPES.len());
444    }
445
446    // ---------------------------------------------------------------------------
447    // collect_diagnostics tests
448    // ---------------------------------------------------------------------------
449
450    #[test]
451    fn test_collect_diagnostics_unauthenticated_no_probe() {
452        let auth_store = AuthStore::new();
453        let ctx = ExecutionContext::new(false, None, None, None, false);
454        let result = collect_diagnostics(&auth_store, &ctx, None);
455        assert!(result.is_ok());
456        let diagnostics = result.unwrap();
457        assert!(!diagnostics.auth_status.authenticated);
458        assert!(!diagnostics.execution_mode.non_interactive);
459        assert!(!diagnostics.execution_mode.dry_run);
460        // Probe not requested → status is "skipped"
461        assert_eq!(diagnostics.api_probe.status, ProbeStatus::Skipped);
462        assert_eq!(diagnostics.api_probe.duration_ms, 0);
463        // next_steps should include probe hint
464        let next_steps = diagnostics.next_steps.unwrap_or_default();
465        assert!(next_steps.iter().any(|s| s.contains("--probe")));
466        // Scope check shows all missing
467        assert!(!diagnostics.scope_check.ok);
468        assert_eq!(
469            diagnostics.scope_check.missing_scopes.len(),
470            REQUIRED_SCOPES.len()
471        );
472    }
473
474    #[test]
475    fn test_collect_diagnostics_authenticated_full_scopes_no_probe() {
476        let mut auth_store = AuthStore::new();
477        let scopes: Vec<String> = REQUIRED_SCOPES.iter().map(|&s| s.to_string()).collect();
478        let token = AuthToken {
479            access_token: "test_token".to_string(),
480            token_type: "Bearer".to_string(),
481            expires_at: None,
482            scopes,
483        };
484        auth_store.set_token(token);
485
486        let ctx = ExecutionContext::new(true, Some("trace-123".to_string()), Some(50), None, true);
487        let result = collect_diagnostics(&auth_store, &ctx, None);
488        assert!(result.is_ok());
489        let diagnostics = result.unwrap();
490        assert!(diagnostics.auth_status.authenticated);
491        assert!(diagnostics.execution_mode.non_interactive);
492        assert!(diagnostics.execution_mode.dry_run);
493        assert_eq!(diagnostics.execution_mode.max_cost_credits, Some(50));
494        // All scopes present → ok
495        assert!(diagnostics.scope_check.ok);
496        assert!(diagnostics.scope_check.missing_scopes.is_empty());
497        // No probe requested → status is "skipped"
498        assert_eq!(diagnostics.api_probe.status, ProbeStatus::Skipped);
499    }
500
501    #[test]
502    fn test_collect_diagnostics_authenticated_missing_scopes() {
503        let mut auth_store = AuthStore::new();
504        let token = AuthToken {
505            access_token: "test_token".to_string(),
506            token_type: "Bearer".to_string(),
507            expires_at: None,
508            scopes: vec!["tweet.read".to_string()],
509        };
510        auth_store.set_token(token);
511
512        let ctx = ExecutionContext::new(false, None, None, None, false);
513        let result = collect_diagnostics(&auth_store, &ctx, None);
514        assert!(result.is_ok());
515        let diagnostics = result.unwrap();
516        assert!(diagnostics.auth_status.authenticated);
517        assert!(!diagnostics.scope_check.ok);
518        assert!(!diagnostics.scope_check.missing_scopes.is_empty());
519        // Warning should mention missing scopes
520        let warnings = diagnostics.warnings.unwrap_or_default();
521        assert!(warnings
522            .iter()
523            .any(|w| w.contains("Missing required OAuth scopes")));
524    }
525
526    #[test]
527    fn test_collect_diagnostics_with_probe_success() {
528        let auth_store = AuthStore::new();
529        let ctx = ExecutionContext::new(false, None, None, None, false);
530        let prober = MockProber::ok();
531        let result = collect_diagnostics(&auth_store, &ctx, Some(&prober));
532        assert!(result.is_ok());
533        let diagnostics = result.unwrap();
534        let probe = &diagnostics.api_probe;
535        assert_eq!(probe.status, ProbeStatus::Ok);
536        assert_eq!(probe.http_status, Some(200));
537        // durationMs should be set (42 from mock)
538        assert_eq!(probe.duration_ms, 42);
539    }
540
541    #[test]
542    fn test_collect_diagnostics_with_probe_failure() {
543        let auth_store = AuthStore::new();
544        let ctx = ExecutionContext::new(false, None, None, None, false);
545        let prober = MockProber::failed("connection refused");
546        let result = collect_diagnostics(&auth_store, &ctx, Some(&prober));
547        assert!(result.is_ok());
548        let diagnostics = result.unwrap();
549        let probe = &diagnostics.api_probe;
550        assert_eq!(probe.status, ProbeStatus::Failed);
551        assert_eq!(probe.duration_ms, 100);
552        // Warnings and next_steps should be populated
553        let warnings = diagnostics.warnings.unwrap_or_default();
554        assert!(warnings.iter().any(|w| w.contains("API probe failed")));
555        let next_steps = diagnostics.next_steps.unwrap_or_default();
556        assert!(next_steps.iter().any(|s| s.contains("network")));
557    }
558
559    #[test]
560    fn test_collect_diagnostics_skipped_probe_returns_skipped_status() {
561        // Passing None means probe is skipped; api_probe is always present with status=skipped
562        let auth_store = AuthStore::new();
563        let ctx = ExecutionContext::new(false, None, None, None, false);
564        let diagnostics = collect_diagnostics(&auth_store, &ctx, None).unwrap();
565        assert_eq!(diagnostics.api_probe.status, ProbeStatus::Skipped);
566        assert_eq!(diagnostics.api_probe.duration_ms, 0);
567        // next_steps must include --probe hint
568        let next_steps = diagnostics.next_steps.unwrap_or_default();
569        assert!(next_steps.iter().any(|s| s.contains("--probe")));
570    }
571}