Skip to main content

apcore_toolkit/
scanner.rs

1// Scanner trait and shared utilities for framework scanners.
2//
3// Provides filtering, deduplication, and annotation inference.
4// Framework-specific implementations live in separate crates
5// (e.g., axum-apcore, actix-apcore).
6
7use apcore::module::ModuleAnnotations;
8use async_trait::async_trait;
9use regex::Regex;
10
11use crate::types::ScannedModule;
12
13/// Abstract interface for framework scanners.
14///
15/// Implementors provide `scan()` for framework-specific endpoint scanning
16/// and `source_name()` for identification. The `App` type parameter allows
17/// each framework adapter to accept its own application type:
18///
19/// ```ignore
20/// // Example: Axum adapter
21/// struct AxumScanner;
22///
23/// #[async_trait]
24/// impl Scanner<axum::Router> for AxumScanner {
25///     async fn scan(&self, app: &axum::Router) -> Vec<ScannedModule> { /* ... */ }
26///     fn source_name(&self) -> &str { "axum" }
27/// }
28///
29/// // Example: Actix adapter
30/// struct ActixScanner;
31///
32/// #[async_trait]
33/// impl Scanner<()> for ActixScanner {
34///     async fn scan(&self, _app: &()) -> Vec<ScannedModule> { /* ... */ }
35///     fn source_name(&self) -> &str { "actix-web" }
36/// }
37/// ```
38#[async_trait]
39pub trait Scanner<App: Send + Sync = ()> {
40    /// Scan endpoints and return module definitions.
41    ///
42    /// The `app` parameter receives framework-specific state (e.g., `axum::Router`,
43    /// `actix_web::App`). Use `()` if no app context is needed.
44    async fn scan(&self, app: &App) -> Vec<ScannedModule>;
45
46    /// Return human-readable scanner name (e.g., "axum", "actix-web").
47    fn source_name(&self) -> &str;
48}
49
50/// Apply include/exclude regex filters to scanned modules.
51///
52/// - `include`: If set, only modules whose `module_id` matches are kept.
53/// - `exclude`: If set, modules whose `module_id` matches are removed.
54///
55/// Returns an error if either pattern is not a valid regex.
56pub fn filter_modules(
57    modules: &[ScannedModule],
58    include: Option<&str>,
59    exclude: Option<&str>,
60) -> Result<Vec<ScannedModule>, regex::Error> {
61    let mut result: Vec<ScannedModule> = modules.to_vec();
62
63    if let Some(pattern) = include {
64        let re = Regex::new(pattern)?;
65        result.retain(|m| re.is_match(&m.module_id));
66    }
67
68    if let Some(pattern) = exclude {
69        let re = Regex::new(pattern)?;
70        result.retain(|m| !re.is_match(&m.module_id));
71    }
72
73    Ok(result)
74}
75
76/// Resolve duplicate module IDs by appending `_2`, `_3`, etc.
77///
78/// A warning is appended to the module's warnings list when a rename occurs.
79pub fn deduplicate_ids(modules: Vec<ScannedModule>) -> Vec<ScannedModule> {
80    let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
81    let mut result: Vec<ScannedModule> = Vec::with_capacity(modules.len());
82
83    for mut module in modules {
84        let mid = module.module_id.clone();
85        let count = seen.entry(mid.clone()).or_insert(0);
86        *count += 1;
87
88        if *count > 1 {
89            let new_id = format!("{}_{}", mid, count);
90            module.warnings.push(format!(
91                "Module ID renamed from '{}' to '{}' to avoid collision",
92                mid, new_id
93            ));
94            module.module_id = new_id;
95        }
96
97        result.push(module);
98    }
99
100    result
101}
102
103/// Infer behavioral annotations from an HTTP method.
104///
105/// Mapping:
106/// - GET    -> readonly=true, cacheable=true
107/// - DELETE -> destructive=true
108/// - PUT    -> idempotent=true
109/// - Others -> default (all false)
110pub fn infer_annotations_from_method(method: &str) -> ModuleAnnotations {
111    match method.to_uppercase().as_str() {
112        "GET" => ModuleAnnotations {
113            readonly: true,
114            cacheable: true,
115            ..Default::default()
116        },
117        "DELETE" => ModuleAnnotations {
118            destructive: true,
119            ..Default::default()
120        },
121        "PUT" => ModuleAnnotations {
122            idempotent: true,
123            ..Default::default()
124        },
125        _ => ModuleAnnotations::default(),
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use serde_json::json;
133
134    fn make_module(id: &str) -> ScannedModule {
135        ScannedModule::new(
136            id.into(),
137            "test".into(),
138            json!({}),
139            json!({}),
140            vec![],
141            "app:func".into(),
142        )
143    }
144
145    #[test]
146    fn test_filter_modules_include() {
147        let modules = vec![
148            make_module("users.get"),
149            make_module("users.create"),
150            make_module("tasks.list"),
151        ];
152        let filtered = filter_modules(&modules, Some("users"), None).unwrap();
153        assert_eq!(filtered.len(), 2);
154        assert!(filtered.iter().all(|m| m.module_id.starts_with("users")));
155    }
156
157    #[test]
158    fn test_filter_modules_exclude() {
159        let modules = vec![
160            make_module("users.get"),
161            make_module("users.create"),
162            make_module("tasks.list"),
163        ];
164        let filtered = filter_modules(&modules, None, Some("users")).unwrap();
165        assert_eq!(filtered.len(), 1);
166        assert_eq!(filtered[0].module_id, "tasks.list");
167    }
168
169    #[test]
170    fn test_filter_modules_both() {
171        let modules = vec![
172            make_module("users.get"),
173            make_module("users.admin.create"),
174            make_module("tasks.list"),
175        ];
176        let filtered = filter_modules(&modules, Some("users"), Some("admin")).unwrap();
177        assert_eq!(filtered.len(), 1);
178        assert_eq!(filtered[0].module_id, "users.get");
179    }
180
181    #[test]
182    fn test_deduplicate_ids_no_duplicates() {
183        let modules = vec![make_module("a"), make_module("b")];
184        let result = deduplicate_ids(modules);
185        assert_eq!(result[0].module_id, "a");
186        assert_eq!(result[1].module_id, "b");
187        assert!(result[0].warnings.is_empty());
188    }
189
190    #[test]
191    fn test_deduplicate_ids_with_duplicates() {
192        let modules = vec![make_module("a"), make_module("a"), make_module("a")];
193        let result = deduplicate_ids(modules);
194        assert_eq!(result[0].module_id, "a");
195        assert_eq!(result[1].module_id, "a_2");
196        assert_eq!(result[2].module_id, "a_3");
197        assert!(result[1].warnings[0].contains("renamed"));
198    }
199
200    #[test]
201    fn test_infer_annotations_get() {
202        let ann = infer_annotations_from_method("GET");
203        assert!(ann.readonly);
204        assert!(ann.cacheable);
205        assert!(!ann.destructive);
206    }
207
208    #[test]
209    fn test_infer_annotations_delete() {
210        let ann = infer_annotations_from_method("DELETE");
211        assert!(ann.destructive);
212        assert!(!ann.readonly);
213    }
214
215    #[test]
216    fn test_infer_annotations_put() {
217        let ann = infer_annotations_from_method("PUT");
218        assert!(ann.idempotent);
219        assert!(!ann.readonly);
220    }
221
222    #[test]
223    fn test_infer_annotations_post() {
224        let ann = infer_annotations_from_method("POST");
225        assert!(!ann.readonly);
226        assert!(!ann.destructive);
227        assert!(!ann.idempotent);
228    }
229
230    #[test]
231    fn test_infer_annotations_case_insensitive() {
232        let ann = infer_annotations_from_method("get");
233        assert!(ann.readonly);
234    }
235
236    #[test]
237    fn test_filter_modules_no_filters() {
238        let modules = vec![make_module("users.get"), make_module("tasks.list")];
239        let filtered = filter_modules(&modules, None, None).unwrap();
240        assert_eq!(filtered.len(), 2);
241    }
242
243    #[test]
244    fn test_filter_modules_include_matches_none() {
245        let modules = vec![make_module("users.get"), make_module("tasks.list")];
246        let filtered = filter_modules(&modules, Some("^zzz$"), None).unwrap();
247        assert!(filtered.is_empty());
248    }
249
250    #[test]
251    fn test_filter_modules_exclude_matches_all() {
252        let modules = vec![make_module("users.get"), make_module("users.create")];
253        let filtered = filter_modules(&modules, None, Some("users")).unwrap();
254        assert!(filtered.is_empty());
255    }
256
257    #[test]
258    fn test_filter_modules_invalid_include_regex() {
259        let modules = vec![make_module("a")];
260        let result = filter_modules(&modules, Some("[invalid"), None);
261        assert!(result.is_err());
262    }
263
264    #[test]
265    fn test_filter_modules_invalid_exclude_regex() {
266        let modules = vec![make_module("a")];
267        let result = filter_modules(&modules, None, Some("[invalid"));
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn test_deduplicate_ids_empty_list() {
273        let result = deduplicate_ids(vec![]);
274        assert!(result.is_empty());
275    }
276
277    #[test]
278    fn test_deduplicate_ids_original_unchanged() {
279        let original = vec![make_module("a"), make_module("a")];
280        let cloned = original.clone();
281        let result = deduplicate_ids(original);
282
283        // The original Vec is consumed by deduplicate_ids (ownership).
284        // Verify the clone is independent and unmodified.
285        assert_eq!(cloned[0].module_id, "a");
286        assert_eq!(cloned[1].module_id, "a");
287        assert!(cloned[0].warnings.is_empty());
288        assert!(cloned[1].warnings.is_empty());
289
290        // The result has been deduplicated.
291        assert_eq!(result[1].module_id, "a_2");
292    }
293
294    #[test]
295    fn test_deduplicate_ids_mixed() {
296        let modules = vec![
297            make_module("a"),
298            make_module("b"),
299            make_module("a"),
300            make_module("c"),
301            make_module("b"),
302        ];
303        let result = deduplicate_ids(modules);
304        assert_eq!(result[0].module_id, "a");
305        assert_eq!(result[1].module_id, "b");
306        assert_eq!(result[2].module_id, "a_2");
307        assert_eq!(result[3].module_id, "c");
308        assert_eq!(result[4].module_id, "b_2");
309    }
310
311    #[test]
312    fn test_deduplicate_warnings_first_no_warning() {
313        let modules = vec![make_module("x"), make_module("x")];
314        let result = deduplicate_ids(modules);
315        assert!(
316            result[0].warnings.is_empty(),
317            "First occurrence should have no warning"
318        );
319        assert!(
320            !result[1].warnings.is_empty(),
321            "Duplicate should have a warning"
322        );
323    }
324
325    #[test]
326    fn test_deduplicate_warnings_preserved() {
327        let mut m = make_module("dup");
328        m.warnings.push("existing warning".into());
329        let modules = vec![make_module("dup"), m];
330        let result = deduplicate_ids(modules);
331
332        // Second module had an existing warning; it should still be there
333        // along with the new rename warning.
334        assert_eq!(result[1].warnings.len(), 2);
335        assert_eq!(result[1].warnings[0], "existing warning");
336        assert!(result[1].warnings[1].contains("renamed"));
337    }
338
339    #[test]
340    fn test_infer_annotations_patch() {
341        let ann = infer_annotations_from_method("PATCH");
342        assert!(!ann.readonly);
343        assert!(!ann.destructive);
344        assert!(!ann.idempotent);
345        assert!(!ann.cacheable);
346    }
347}