1use std::collections::BTreeSet;
7
8use serde::{Deserialize, Serialize};
9
10pub const REQUIRED_SCOPES: &[&str] = &[
12 "tweet.read",
13 "tweet.write",
14 "users.read",
15 "follows.read",
16 "follows.write",
17 "like.read",
18 "like.write",
19 "bookmark.read",
20 "bookmark.write",
21 "dm.read",
22 "dm.write",
23 "offline.access",
24];
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct FeatureScopeMapping {
29 pub feature: &'static str,
31 pub description: &'static str,
33 pub required_scopes: &'static [&'static str],
35}
36
37pub const FEATURE_SCOPE_MAP: &[FeatureScopeMapping] = &[
39 FeatureScopeMapping {
40 feature: "Search tweets",
41 description: "Search recent tweets for discovery and targeting.",
42 required_scopes: &["tweet.read", "users.read"],
43 },
44 FeatureScopeMapping {
45 feature: "Post tweet/reply/thread",
46 description: "Create tweets, replies, and thread posts.",
47 required_scopes: &["tweet.read", "tweet.write", "users.read"],
48 },
49 FeatureScopeMapping {
50 feature: "Like/unlike",
51 description: "Like and unlike tweets on behalf of the account.",
52 required_scopes: &["like.read", "like.write", "users.read"],
53 },
54 FeatureScopeMapping {
55 feature: "Follow/unfollow",
56 description: "Follow and unfollow users from the authenticated account.",
57 required_scopes: &["follows.read", "follows.write", "users.read"],
58 },
59 FeatureScopeMapping {
60 feature: "Read mentions",
61 description: "Read @mentions for mention-reply workflows.",
62 required_scopes: &["tweet.read", "users.read"],
63 },
64 FeatureScopeMapping {
65 feature: "Bookmarks",
66 description: "Bookmark and unbookmark tweets, read bookmarked tweets.",
67 required_scopes: &["bookmark.read", "bookmark.write", "users.read"],
68 },
69 FeatureScopeMapping {
70 feature: "Token refresh",
71 description: "Refresh access tokens without re-authentication.",
72 required_scopes: &["offline.access"],
73 },
74 FeatureScopeMapping {
75 feature: "Read DMs",
76 description: "Read direct message conversations and events.",
77 required_scopes: &["dm.read", "users.read"],
78 },
79 FeatureScopeMapping {
80 feature: "Send DMs",
81 description: "Send direct messages and create group conversations.",
82 required_scopes: &["dm.write", "dm.read", "users.read"],
83 },
84];
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct DegradedFeature {
89 pub feature: String,
91 pub description: String,
93 pub missing_scopes: Vec<String>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ScopeAnalysis {
100 pub granted: Vec<String>,
102 pub required: Vec<String>,
104 pub missing: Vec<String>,
106 pub extra: Vec<String>,
108 pub degraded_features: Vec<DegradedFeature>,
110 pub all_required_present: bool,
112}
113
114pub fn analyze_scopes(granted: &[String]) -> ScopeAnalysis {
116 let granted_set: BTreeSet<String> = granted
117 .iter()
118 .map(|scope| scope.trim())
119 .filter(|scope| !scope.is_empty())
120 .map(ToOwned::to_owned)
121 .collect();
122
123 let required_set: BTreeSet<String> = REQUIRED_SCOPES.iter().map(|s| (*s).to_string()).collect();
124
125 let missing: Vec<String> = required_set.difference(&granted_set).cloned().collect();
126 let extra: Vec<String> = granted_set.difference(&required_set).cloned().collect();
127
128 let degraded_features: Vec<DegradedFeature> = FEATURE_SCOPE_MAP
129 .iter()
130 .filter_map(|mapping| {
131 let missing_scopes: Vec<String> = mapping
132 .required_scopes
133 .iter()
134 .filter(|scope| !granted_set.contains(**scope))
135 .map(|scope| (*scope).to_string())
136 .collect();
137
138 if missing_scopes.is_empty() {
139 None
140 } else {
141 Some(DegradedFeature {
142 feature: mapping.feature.to_string(),
143 description: mapping.description.to_string(),
144 missing_scopes,
145 })
146 }
147 })
148 .collect();
149
150 ScopeAnalysis {
151 granted: granted_set.into_iter().collect(),
152 required: REQUIRED_SCOPES.iter().map(|s| (*s).to_string()).collect(),
153 missing: missing.clone(),
154 extra,
155 degraded_features,
156 all_required_present: missing.is_empty(),
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn all_required_scopes() -> Vec<String> {
165 REQUIRED_SCOPES.iter().map(|s| (*s).to_string()).collect()
166 }
167
168 #[test]
169 fn full_scopes_have_no_degradation() {
170 let analysis = analyze_scopes(&all_required_scopes());
171 assert!(analysis.all_required_present);
172 assert!(analysis.missing.is_empty());
173 assert!(analysis.degraded_features.is_empty());
174 assert!(analysis.extra.is_empty());
175 }
176
177 #[test]
178 fn partial_scopes_report_degraded_features() {
179 let mut scopes = all_required_scopes();
180 scopes.retain(|scope| scope != "like.write");
181
182 let analysis = analyze_scopes(&scopes);
183
184 assert!(!analysis.all_required_present);
185 assert_eq!(analysis.missing, vec!["like.write".to_string()]);
186
187 let like_feature = analysis
188 .degraded_features
189 .iter()
190 .find(|feature| feature.feature == "Like/unlike")
191 .expect("like/unlike feature should be degraded");
192 assert_eq!(like_feature.missing_scopes, vec!["like.write".to_string()]);
193 }
194
195 #[test]
196 fn empty_scopes_degrade_all_features() {
197 let analysis = analyze_scopes(&[]);
198
199 assert!(!analysis.all_required_present);
200 assert_eq!(analysis.missing.len(), REQUIRED_SCOPES.len());
201 assert_eq!(analysis.degraded_features.len(), FEATURE_SCOPE_MAP.len());
202 assert!(analysis.extra.is_empty());
203 }
204
205 #[test]
206 fn extra_scopes_are_reported_without_error() {
207 let mut scopes = all_required_scopes();
208 scopes.push("mute.read".to_string());
209
210 let analysis = analyze_scopes(&scopes);
211
212 assert!(analysis.all_required_present);
213 assert!(analysis.missing.is_empty());
214 assert_eq!(analysis.extra, vec!["mute.read".to_string()]);
215 }
216}