1use std::path::{Path, PathBuf};
2
3use syn::punctuated::Punctuated;
4use syn::visit::{self, Visit};
5use syn::{Expr, Item, ItemStruct, Macro, Token};
6
7use crate::code::derives;
8use crate::syntax::{has_attribute, line_of, path_ends_with, segments};
9
10#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct ProductEvent {
13 pub name: String,
14 pub values: Vec<String>,
15 pub file: PathBuf,
16 pub line: usize,
17}
18
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct TelemetryCode {
21 pub product_events: Vec<ProductEvent>,
22 pub steps: Vec<Vec<String>>,
24 pub sensitive_values: Vec<String>,
26 pub sensitive_fields: Vec<(String, String)>,
28}
29
30impl TelemetryCode {
31 pub(crate) fn collect(&mut self, item: &Item, file: &Path, module_path: &[String]) {
32 let mut calls = ProductEventCalls {
33 file,
34 found: Vec::new(),
35 };
36 calls.visit_item(item);
37 self.product_events.extend(calls.found);
38 match item {
39 Item::Fn(function) if has_attribute(&function.attrs, "span") => {
40 self.steps
41 .push([module_path, &[function.sig.ident.to_string()]].concat());
42 }
43 Item::Struct(structure) => self.collect_sensitive(structure),
44 _ => {}
45 }
46 }
47
48 fn collect_sensitive(&mut self, structure: &ItemStruct) {
49 let owner = structure.ident.to_string();
50 if derives(&structure.attrs, "Value") && has_attribute(&structure.attrs, "sensitive") {
51 self.sensitive_values.push(owner);
52 return;
53 }
54 if !derives(&structure.attrs, "Aggregate") {
55 return;
56 }
57 let fields = structure
58 .fields
59 .iter()
60 .filter(|field| has_attribute(&field.attrs, "sensitive"))
61 .filter_map(|field| field.ident.as_ref());
62 for field in fields {
63 self.sensitive_fields
64 .push((owner.clone(), field.to_string()));
65 }
66 }
67}
68
69struct ProductEventCalls<'file> {
70 file: &'file Path,
71 found: Vec<ProductEvent>,
72}
73
74impl Visit<'_> for ProductEventCalls<'_> {
75 fn visit_macro(&mut self, call: &Macro) {
76 if !path_ends_with(&call.path, &["product_event"]) {
77 visit::visit_macro(self, call);
78 return;
79 }
80 let arguments = call
81 .parse_body_with(Punctuated::<Expr, Token![,]>::parse_terminated)
82 .unwrap_or_default();
83 let mut written = arguments.iter().filter_map(dotted);
84 let Some(name) = written.next() else { return };
85 self.found.push(ProductEvent {
86 name,
87 values: written.collect(),
88 file: self.file.to_path_buf(),
89 line: line_of(&call.path),
90 });
91 }
92}
93
94fn dotted(expression: &Expr) -> Option<String> {
95 match expression {
96 Expr::Path(path) => Some(segments(&path.path).join(".")),
97 Expr::Field(field) => {
98 let member = match &field.member {
99 syn::Member::Named(ident) => ident.to_string(),
100 syn::Member::Unnamed(index) => index.index.to_string(),
101 };
102 Some(format!("{}.{member}", dotted(&field.base)?))
103 }
104 _ => None,
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use std::path::Path;
111
112 use super::TelemetryCode;
113
114 #[test]
115 fn reads_product_events_steps_and_sensitive_values_and_fields() {
116 let file: syn::File = syn::parse_str(
117 r"
118 #[derive(Value)]
119 #[sensitive]
120 pub struct TaxNumber(String);
121
122 #[derive(Value)]
123 pub struct Note(String);
124
125 #[derive(Aggregate)]
126 pub struct Payer {
127 meta: Meta,
128 name: String,
129 #[sensitive]
130 iban: String,
131 }
132
133 #[span]
134 async fn reserve_stock() {}
135
136 pub async fn place_order(order: Order) {
137 sofie::telemetry::product_event!(order.placed, order.total, count);
138 }
139 ",
140 )
141 .unwrap();
142 let mut code = TelemetryCode::default();
143 for item in &file.items {
144 code.collect(item, Path::new("actions.rs"), &["actions".to_owned()]);
145 }
146
147 let events: Vec<(&str, &[String], usize)> = code
148 .product_events
149 .iter()
150 .map(|event| (event.name.as_str(), event.values.as_slice(), event.line))
151 .collect();
152 assert_eq!(
153 events,
154 [(
155 "order.placed",
156 &["order.total".to_owned(), "count".to_owned()][..],
157 21
158 )]
159 );
160 assert_eq!(code.steps, [["actions", "reserve_stock"]]);
161 assert_eq!(code.sensitive_values, ["TaxNumber"]);
162 assert_eq!(
163 code.sensitive_fields,
164 [("Payer".to_owned(), "iban".to_owned())]
165 );
166 }
167}