1use std::path::Path;
2
3use syn::parse::ParseStream;
4use syn::{Attribute, FnArg, Ident, Item, ItemFn, LitStr, Meta, Token, Type};
5
6use crate::areas::{area_definition, implementing};
7use crate::code::{Code, derives};
8use crate::contract_traits::{ContractTrait, contract_trait};
9use crate::gateways::{GatewayTrait, gateway_trait};
10use crate::handlers::HandlerScope;
11use crate::provisions::{ImplSite, impl_site, unit_struct};
12use crate::syntax::{is_attribute, line_of, segments};
13use crate::{
14 AreaDefinition, Cadence, EventDeclaration, Job, Listener, ModelError, PrivateItem, Schedule,
15 Task, WallClock,
16};
17
18const TASK_MODULE: &str = "tasks";
19
20#[derive(Default)]
21pub(crate) struct Found {
22 pub listeners: Vec<Listener>,
23 pub schedules: Vec<Schedule>,
24 pub events: Vec<EventDeclaration>,
25 pub areas: Vec<AreaDefinition>,
26 pub member_areas: Vec<String>,
27 pub sign_up_areas: Vec<String>,
28 pub jobs: Vec<Job>,
29 pub tasks: Vec<Task>,
30 pub impls: Vec<ImplSite>,
31 pub units: Vec<Vec<String>>,
32 pub gateways: Vec<GatewayTrait>,
33 pub contract_traits: Vec<ContractTrait>,
34 pub private_items: Vec<PrivateItem>,
35 pub code: Code,
36}
37
38impl Found {
39 pub(crate) fn wired_count(&self) -> usize {
40 self.listeners.len()
41 + self.schedules.len()
42 + self.events.len()
43 + self.areas.len()
44 + self.jobs.len()
45 + self.tasks.len()
46 }
47
48 pub(crate) fn named_by_wiring_count(&self) -> usize {
51 self.code.inputs.len() + self.code.features.declarations.len()
52 }
53}
54
55pub(crate) fn collect_item(
56 item: &Item,
57 file: &Path,
58 scope: &HandlerScope,
59 found: &mut Found,
60) -> Result<(), ModelError> {
61 let module_path = scope.module_path();
62 found.code.collect(item, file, scope);
63 found.units.extend(unit_struct(item, module_path));
64 found
65 .gateways
66 .extend(gateway_trait(item, file, module_path));
67 found
68 .contract_traits
69 .extend(contract_trait(item, file, module_path));
70 match item {
71 Item::Fn(function) => collect_function(function, file, scope, found),
72 Item::Struct(_)
74 if is_contract_gen(file) && module_path.last().is_some_and(|name| name == "pruned") =>
75 {
76 Ok(())
77 }
78 Item::Struct(structure) if derives(&structure.attrs, "Event") => {
79 found.events.push(EventDeclaration {
80 name: structure.ident.to_string(),
81 file: file.to_path_buf(),
82 line: line_of(structure),
83 });
84 Ok(())
85 }
86 Item::Impl(implementation) => {
87 found
88 .impls
89 .extend(impl_site(implementation, file, module_path));
90 found
91 .areas
92 .extend(area_definition(implementation, file, module_path)?);
93 if !is_contract_gen(file) {
94 found
95 .member_areas
96 .extend(implementing(implementation, "MemberArea"));
97 }
98 found
99 .sign_up_areas
100 .extend(implementing(implementation, "SignUp"));
101 Ok(())
102 }
103 _ => Ok(()),
104 }
105}
106
107fn is_contract_gen(file: &Path) -> bool {
110 file.ends_with(
111 Path::new("src")
112 .join(crate::naming::GEN_MODULE)
113 .join("mod.rs"),
114 )
115}
116
117fn collect_function(
118 function: &ItemFn,
119 file: &Path,
120 scope: &HandlerScope,
121 found: &mut Found,
122) -> Result<(), ModelError> {
123 let module_path = scope.module_path();
124 let mut path = module_path.to_vec();
125 path.push(function.sig.ident.to_string());
126 for attribute in &function.attrs {
127 let line = line_of(attribute);
128 if is_attribute(attribute, "on") {
129 found.listeners.push(Listener {
130 file: file.to_path_buf(),
131 line,
132 function: path.clone(),
133 event: listened_event(attribute, function)
134 .and_then(|written| scope.qualified(&written))
135 .map_err(|message| ModelError::new(file, line, message))?,
136 });
137 } else if is_attribute(attribute, "job") {
138 found.jobs.push(Job {
139 file: file.to_path_buf(),
140 line,
141 function: path.clone(),
142 });
143 } else if is_attribute(attribute, "task") {
144 let task = task(&found.tasks, file, line, path.clone())
145 .map_err(|message| ModelError::new(file, line, message))?;
146 found.tasks.push(task);
147 } else if is_attribute(attribute, "every") {
148 let (cadence, each) = schedule_arguments(attribute);
149 found.schedules.push(Schedule {
150 file: file.to_path_buf(),
151 line,
152 function: path.clone(),
153 cadence,
154 each: each.map(|each| scope.callee(&each)),
155 });
156 }
157 }
158 Ok(())
159}
160
161fn task(
162 tasks: &[Task],
163 file: &Path,
164 line: usize,
165 function: Vec<String>,
166) -> Result<Task, &'static str> {
167 if function.first().map(String::as_str) != Some(TASK_MODULE) {
168 return Err("a task lives in its module's `src/tasks.rs` or `src/tasks/`");
169 }
170 if tasks
171 .iter()
172 .any(|task| task.function.last() == function.last())
173 {
174 return Err(
175 "another task of this module has this function's name; a task is named \
176 `<module>.<function>`",
177 );
178 }
179 Ok(Task {
180 file: file.to_path_buf(),
181 line,
182 function,
183 })
184}
185
186fn schedule_arguments(attribute: &Attribute) -> (Cadence, Option<Vec<String>>) {
188 attribute
189 .parse_args_with(read_schedule)
190 .unwrap_or_else(|_| {
191 let written = attribute
192 .meta
193 .require_list()
194 .map(|list| list.tokens.to_string());
195 (Cadence::Interval(written.unwrap_or_default()), None)
196 })
197}
198
199fn read_schedule(input: ParseStream) -> syn::Result<(Cadence, Option<Vec<String>>)> {
200 let interval = input
201 .peek(LitStr)
202 .then(|| input.parse::<LitStr>())
203 .transpose()?;
204 let (mut each, mut daily_at, mut weekly_on, mut zone) = (None, None, None, None);
205 let mut follows_an_argument = interval.is_some();
206 while !input.is_empty() {
207 if follows_an_argument {
208 input.parse::<Token![,]>()?;
209 if input.is_empty() {
210 break;
211 }
212 }
213 follows_an_argument = true;
214 let key: Ident = input.parse()?;
215 input.parse::<Token![=]>()?;
216 if key == "each" {
217 each = Some(segments(&input.parse::<syn::Path>()?));
218 continue;
219 }
220 let value = input.parse::<LitStr>()?.value();
221 match key.to_string().as_str() {
222 "daily_at" => daily_at = Some(value),
223 "weekly_on" => weekly_on = Some(value),
224 "zone" => zone = Some(value),
225 _ => return Err(syn::Error::new(key.span(), "unknown schedule argument")),
226 }
227 }
228 let cadence = match (interval, daily_at) {
229 (Some(interval), None) => Cadence::Interval(interval.value()),
230 (None, Some(daily_at)) => Cadence::WallClock(WallClock {
231 daily_at,
232 weekly_on,
233 zone: zone.unwrap_or_default(),
234 }),
235 _ => return Err(input.error("an interval or `daily_at`")),
236 };
237 Ok((cadence, each))
238}
239
240fn listened_event(attribute: &Attribute, function: &ItemFn) -> Result<Vec<String>, String> {
241 if !matches!(attribute.meta, Meta::Path(_)) {
242 return Err(
243 "write a bare `#[on]`: the listener's first argument's type is the event".to_owned(),
244 );
245 }
246 if let Some(FnArg::Typed(argument)) = function.sig.inputs.first()
247 && let Type::Path(path) = argument.ty.as_ref()
248 {
249 return Ok(segments(&path.path));
250 }
251 Err(
252 "a listener takes the event it hears as its first argument: `event: OrderPlaced`"
253 .to_owned(),
254 )
255}
256
257#[must_use]
258pub fn interval_seconds(text: &str) -> Option<u64> {
259 let unit_at = text.len().checked_sub(1)?;
260 let (count, unit) = text.split_at_checked(unit_at)?;
261 let scale = match unit {
262 "s" => 1,
263 "m" => 60,
264 "h" => 60 * 60,
265 "d" => 24 * 60 * 60,
266 _ => return None,
267 };
268 if !count.bytes().all(|byte| byte.is_ascii_digit()) {
269 return None;
270 }
271 count
272 .parse::<u64>()
273 .ok()
274 .filter(|count| *count > 0)
275 .and_then(|count| count.checked_mul(scale))
276}
277
278#[cfg(test)]
279mod tests {
280 #[test]
281 fn the_contract_s_generated_file_is_not_hand_written() {
282 assert!(super::is_contract_gen(std::path::Path::new(
283 "shop/modules/order/contract/src/gen/mod.rs"
284 )));
285 assert!(!super::is_contract_gen(std::path::Path::new(
286 "shop/modules/order/contract/src/area.rs"
287 )));
288 }
289
290 use super::interval_seconds;
291
292 #[test]
293 fn reads_whole_numbers_of_one_unit() {
294 assert_eq!(interval_seconds("30s"), Some(30));
295 assert_eq!(interval_seconds("15m"), Some(900));
296 assert_eq!(interval_seconds("1h"), Some(3600));
297 assert_eq!(interval_seconds("2d"), Some(172_800));
298 }
299
300 #[test]
301 fn refuses_anything_else() {
302 for text in [
303 "",
304 "h",
305 "0h",
306 "-1h",
307 "+1h",
308 "1.5h",
309 "1w",
310 "1 h",
311 "1H",
312 "99999999999999999999d",
313 ] {
314 assert_eq!(interval_seconds(text), None, "{text}");
315 }
316 }
317}