1use crate::store::{Annotation, AnnotationStore};
8use crate::types::{AttrName, Binding, RelativePath, TagName};
9use rustc_hash::{FxHashMap, FxHashSet};
10use serde::Serialize;
11use serde_json::Value as JsonValue;
12
13#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
15#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
16#[cfg_attr(feature = "ts", ts(export))]
17#[cfg_attr(feature = "flow", flow(export))]
18#[derive(Debug, Clone, Serialize)]
19pub struct Pattern {
20 pub tag: TagName,
22 #[cfg_attr(
24 feature = "ts",
25 ts(as = "std::collections::HashMap<AttrName, AttrSummary>")
26 )]
27 #[cfg_attr(
28 feature = "flow",
29 flow(as = "std::collections::HashMap<AttrName, AttrSummary>")
30 )]
31 pub common_attrs: FxHashMap<AttrName, AttrSummary>,
32 pub template_source: Option<String>,
34 pub instance_count: usize,
36}
37
38#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
40#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
41#[cfg_attr(feature = "ts", ts(export))]
42#[cfg_attr(feature = "flow", flow(export))]
43#[derive(Debug, Clone, Serialize)]
44pub struct AttrSummary {
45 pub always_present: bool,
47 pub distinct_values: usize,
49 pub sample_values: Vec<JsonValue>,
51}
52
53#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
55#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
56#[cfg_attr(feature = "ts", ts(export))]
57#[cfg_attr(feature = "flow", flow(export))]
58#[derive(Debug, Clone, Serialize)]
59pub struct Delta {
60 pub binding: Binding,
62 #[cfg_attr(
64 feature = "ts",
65 ts(as = "std::collections::HashMap<AttrName, serde_json::Value>")
66 )]
67 #[cfg_attr(
68 feature = "flow",
69 flow(as = "std::collections::HashMap<AttrName, serde_json::Value>")
70 )]
71 pub unique_attrs: FxHashMap<AttrName, JsonValue>,
72 pub file: RelativePath,
74}
75
76#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
78#[cfg_attr(feature = "flow", derive(flowjs_rs::Flow))]
79#[cfg_attr(feature = "ts", ts(export))]
80#[cfg_attr(feature = "flow", flow(export))]
81#[derive(Debug, Clone, Serialize)]
82pub struct PatternResult {
83 pub pattern: Option<Pattern>,
85 pub deltas: Vec<Delta>,
87}
88
89const MAX_SAMPLE_VALUES: usize = 3;
90
91#[must_use = "pattern contains structural analysis of the tag"]
93pub fn extract_pattern(store: &AnnotationStore, tag: &TagName) -> Option<Pattern> {
94 let annotations = collect_by_tag(store, tag);
95 if annotations.is_empty() {
96 return None;
97 }
98
99 let common_attrs = build_attr_summaries(&annotations);
100
101 Some(Pattern {
102 tag: tag.clone(),
103 common_attrs,
104 template_source: None,
105 instance_count: annotations.len(),
106 })
107}
108
109pub fn diff_from_pattern(pattern: &Pattern, annotation: &Annotation) -> Delta {
111 let mut unique_attrs = FxHashMap::default();
112
113 for (attr, value) in &annotation.attrs {
114 match pattern.common_attrs.get(attr) {
115 Some(summary) => {
116 if summary.distinct_values > 1 {
119 unique_attrs.insert(attr.clone(), value.clone());
120 }
121 }
122 None => {
123 unique_attrs.insert(attr.clone(), value.clone());
125 }
126 }
127 }
128
129 Delta {
130 binding: annotation.binding.clone(),
131 unique_attrs,
132 file: annotation.file.clone(),
133 }
134}
135
136#[must_use = "pattern and deltas contain per-instance analysis"]
138pub fn pattern_with_deltas(
139 store: &AnnotationStore,
140 tag: &TagName,
141) -> Option<(Pattern, Vec<Delta>)> {
142 let pattern = extract_pattern(store, tag)?;
143 let annotations = collect_by_tag(store, tag);
144 let deltas = annotations
145 .iter()
146 .map(|ann| diff_from_pattern(&pattern, ann))
147 .collect();
148 Some((pattern, deltas))
149}
150
151fn collect_by_tag<'a>(store: &'a AnnotationStore, tag: &TagName) -> Vec<&'a Annotation> {
153 let all = store.get_all_annotations();
154 all.iter()
155 .flat_map(|ann| collect_recursive(ann, tag))
156 .collect()
157}
158
159fn collect_recursive<'a>(ann: &'a Annotation, tag: &TagName) -> Vec<&'a Annotation> {
160 let mut out = Vec::new();
161 if ann.tag == *tag {
162 out.push(ann);
163 }
164 for child in &ann.children {
165 out.extend(collect_recursive(child, tag));
166 }
167 out
168}
169
170fn build_attr_summaries(annotations: &[&Annotation]) -> FxHashMap<AttrName, AttrSummary> {
172 let total = annotations.len();
173
174 let mut attr_names: FxHashSet<AttrName> = FxHashSet::default();
176 for ann in annotations {
177 for key in ann.attrs.keys() {
178 attr_names.insert(key.clone());
179 }
180 }
181
182 let mut summaries = FxHashMap::default();
183
184 for attr in attr_names {
185 let mut present_count = 0usize;
186 let mut distinct: FxHashSet<String> = FxHashSet::default();
187 let mut samples: Vec<JsonValue> = Vec::new();
188
189 for ann in annotations {
190 if let Some(value) = ann.attrs.get(&attr) {
191 present_count += 1;
192 let serialized = value.to_string();
193 if distinct.insert(serialized) && samples.len() < MAX_SAMPLE_VALUES {
194 samples.push(value.clone());
195 }
196 }
197 }
198
199 summaries.insert(
200 attr,
201 AttrSummary {
202 always_present: present_count == total,
203 distinct_values: distinct.len(),
204 sample_values: samples,
205 },
206 );
207 }
208
209 summaries
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use std::path::Path;
216
217 fn make_annotation(
218 tag: &str,
219 binding: &str,
220 file: &str,
221 attrs: Vec<(&str, &str)>,
222 ) -> Annotation {
223 let mut attr_map = FxHashMap::default();
224 for (k, v) in attrs {
225 attr_map.insert(AttrName::from(k), JsonValue::String(v.to_string()));
226 }
227 Annotation {
228 tag: TagName::from(tag),
229 attrs: attr_map,
230 binding: Binding::from(binding),
231 file: RelativePath::from(file),
232 children: vec![],
233 }
234 }
235
236 fn test_store() -> AnnotationStore {
237 let mut store = AnnotationStore::new(Path::new("/project"));
238 store.inject_test_data(
239 &RelativePath::from("src/routes/users.ts"),
240 vec![make_annotation(
241 "route",
242 "getUsers",
243 "src/routes/users.ts",
244 vec![("method", "GET"), ("path", "/users")],
245 )],
246 );
247 store.inject_test_data(
248 &RelativePath::from("src/routes/posts.ts"),
249 vec![make_annotation(
250 "route",
251 "getPosts",
252 "src/routes/posts.ts",
253 vec![("method", "GET"), ("path", "/posts")],
254 )],
255 );
256 store.inject_test_data(
257 &RelativePath::from("src/routes/admin.ts"),
258 vec![make_annotation(
259 "route",
260 "createUser",
261 "src/routes/admin.ts",
262 vec![("method", "POST"), ("path", "/admin/users")],
263 )],
264 );
265 store
266 }
267
268 #[test]
269 fn extract_pattern_finds_common_attrs() {
270 let store = test_store();
272 let tag = TagName::from("route");
273
274 let pattern = extract_pattern(&store, &tag).unwrap();
276
277 assert_eq!(pattern.instance_count, 3, "should find 3 route annotations");
279 assert_eq!(pattern.tag, "route", "tag should match");
280
281 let method_summary = pattern.common_attrs.get(&AttrName::from("method")).unwrap();
282 assert!(
283 method_summary.always_present,
284 "method should be always present"
285 );
286 assert_eq!(
287 method_summary.distinct_values, 2,
288 "GET and POST are distinct"
289 );
290
291 let path_summary = pattern.common_attrs.get(&AttrName::from("path")).unwrap();
292 assert!(path_summary.always_present, "path should be always present");
293 assert_eq!(path_summary.distinct_values, 3, "all paths are distinct");
294 }
295
296 #[test]
297 fn diff_from_pattern_identifies_unique_attrs() {
298 let store = test_store();
300 let tag = TagName::from("route");
301 let pattern = extract_pattern(&store, &tag).unwrap();
302 let ann = make_annotation(
303 "route",
304 "getUsers",
305 "src/routes/users.ts",
306 vec![("method", "GET"), ("path", "/users")],
307 );
308
309 let delta = diff_from_pattern(&pattern, &ann);
311
312 assert_eq!(delta.binding, "getUsers", "binding should match");
314 assert!(
315 delta.unique_attrs.contains_key(&AttrName::from("method")),
316 "method has >1 distinct value, so it is unique per instance"
317 );
318 assert!(
319 delta.unique_attrs.contains_key(&AttrName::from("path")),
320 "path has >1 distinct value, so it is unique per instance"
321 );
322 }
323
324 #[test]
325 fn pattern_with_deltas_returns_all() {
326 let store = test_store();
328 let tag = TagName::from("route");
329
330 let (pattern, deltas) = pattern_with_deltas(&store, &tag).unwrap();
332
333 assert_eq!(pattern.instance_count, 3, "should have 3 instances");
335 assert_eq!(deltas.len(), 3, "should have 3 deltas");
336 }
337
338 #[test]
339 fn missing_tag_returns_none() {
340 let store = test_store();
342 let tag = TagName::from("nonexistent");
343
344 let result = extract_pattern(&store, &tag);
346
347 assert!(result.is_none(), "should return None for unknown tag");
349 }
350
351 #[test]
352 fn constant_attr_excluded_from_delta() {
353 let mut store = AnnotationStore::new(Path::new("/project"));
355 store.inject_test_data(
356 &RelativePath::from("src/a.ts"),
357 vec![make_annotation(
358 "hook",
359 "useA",
360 "src/a.ts",
361 vec![("type", "effect")],
362 )],
363 );
364 store.inject_test_data(
365 &RelativePath::from("src/b.ts"),
366 vec![make_annotation(
367 "hook",
368 "useB",
369 "src/b.ts",
370 vec![("type", "effect")],
371 )],
372 );
373 let tag = TagName::from("hook");
374 let pattern = extract_pattern(&store, &tag).unwrap();
375 let ann = make_annotation("hook", "useA", "src/a.ts", vec![("type", "effect")]);
376
377 let delta = diff_from_pattern(&pattern, &ann);
379
380 assert!(
382 delta.unique_attrs.is_empty(),
383 "constant attr should not appear in delta"
384 );
385 }
386}