sofie_model/
entitlements.rs1use std::path::{Path, PathBuf};
2
3use syn::{Expr, Item, ItemConst};
4
5use crate::syntax::{last_name, line_of, path_ends_with, string_literal};
6use crate::{App, Module};
7
8const KINDS: [&str; 3] = ["Entitlement", "Limit", "Quota"];
10
11#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct EntitlementDeclaration {
14 pub constant: Vec<String>,
15 pub name: String,
16 pub file: PathBuf,
17 pub line: usize,
18}
19
20pub(crate) fn entitlement_of(
21 item: &Item,
22 file: &Path,
23 module_path: &[String],
24) -> Option<EntitlementDeclaration> {
25 let Item::Const(constant) = item else {
26 return None;
27 };
28 let kind = declared_kind(constant)?;
29 let Expr::Call(call) = constant.expr.as_ref() else {
30 return None;
31 };
32 let Expr::Path(function) = call.func.as_ref() else {
33 return None;
34 };
35 if !path_ends_with(&function.path, &[kind.as_str(), "new"]) {
36 return None;
37 }
38 Some(EntitlementDeclaration {
39 constant: [module_path, &[constant.ident.to_string()]].concat(),
40 name: call.args.first().and_then(string_literal)?,
41 file: file.to_path_buf(),
42 line: line_of(&constant.ident),
43 })
44}
45
46fn declared_kind(constant: &ItemConst) -> Option<String> {
47 let syn::Type::Path(ty) = constant.ty.as_ref() else {
48 return None;
49 };
50 last_name(&ty.path).filter(|kind| KINDS.contains(&kind.as_str()))
51}
52
53impl App {
54 #[must_use]
55 pub fn entitlements(&self) -> Entitlements<'_> {
56 Entitlements {
57 modules: &self.modules,
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug)]
64pub struct Entitlements<'app> {
65 modules: &'app [Module],
66}
67
68impl<'app> Entitlements<'app> {
69 pub fn iter(self) -> impl Iterator<Item = &'app EntitlementDeclaration> {
70 self.modules
71 .iter()
72 .flat_map(|module| &module.code.entitlements)
73 }
74
75 #[must_use]
79 pub fn written(self, written: &str) -> Option<&'app EntitlementDeclaration> {
80 let mut expression = syn::parse_str::<Expr>(written).ok()?;
81 while let Expr::MethodCall(call) = expression {
82 if call.method != "entitlement" || !call.args.is_empty() {
83 return None;
84 }
85 expression = *call.receiver;
86 }
87 match &expression {
88 Expr::Path(constant) => {
89 let constant = last_name(&constant.path)?;
90 self.iter()
91 .find(|declared| declared.constant.last() == Some(&constant))
92 }
93 Expr::Call(call) => {
94 let name = call.args.first().and_then(string_literal)?;
95 self.iter().find(|declared| declared.name == name)
96 }
97 _ => None,
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use std::path::Path;
105
106 use super::entitlement_of;
107 use crate::testing::{app, module};
108
109 #[test]
110 fn reads_entitlements_limits_and_quotas_named_by_a_literal() {
111 let file: syn::File = syn::parse_str(
112 r#"
113 pub const EXPORT: Entitlement = Entitlement::new("project.export");
114 pub const PROJECTS: sofie::billing::Limit = Limit::new("project.projects");
115 pub const CALLS: Quota = Quota::new("project.api_calls", Period::Month);
116 pub const NAMED: Entitlement = Entitlement::new(NAME);
117 pub const PRO: Plan = Plan::new("pro");
118 "#,
119 )
120 .unwrap();
121
122 let declared: Vec<(String, String)> = file
123 .items
124 .iter()
125 .filter_map(|item| entitlement_of(item, Path::new("plans.rs"), &["plans".to_owned()]))
126 .map(|declaration| (declaration.constant.join("::"), declaration.name))
127 .collect();
128
129 let pair = |constant: &str, name: &str| (constant.to_owned(), name.to_owned());
130 assert_eq!(
131 declared,
132 [
133 pair("plans::EXPORT", "project.export"),
134 pair("plans::PROJECTS", "project.projects"),
135 pair("plans::CALLS", "project.api_calls"),
136 ]
137 );
138 }
139
140 #[test]
141 fn finds_what_a_route_s_entitlement_names_however_it_is_written() {
142 let file: syn::File = syn::parse_str(
143 r#"
144 pub const EXPORT: Entitlement = Entitlement::new("project.export");
145 pub const PROJECTS: Limit = Limit::new("project.projects");
146 "#,
147 )
148 .unwrap();
149 let mut project = module("project", "/shop/modules/project");
150 project.code.entitlements = file
151 .items
152 .iter()
153 .filter_map(|item| entitlement_of(item, Path::new("plans.rs"), &["plans".to_owned()]))
154 .collect();
155 let shop = app("/shop", vec![project]);
156 let found = |written: &str| {
157 shop.entitlements()
158 .written(written)
159 .map(|declared| declared.name.clone())
160 };
161
162 assert_eq!(
163 found("crate::plans::EXPORT").as_deref(),
164 Some("project.export")
165 );
166 assert_eq!(
167 found("PROJECTS.entitlement()").as_deref(),
168 Some("project.projects")
169 );
170 assert_eq!(
171 found("Entitlement::new(\"project.export\")").as_deref(),
172 Some("project.export")
173 );
174 assert_eq!(found("plans::PRO"), None);
175 assert_eq!(found("Entitlement::new(\"project.import\")"), None);
176 }
177}