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 #[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}