Skip to main content

sofie_model/
flags.rs

1use std::path::{Path, PathBuf};
2
3use syn::{Expr, Item, ItemConst, ItemFn, Lit};
4
5use crate::syntax::{is_attribute, line_of, segments, string_literal};
6use crate::{MenuEntry, Modifier, Module, Route};
7
8const DEFAULT_RETIRE_AFTER_DAYS: u32 = 90;
9
10#[derive(Clone, Debug, Default, PartialEq, Eq)]
11pub struct FeatureCode {
12    pub declarations: Vec<FeatureDeclaration>,
13    pub gates: Vec<FeatureGate>,
14}
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct FeatureDeclaration {
18    pub constant: Vec<String>,
19    pub name: Option<String>,
20    pub added: Option<String>,
21    pub retire_after_days: u32,
22    pub variants: bool,
23    pub file: PathBuf,
24    pub line: usize,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct FeatureGate {
29    pub function: Vec<String>,
30    pub feature: Vec<String>,
31    pub file: PathBuf,
32    pub line: usize,
33}
34
35impl FeatureCode {
36    pub(crate) fn collect(&mut self, item: &Item, file: &Path, module_path: &[String]) {
37        match item {
38            Item::Const(constant) => {
39                self.declarations
40                    .extend(declaration_of(constant, file, module_path));
41            }
42            Item::Fn(function) => self.gates.extend(gate_of(function, file, module_path)),
43            _ => {}
44        }
45    }
46
47    /// `sofie gen` does not resolve imports, so the crate's one constant of that name is taken.
48    #[must_use]
49    pub fn declaration_of(&self, gate: &FeatureGate) -> Option<&FeatureDeclaration> {
50        self.declared_once(gate.feature.last()?)
51    }
52
53    #[must_use]
54    pub fn gate_on(&self, function: &[String]) -> Option<&FeatureDeclaration> {
55        let gate = self.gates.iter().find(|gate| gate.function == function)?;
56        self.declaration_of(gate)
57    }
58
59    pub(crate) fn declaration_written(&self, written: &str) -> Option<&FeatureDeclaration> {
60        self.declared_once(written.rsplit("::").next()?.trim())
61    }
62
63    fn declared_once(&self, constant: &str) -> Option<&FeatureDeclaration> {
64        let mut found = self.declarations.iter().filter(|declaration| {
65            declaration.constant.last().map(String::as_str) == Some(constant)
66        });
67        let first = found.next()?;
68        found.next().is_none().then_some(first)
69    }
70}
71
72impl Module {
73    #[must_use]
74    pub fn route_feature(&self, route: &Route) -> Option<&FeatureDeclaration> {
75        let features = &self.code.features;
76        route
77            .modifiers
78            .iter()
79            .find_map(|modifier| match modifier {
80                Modifier::Feature(written) => features.declaration_written(written),
81                _ => None,
82            })
83            .or_else(|| features.gate_on(&route.handler))
84    }
85
86    #[must_use]
87    pub fn menu_feature(&self, entry: &MenuEntry) -> Option<&FeatureDeclaration> {
88        self.code
89            .features
90            .declaration_written(entry.feature.as_deref()?)
91    }
92}
93
94fn declaration_of(
95    constant: &ItemConst,
96    file: &Path,
97    module_path: &[String],
98) -> Option<FeatureDeclaration> {
99    let syn::Type::Path(ty) = constant.ty.as_ref() else {
100        return None;
101    };
102    let kind = ty.path.segments.last()?.ident.to_string();
103    if kind != "Feature" && kind != "Variants" {
104        return None;
105    }
106    let mut declaration = FeatureDeclaration {
107        constant: [module_path, &[constant.ident.to_string()]].concat(),
108        name: None,
109        added: None,
110        retire_after_days: DEFAULT_RETIRE_AFTER_DAYS,
111        variants: kind == "Variants",
112        file: file.to_path_buf(),
113        line: line_of(&constant.ident),
114    };
115    read_builder(&constant.expr, &mut declaration);
116    Some(declaration)
117}
118
119fn read_builder(expression: &Expr, declaration: &mut FeatureDeclaration) {
120    match expression {
121        Expr::MethodCall(call) => {
122            if call.method == "retire_after_days"
123                && let Some(Expr::Lit(literal)) = call.args.first()
124                && let Lit::Int(days) = &literal.lit
125            {
126                declaration.retire_after_days =
127                    days.base10_parse().unwrap_or(declaration.retire_after_days);
128            }
129            if call.method == "added" {
130                declaration.added = call.args.first().and_then(string_literal);
131            }
132            read_builder(&call.receiver, declaration);
133        }
134        Expr::Call(call) => declaration.name = call.args.first().and_then(string_literal),
135        _ => {}
136    }
137}
138
139fn gate_of(function: &ItemFn, file: &Path, module_path: &[String]) -> Option<FeatureGate> {
140    let attribute = function
141        .attrs
142        .iter()
143        .find(|attribute| is_attribute(attribute, "feature"))?;
144    let feature = attribute
145        .parse_args::<syn::Path>()
146        .map(|path| segments(&path))
147        .unwrap_or_default();
148    Some(FeatureGate {
149        function: [module_path, &[function.sig.ident.to_string()]].concat(),
150        feature,
151        file: file.to_path_buf(),
152        line: line_of(attribute),
153    })
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn collected(source: &str) -> FeatureCode {
161        let file: syn::File = syn::parse_str(source).unwrap();
162        let mut code = FeatureCode::default();
163        for item in &file.items {
164            code.collect(item, Path::new("module.rs"), &["module".to_owned()]);
165        }
166        code
167    }
168
169    #[test]
170    fn reads_declared_flags_with_their_age() {
171        let code = collected(
172            r#"
173            pub const SPLIT: Feature = Feature::new("order.split_payments").added("2026-09-01").retire_after_days(30);
174            pub const LAYOUT: Variants<Layout> = sofie::flags::Variants::new("order.checkout_layout");
175            pub const OTHER: u32 = 3;
176            "#,
177        );
178        let names: Vec<_> = code
179            .declarations
180            .iter()
181            .map(|d| {
182                (
183                    d.name.clone(),
184                    d.added.clone(),
185                    d.retire_after_days,
186                    d.variants,
187                )
188            })
189            .collect();
190        assert_eq!(
191            names,
192            [
193                (
194                    Some("order.split_payments".to_owned()),
195                    Some("2026-09-01".to_owned()),
196                    30,
197                    false
198                ),
199                (
200                    Some("order.checkout_layout".to_owned()),
201                    None,
202                    DEFAULT_RETIRE_AFTER_DAYS,
203                    true
204                ),
205            ]
206        );
207        assert_eq!(code.declarations[0].constant, ["module", "SPLIT"]);
208    }
209
210    #[test]
211    fn reads_gated_functions_and_finds_their_flag() {
212        let code = collected(
213            r#"
214            pub const SPLIT: Feature = Feature::new("order.split_payments");
215            #[on]
216            #[feature(crate::module::SPLIT)]
217            pub async fn audit(event: OrderPlaced) -> Result<()> { Ok(()) }
218            pub async fn plain() -> Result<()> { Ok(()) }
219            "#,
220        );
221        assert_eq!(code.gates.len(), 1);
222        let gate = &code.gates[0];
223        assert_eq!(gate.function, ["module", "audit"]);
224        assert_eq!(
225            code.declaration_of(gate).unwrap().constant,
226            ["module", "SPLIT"]
227        );
228    }
229}