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
9const 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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DoctorDiagnostics {
24 #[serde(rename = "authStatus")]
26 pub auth_status: AuthStatus,
27
28 #[serde(rename = "authStoragePath", skip_serializing_if = "Option::is_none")]
30 pub auth_storage_path: Option<PathInfo>,
31
32 #[serde(rename = "budgetStoragePath", skip_serializing_if = "Option::is_none")]
34 pub budget_storage_path: Option<PathInfo>,
35
36 #[serde(rename = "executionMode")]
38 pub execution_mode: ExecutionMode,
39
40 #[serde(rename = "scopeCheck")]
42 pub scope_check: ScopeCheck,
43
44 #[serde(rename = "apiProbe")]
46 pub api_probe: ApiProbeResult,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub warnings: Option<Vec<String>>,
51
52 #[serde(rename = "nextSteps", skip_serializing_if = "Option::is_none")]
54 pub next_steps: Option<Vec<String>>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ScopeCheck {
60 pub ok: bool,
62
63 #[serde(rename = "grantedScopes")]
65 pub granted_scopes: Vec<String>,
66
67 #[serde(rename = "missingScopes")]
69 pub missing_scopes: Vec<String>,
70}
71
72impl ScopeCheck {
73 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101#[serde(rename_all = "lowercase")]
102pub enum ProbeStatus {
103 Skipped,
105 Ok,
107 Failed,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ApiProbeResult {
114 pub status: ProbeStatus,
116
117 #[serde(rename = "durationMs")]
119 pub duration_ms: u64,
120
121 #[serde(rename = "httpStatus", skip_serializing_if = "Option::is_none")]
123 pub http_status: Option<u16>,
124
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub message: Option<String>,
128}
129
130impl ApiProbeResult {
131 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 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 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 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
172pub trait ApiProber: Send + Sync {
175 fn probe(&self) -> Result<ApiProbeResult>;
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct PathInfo {
184 pub path: String,
186
187 pub exists: bool,
189
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub readable: Option<bool>,
193
194 #[serde(skip_serializing_if = "Option::is_none")]
196 pub writable: Option<bool>,
197}
198
199impl PathInfo {
200 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; Self {
215 path: path.to_string_lossy().to_string(),
216 exists,
217 readable,
218 writable,
219 }
220 }
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ExecutionMode {
226 #[serde(rename = "nonInteractive")]
228 pub non_interactive: bool,
229
230 #[serde(rename = "dryRun")]
232 pub dry_run: bool,
233
234 #[serde(rename = "maxCostCredits", skip_serializing_if = "Option::is_none")]
236 pub max_cost_credits: Option<u32>,
237
238 #[serde(rename = "dailyBudgetCredits", skip_serializing_if = "Option::is_none")]
240 pub daily_budget_credits: Option<u32>,
241}
242
243impl ExecutionMode {
244 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
255pub 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 let auth_status = auth_store.status();
269
270 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 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 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 let execution_mode = ExecutionMode::from_context(ctx);
312
313 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 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 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 #[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 #[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 #[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 #[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 assert_eq!(diagnostics.api_probe.status, ProbeStatus::Skipped);
462 assert_eq!(diagnostics.api_probe.duration_ms, 0);
463 let next_steps = diagnostics.next_steps.unwrap_or_default();
465 assert!(next_steps.iter().any(|s| s.contains("--probe")));
466 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 assert!(diagnostics.scope_check.ok);
496 assert!(diagnostics.scope_check.missing_scopes.is_empty());
497 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 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 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 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 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 let next_steps = diagnostics.next_steps.unwrap_or_default();
569 assert!(next_steps.iter().any(|s| s.contains("--probe")));
570 }
571}