Skip to main content

sofie_model/
entitlements.rs

1use 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
8/// What a plan may hold, each held while it allows more than none.
9const KINDS: [&str; 3] = ["Entitlement", "Limit", "Quota"];
10
11/// `const EXPORT: Entitlement = Entitlement::new("project.export");`, or a `Limit` or `Quota`.
12#[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/// What the app's modules declare a plan may hold.
63#[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    /// `.entitled(…)` as written: a constant, a limit's or quota's `.entitlement()`, or
76    /// `Entitlement::new("…")`. A constant is matched by its name, as `sofie gen` does not
77    /// resolve imports.
78    #[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}