Skip to main content

cortex_runtime/acquisition/
drag_discovery.rs

1//! Discovers drag-and-drop interactions and maps them to their underlying API calls.
2//!
3//! Instead of simulating mouse drags in a browser, this module identifies the
4//! HTTP endpoint that persists the drag result. Three discovery strategies:
5//!
6//! 1. **Library detection** -- recognise react-beautiful-dnd, SortableJS, Angular CDK,
7//!    dnd-kit, jQuery UI, and HTML5 native drag from DOM attributes and JS signatures.
8//! 2. **Element discovery** -- find draggable elements, drop zones, and their data attributes.
9//! 3. **API extraction** -- scan JS bundles for the fetch/axios/XHR call made after a drop.
10//!
11//! All public entry points are **synchronous**. Callers should wrap in
12//! `tokio::task::spawn_blocking` when integrating with the async runtime.
13
14use crate::map::types::OpCode;
15use regex::Regex;
16use scraper::{Html, Selector};
17use serde::{Deserialize, Serialize};
18use std::sync::OnceLock;
19
20// ── Compile-time platform configuration ─────────────────────────────────────
21
22/// Raw JSON content of the drag platform templates, embedded at compile time.
23const DRAG_PLATFORMS_JSON: &str = include_str!("drag_platforms.json");
24
25// ── Public types ────────────────────────────────────────────────────────────
26
27/// Which drag-and-drop library is in use on the page.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub enum DragLibrary {
30    /// Facebook's react-beautiful-dnd library.
31    ReactBeautifulDnd,
32    /// SortableJS / Sortable library.
33    SortableJS,
34    /// Angular CDK drag-and-drop module.
35    AngularCdk,
36    /// dnd-kit (React-based drag-and-drop toolkit).
37    DndKit,
38    /// jQuery UI Sortable / Draggable.
39    JQueryUI,
40    /// Native HTML5 drag-and-drop API.
41    Html5Native,
42    /// No recognised drag-and-drop library.
43    Unknown,
44}
45
46/// An API endpoint discovered from JavaScript analysis.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ApiEndpoint {
49    /// The endpoint URL (may contain path templates like `{id}`).
50    pub url: String,
51    /// HTTP method: GET, POST, PUT, PATCH, or DELETE.
52    pub method: String,
53    /// Optional body template extracted from JS source.
54    pub body_template: Option<String>,
55}
56
57/// A discovered drag-and-drop interaction that can be replayed via HTTP.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct DragAction {
60    /// The drag-and-drop library detected on the page.
61    pub drag_library: DragLibrary,
62    /// CSS selector for draggable elements.
63    pub draggable_selector: String,
64    /// Data attribute on draggable elements that holds the source item ID.
65    pub source_id_attr: String,
66    /// CSS selector for drop zone elements.
67    pub drop_zone_selector: String,
68    /// Data attribute on drop zones that holds the target container ID.
69    pub target_id_attr: String,
70    /// The API endpoint that persists the drag result (if discovered).
71    pub api_endpoint: Option<ApiEndpoint>,
72    /// Query/body parameter name for the new position (e.g., `"pos"`, `"index"`).
73    pub position_param: String,
74    /// OpCode for this drag action in the binary map spec.
75    pub opcode: OpCode,
76    /// Confidence that this drag interaction is correctly identified, in `[0.0, 1.0]`.
77    pub confidence: f32,
78}
79
80// ── Internal JSON deserialization types ─────────────────────────────────────
81
82/// Known drag-and-drop configuration for a specific platform domain.
83#[derive(Debug, Clone, Deserialize)]
84struct PlatformDragConfig {
85    /// The type of drag interaction (e.g., `"card_move"`, `"task_move"`).
86    drag_type: String,
87    /// API endpoint details for persisting the drag result.
88    api: PlatformDragApi,
89    /// CSS selector for source (draggable) elements.
90    source_selector: String,
91    /// Data attribute name that holds the source element ID.
92    source_id: Option<String>,
93    /// CSS selector for target (drop zone) elements.
94    target_selector: Option<String>,
95    /// Data attribute name that holds the target zone ID.
96    target_id: Option<String>,
97}
98
99/// API configuration for a platform's drag-and-drop endpoint.
100#[derive(Debug, Clone, Deserialize)]
101struct PlatformDragApi {
102    /// HTTP method (e.g., `"PUT"`, `"POST"`, `"PATCH"`).
103    method: String,
104    /// URL path template (e.g., `"/1/cards/{card_id}"`).
105    path: String,
106    /// Optional body template as a JSON value.
107    body: Option<serde_json::Value>,
108}
109
110type DragPlatformRegistry = std::collections::HashMap<String, PlatformDragConfig>;
111
112/// Parse and cache the embedded drag platform templates.
113fn drag_platform_registry() -> &'static DragPlatformRegistry {
114    static REGISTRY: OnceLock<DragPlatformRegistry> = OnceLock::new();
115    REGISTRY.get_or_init(|| serde_json::from_str(DRAG_PLATFORMS_JSON).unwrap_or_default())
116}
117
118// ── Public API ──────────────────────────────────────────────────────────────
119
120/// Check if a domain has a known drag-enabled platform configuration.
121pub fn has_known_drag(domain: &str) -> bool {
122    let registry = drag_platform_registry();
123    registry.contains_key(domain)
124        || registry
125            .keys()
126            .any(|k| domain.ends_with(k.as_str()) || k.contains(domain))
127}
128
129/// Discover drag-and-drop interactions from HTML and JavaScript bundles.
130///
131/// This is the main entry point. It layers three discovery strategies:
132///
133/// 1. **Platform registry** -- checks if the page belongs to a known platform
134///    (Trello, GitHub Projects, Asana, etc.) and returns pre-configured actions.
135/// 2. **Library detection** -- identifies the drag-and-drop library from DOM
136///    attributes and JS signatures.
137/// 3. **API extraction** -- scans JS bundles for the HTTP call made after a drop.
138///
139/// # Arguments
140///
141/// * `html` -- raw HTML source of the page.
142/// * `js_bundles` -- JavaScript source texts loaded by the page.
143///
144/// # Returns
145///
146/// A vector of [`DragAction`] items, one per discovered drag interaction.
147pub fn discover_drag_actions(html: &str, js_bundles: &[String]) -> Vec<DragAction> {
148    let mut actions = Vec::new();
149
150    // Strategy 1: check platform registry for known domains.
151    if let Some(domain) = extract_domain_from_html(html) {
152        let platform_actions = discover_drag_from_platform(&domain);
153        if !platform_actions.is_empty() {
154            return platform_actions;
155        }
156    }
157
158    // Strategy 2: detect drag library from HTML attributes and JS signatures.
159    let library = detect_drag_library(html, js_bundles);
160    if library == DragLibrary::Unknown {
161        return actions;
162    }
163
164    // Strategy 3: find draggable elements, drop zones, and API endpoint.
165    let (draggable_selector, source_id_attr, drop_zone_selector, target_id_attr) =
166        find_drag_elements(html, &library);
167
168    let api_endpoint = scan_js_for_drag_api(js_bundles);
169
170    let confidence = compute_confidence(&library, &api_endpoint, &draggable_selector);
171
172    actions.push(DragAction {
173        drag_library: library,
174        draggable_selector,
175        source_id_attr,
176        drop_zone_selector,
177        target_id_attr,
178        api_endpoint,
179        position_param: "position".to_string(),
180        opcode: OpCode::new(0x07, 0x00),
181        confidence,
182    });
183
184    actions
185}
186
187/// Detect which drag-and-drop library is in use on the page.
188///
189/// Checks HTML for characteristic DOM attributes and CSS classes, then
190/// scans JavaScript bundles for library-specific function signatures.
191///
192/// # Detection rules
193///
194/// | Signal | Library |
195/// |--------|---------|
196/// | `[data-rbd-draggable-id]` in HTML | react-beautiful-dnd |
197/// | `[cdkDrag]` in HTML | Angular CDK |
198/// | `.sortable` class in HTML | SortableJS |
199/// | `.ui-sortable` class in HTML | jQuery UI |
200/// | `[draggable="true"]` in HTML | HTML5 Native |
201/// | `Sortable.create` in JS | SortableJS |
202/// | `DragDropContext` in JS | react-beautiful-dnd |
203/// | `useDraggable` or `@dnd-kit` in JS | dnd-kit |
204/// | `cdkDrag` in JS | Angular CDK |
205///
206/// # Arguments
207///
208/// * `html` -- raw HTML source of the page.
209/// * `js_bundles` -- JavaScript source texts loaded by the page.
210///
211/// # Returns
212///
213/// The detected [`DragLibrary`] variant, or [`DragLibrary::Unknown`].
214pub fn detect_drag_library(html: &str, js_bundles: &[String]) -> DragLibrary {
215    // Check HTML DOM attributes first (more specific signals).
216    let document = Html::parse_document(html);
217
218    // react-beautiful-dnd: data-rbd-draggable-id
219    if let Ok(sel) = Selector::parse("[data-rbd-draggable-id]") {
220        if document.select(&sel).next().is_some() {
221            return DragLibrary::ReactBeautifulDnd;
222        }
223    }
224
225    // Angular CDK: cdkDrag attribute
226    if let Ok(sel) = Selector::parse("[cdkDrag], [cdkdrag]") {
227        if document.select(&sel).next().is_some() {
228            return DragLibrary::AngularCdk;
229        }
230    }
231
232    // jQuery UI: .ui-sortable class
233    if let Ok(sel) = Selector::parse(".ui-sortable") {
234        if document.select(&sel).next().is_some() {
235            return DragLibrary::JQueryUI;
236        }
237    }
238
239    // SortableJS: .sortable class (check after jQuery UI to avoid false positives)
240    if let Ok(sel) = Selector::parse(".sortable") {
241        if document.select(&sel).next().is_some() {
242            return DragLibrary::SortableJS;
243        }
244    }
245
246    // Check JS bundles for library signatures.
247    let js_combined: String = js_bundles
248        .iter()
249        .map(|s| s.as_str())
250        .collect::<Vec<_>>()
251        .join("\n");
252
253    if js_combined.contains("DragDropContext") || js_combined.contains("data-rbd-draggable-id") {
254        return DragLibrary::ReactBeautifulDnd;
255    }
256
257    if js_combined.contains("useDraggable") || js_combined.contains("@dnd-kit") {
258        return DragLibrary::DndKit;
259    }
260
261    if js_combined.contains("Sortable.create") || js_combined.contains("new Sortable") {
262        return DragLibrary::SortableJS;
263    }
264
265    if js_combined.contains("cdkDrag") || js_combined.contains("CdkDragDrop") {
266        return DragLibrary::AngularCdk;
267    }
268
269    // HTML5 native: draggable="true" (least specific, check last)
270    if let Ok(sel) = Selector::parse("[draggable=\"true\"]") {
271        if document.select(&sel).next().is_some() {
272            return DragLibrary::Html5Native;
273        }
274    }
275
276    DragLibrary::Unknown
277}
278
279/// Look up a domain in the platform registry and return pre-configured drag actions.
280///
281/// Known platforms include Trello, GitHub Projects, Asana, Notion, Monday.com,
282/// and Jira. Each has pre-mapped API endpoints and selectors embedded in
283/// `drag_platforms.json`.
284///
285/// # Arguments
286///
287/// * `domain` -- the domain to look up (e.g., `"trello.com"`).
288///
289/// # Returns
290///
291/// A vector of [`DragAction`] items from the platform template, or an empty
292/// vector if the domain is not recognised.
293pub fn discover_drag_from_platform(domain: &str) -> Vec<DragAction> {
294    let registry = drag_platform_registry();
295
296    // Try exact match first, then check if the domain ends with a known platform.
297    let config = registry.get(domain).or_else(|| {
298        registry
299            .iter()
300            .find(|(key, _)| domain.ends_with(key.as_str()))
301            .map(|(_, v)| v)
302    });
303
304    let config = match config {
305        Some(c) => c,
306        None => return Vec::new(),
307    };
308
309    let body_template = config
310        .api
311        .body
312        .as_ref()
313        .map(|b| serde_json::to_string(b).unwrap_or_default());
314
315    let api_endpoint = ApiEndpoint {
316        url: config.api.path.clone(),
317        method: config.api.method.clone(),
318        body_template,
319    };
320
321    vec![DragAction {
322        drag_library: DragLibrary::Unknown,
323        draggable_selector: config.source_selector.clone(),
324        source_id_attr: config.source_id.clone().unwrap_or_default(),
325        drop_zone_selector: config.target_selector.clone().unwrap_or_default(),
326        target_id_attr: config.target_id.clone().unwrap_or_default(),
327        api_endpoint: Some(api_endpoint),
328        position_param: "position".to_string(),
329        opcode: OpCode::new(0x07, 0x00),
330        confidence: 0.95,
331    }]
332}
333
334// ── Private helpers ─────────────────────────────────────────────────────────
335
336/// Scan JavaScript bundles for the API call made after a drag-and-drop event.
337///
338/// Looks for function bodies named `onDragEnd`, `handleDrop`, `onSortEnd`, or
339/// `dropHandler`, then extracts `fetch()` / `axios` / `$.ajax` calls within.
340fn scan_js_for_drag_api(js_bundles: &[String]) -> Option<ApiEndpoint> {
341    let js_combined: String = js_bundles
342        .iter()
343        .map(|s| s.as_str())
344        .collect::<Vec<_>>()
345        .join("\n");
346
347    // Find the start of each drag handler by name.
348    let handler_name_re = Regex::new(
349        r"(?:onDragEnd|handleDrop|onSortEnd|dropHandler|onDragStop)\s*(?:=\s*(?:async\s*)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>|[:=]\s*(?:async\s+)?function\s*\([^)]*\))\s*\{"
350    ).ok()?;
351
352    // Pre-compile all regexes outside the loop to satisfy clippy.
353    let fetch_re = Regex::new(
354        r#"fetch\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*\{[^}]*method\s*:\s*['"`](\w+)['"`])?"#,
355    )
356    .ok()?;
357    let axios_re =
358        Regex::new(r#"axios\.(get|post|put|patch|delete)\(\s*['"`]([^'"`]+)['"`]"#).ok()?;
359    let ajax_re = Regex::new(r#"\$\.ajax\(\s*\{([^}]*)\}"#).ok()?;
360    let url_re = Regex::new(r#"url\s*:\s*['"`]([^'"`]+)['"`]"#).ok()?;
361    let type_re = Regex::new(r#"type\s*:\s*['"`](\w+)['"`]"#).ok()?;
362
363    for m in handler_name_re.find_iter(&js_combined) {
364        // Extract the function body by counting brace depth from the opening `{`.
365        let body = extract_brace_body(&js_combined[m.end()..]);
366
367        // Look for fetch() calls inside the handler body.
368        if let Some(fetch_caps) = fetch_re.captures(&body) {
369            let url = fetch_caps.get(1).map_or("", |m| m.as_str()).to_string();
370            let method = fetch_caps
371                .get(2)
372                .map_or("POST", |m| m.as_str())
373                .to_uppercase();
374
375            let body_template = extract_body_template(&body);
376
377            return Some(ApiEndpoint {
378                url,
379                method,
380                body_template,
381            });
382        }
383
384        // Look for axios calls inside the handler body.
385        if let Some(axios_caps) = axios_re.captures(&body) {
386            let method = axios_caps
387                .get(1)
388                .map_or("POST", |m| m.as_str())
389                .to_uppercase();
390            let url = axios_caps.get(2).map_or("", |m| m.as_str()).to_string();
391            let body_template = extract_body_template(&body);
392
393            return Some(ApiEndpoint {
394                url,
395                method,
396                body_template,
397            });
398        }
399
400        // Look for $.ajax calls inside the handler body.
401        if let Some(ajax_caps) = ajax_re.captures(&body) {
402            let ajax_block = ajax_caps.get(1).map_or("", |m| m.as_str());
403
404            if let Some(url_caps) = url_re.captures(ajax_block) {
405                let url = url_caps.get(1).map_or("", |m| m.as_str()).to_string();
406                let method = type_re
407                    .captures(ajax_block)
408                    .and_then(|c| c.get(1))
409                    .map_or("POST", |m| m.as_str())
410                    .to_uppercase();
411
412                return Some(ApiEndpoint {
413                    url,
414                    method,
415                    body_template: None,
416                });
417            }
418        }
419    }
420
421    None
422}
423
424/// Extract the content between a matched opening `{` and its balancing closing `}`.
425///
426/// `s` must start right *after* the opening brace. Returns the text between
427/// the braces (exclusive), or the full string if no balancing brace is found.
428fn extract_brace_body(s: &str) -> String {
429    let mut depth: u32 = 1;
430    let mut in_string = false;
431    let mut string_char: char = '"';
432    let mut prev_char = '\0';
433
434    for (i, ch) in s.char_indices() {
435        if in_string {
436            if ch == string_char && prev_char != '\\' {
437                in_string = false;
438            }
439            prev_char = ch;
440            continue;
441        }
442
443        match ch {
444            '"' | '\'' | '`' => {
445                in_string = true;
446                string_char = ch;
447            }
448            '{' => depth += 1,
449            '}' => {
450                depth -= 1;
451                if depth == 0 {
452                    return s[..i].to_string();
453                }
454            }
455            _ => {}
456        }
457        prev_char = ch;
458    }
459
460    s.to_string()
461}
462
463/// Try to extract a body template from a JS function body.
464///
465/// Looks for `JSON.stringify(...)` or inline object literals near fetch/axios calls.
466fn extract_body_template(js_body: &str) -> Option<String> {
467    // Look for JSON.stringify({ ... })
468    let stringify_re = Regex::new(r"JSON\.stringify\(\s*(\{[^}]+\})").ok()?;
469    if let Some(caps) = stringify_re.captures(js_body) {
470        return Some(caps.get(1).map_or("", |m| m.as_str()).to_string());
471    }
472
473    // Look for body: { ... }
474    let body_obj_re = Regex::new(r"body\s*:\s*(\{[^}]+\})").ok()?;
475    if let Some(caps) = body_obj_re.captures(js_body) {
476        return Some(caps.get(1).map_or("", |m| m.as_str()).to_string());
477    }
478
479    None
480}
481
482/// Extract the domain from HTML `<base>`, `<link rel="canonical">`, or `<meta>` tags.
483fn extract_domain_from_html(html: &str) -> Option<String> {
484    let document = Html::parse_document(html);
485
486    // Try <base href="...">
487    if let Ok(sel) = Selector::parse("base[href]") {
488        if let Some(el) = document.select(&sel).next() {
489            if let Some(href) = el.value().attr("href") {
490                if let Some(domain) = domain_from_url(href) {
491                    return Some(domain);
492                }
493            }
494        }
495    }
496
497    // Try <link rel="canonical" href="...">
498    if let Ok(sel) = Selector::parse("link[rel=\"canonical\"]") {
499        if let Some(el) = document.select(&sel).next() {
500            if let Some(href) = el.value().attr("href") {
501                if let Some(domain) = domain_from_url(href) {
502                    return Some(domain);
503                }
504            }
505        }
506    }
507
508    // Try <meta property="og:url" content="...">
509    if let Ok(sel) = Selector::parse("meta[property=\"og:url\"]") {
510        if let Some(el) = document.select(&sel).next() {
511            if let Some(content) = el.value().attr("content") {
512                if let Some(domain) = domain_from_url(content) {
513                    return Some(domain);
514                }
515            }
516        }
517    }
518
519    None
520}
521
522/// Extract the host/domain from a URL string.
523fn domain_from_url(url_str: &str) -> Option<String> {
524    url::Url::parse(url_str)
525        .ok()
526        .and_then(|u| u.host_str().map(String::from))
527}
528
529/// Find draggable elements and drop zones based on the detected library.
530///
531/// Returns `(draggable_selector, source_id_attr, drop_zone_selector, target_id_attr)`.
532fn find_drag_elements(html: &str, library: &DragLibrary) -> (String, String, String, String) {
533    let document = Html::parse_document(html);
534
535    match library {
536        DragLibrary::ReactBeautifulDnd => {
537            let draggable_sel = "[data-rbd-draggable-id]".to_string();
538            let source_id = "data-rbd-draggable-id".to_string();
539            let drop_zone_sel = "[data-rbd-droppable-id]".to_string();
540            let target_id = "data-rbd-droppable-id".to_string();
541            (draggable_sel, source_id, drop_zone_sel, target_id)
542        }
543        DragLibrary::SortableJS => {
544            // SortableJS uses .sortable containers; children are draggable.
545            let draggable_sel = ".sortable > *".to_string();
546            let source_id = find_data_id_attr(&document, ".sortable > *");
547            let drop_zone_sel = ".sortable".to_string();
548            let target_id = find_data_id_attr(&document, ".sortable");
549            (draggable_sel, source_id, drop_zone_sel, target_id)
550        }
551        DragLibrary::AngularCdk => {
552            let draggable_sel = "[cdkDrag], [cdkdrag]".to_string();
553            let source_id = find_data_id_attr(&document, "[cdkDrag], [cdkdrag]");
554            let drop_zone_sel = "[cdkDropList], [cdkdroplist]".to_string();
555            let target_id = find_data_id_attr(&document, "[cdkDropList], [cdkdroplist]");
556            (draggable_sel, source_id, drop_zone_sel, target_id)
557        }
558        DragLibrary::DndKit => {
559            let draggable_sel = "[data-dnd-draggable]".to_string();
560            let source_id = "data-dnd-draggable".to_string();
561            let drop_zone_sel = "[data-dnd-droppable]".to_string();
562            let target_id = "data-dnd-droppable".to_string();
563            (draggable_sel, source_id, drop_zone_sel, target_id)
564        }
565        DragLibrary::JQueryUI => {
566            let draggable_sel = ".ui-sortable > *".to_string();
567            let source_id = find_data_id_attr(&document, ".ui-sortable > *");
568            let drop_zone_sel = ".ui-sortable".to_string();
569            let target_id = find_data_id_attr(&document, ".ui-sortable");
570            (draggable_sel, source_id, drop_zone_sel, target_id)
571        }
572        DragLibrary::Html5Native => {
573            let draggable_sel = "[draggable=\"true\"]".to_string();
574            let source_id = find_data_id_attr(&document, "[draggable=\"true\"]");
575            let drop_zone_sel = "[data-drop-zone], [ondrop]".to_string();
576            let target_id = find_data_id_attr(&document, "[data-drop-zone], [ondrop]");
577            (draggable_sel, source_id, drop_zone_sel, target_id)
578        }
579        DragLibrary::Unknown => (
580            "".to_string(),
581            "".to_string(),
582            "".to_string(),
583            "".to_string(),
584        ),
585    }
586}
587
588/// Try to find a `data-*-id` attribute on the first element matching a selector.
589fn find_data_id_attr(document: &Html, selector_str: &str) -> String {
590    let sel = match Selector::parse(selector_str) {
591        Ok(s) => s,
592        Err(_) => return "id".to_string(),
593    };
594
595    if let Some(el) = document.select(&sel).next() {
596        // Look for data-*-id or data-id attributes.
597        for attr in el.value().attrs() {
598            let (name, _) = attr;
599            if name.starts_with("data-") && (name.ends_with("-id") || name == "data-id") {
600                return name.to_string();
601            }
602        }
603        // Fall back to "id" if the element has an id attribute.
604        if el.value().attr("id").is_some() {
605            return "id".to_string();
606        }
607    }
608
609    "id".to_string()
610}
611
612/// Compute confidence based on the quality of discovery signals.
613fn compute_confidence(
614    library: &DragLibrary,
615    api_endpoint: &Option<ApiEndpoint>,
616    draggable_selector: &str,
617) -> f32 {
618    let mut confidence = 0.0f32;
619
620    // Known library adds base confidence.
621    confidence += match library {
622        DragLibrary::ReactBeautifulDnd => 0.40,
623        DragLibrary::SortableJS => 0.35,
624        DragLibrary::AngularCdk => 0.35,
625        DragLibrary::DndKit => 0.35,
626        DragLibrary::JQueryUI => 0.30,
627        DragLibrary::Html5Native => 0.20,
628        DragLibrary::Unknown => 0.0,
629    };
630
631    // Found API endpoint adds significant confidence.
632    if api_endpoint.is_some() {
633        confidence += 0.40;
634    }
635
636    // Found draggable elements in DOM adds confidence.
637    if !draggable_selector.is_empty() {
638        confidence += 0.15;
639    }
640
641    confidence.min(1.0)
642}
643
644// ── Tests ───────────────────────────────────────────────────────────────────
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn test_detect_react_beautiful_dnd() {
652        let html = r#"
653        <html>
654        <body>
655            <div data-rbd-droppable-id="list-1">
656                <div data-rbd-draggable-id="item-1" data-rbd-drag-handle-draggable-id="item-1">
657                    <span>Task 1</span>
658                </div>
659                <div data-rbd-draggable-id="item-2" data-rbd-drag-handle-draggable-id="item-2">
660                    <span>Task 2</span>
661                </div>
662            </div>
663        </body>
664        </html>
665        "#;
666
667        let library = detect_drag_library(html, &[]);
668        assert_eq!(library, DragLibrary::ReactBeautifulDnd);
669
670        // Also test via JS detection.
671        let js =
672            vec!["import { DragDropContext, Droppable } from 'react-beautiful-dnd';".to_string()];
673        let library_js = detect_drag_library("<html><body></body></html>", &js);
674        assert_eq!(library_js, DragLibrary::ReactBeautifulDnd);
675    }
676
677    #[test]
678    fn test_detect_sortablejs() {
679        let html = r#"
680        <html>
681        <body>
682            <ul class="sortable" id="task-list">
683                <li data-item-id="1">Item 1</li>
684                <li data-item-id="2">Item 2</li>
685                <li data-item-id="3">Item 3</li>
686            </ul>
687        </body>
688        </html>
689        "#;
690
691        let js = vec!["var sortable = Sortable.create(document.getElementById('task-list'), { animation: 150 });".to_string()];
692
693        let library = detect_drag_library(html, &js);
694        assert_eq!(library, DragLibrary::SortableJS);
695    }
696
697    #[test]
698    fn test_discover_drag_from_platform_trello() {
699        let actions = discover_drag_from_platform("trello.com");
700        assert!(!actions.is_empty());
701
702        let action = &actions[0];
703        assert_eq!(action.opcode, OpCode::new(0x07, 0x00));
704        assert!(action.confidence > 0.0);
705        assert!(action.api_endpoint.is_some());
706
707        let api = action.api_endpoint.as_ref().unwrap();
708        assert_eq!(api.method, "PUT");
709        assert!(api.url.contains("/cards/"));
710    }
711
712    #[test]
713    fn test_scan_js_for_drag_api() {
714        let js_with_handler = r#"
715            const onDragEnd = async (result) => {
716                if (!result.destination) return;
717                const { source, destination } = result;
718                await fetch('/api/reorder', {
719                    method: 'PUT',
720                    headers: { 'Content-Type': 'application/json' },
721                    body: JSON.stringify({ itemId: source.index, newPosition: destination.index })
722                });
723            };
724        "#;
725
726        let bundles = vec![js_with_handler.to_string()];
727        let endpoint = scan_js_for_drag_api(&bundles);
728        assert!(endpoint.is_some());
729
730        let ep = endpoint.unwrap();
731        assert_eq!(ep.url, "/api/reorder");
732        assert_eq!(ep.method, "PUT");
733        assert!(ep.body_template.is_some());
734    }
735
736    #[test]
737    fn test_empty_html() {
738        let actions = discover_drag_actions("", &[]);
739        assert!(actions.is_empty());
740    }
741
742    #[test]
743    fn test_detect_angular_cdk() {
744        let html = r#"
745        <html>
746        <body>
747            <div cdkDropList>
748                <div cdkDrag data-item-id="a1">Item A</div>
749                <div cdkDrag data-item-id="a2">Item B</div>
750            </div>
751        </body>
752        </html>
753        "#;
754
755        let library = detect_drag_library(html, &[]);
756        assert_eq!(library, DragLibrary::AngularCdk);
757    }
758
759    #[test]
760    fn test_detect_jquery_ui() {
761        let html = r#"
762        <html>
763        <body>
764            <ul class="ui-sortable">
765                <li class="ui-sortable-handle" data-task-id="t1">Task 1</li>
766                <li class="ui-sortable-handle" data-task-id="t2">Task 2</li>
767            </ul>
768        </body>
769        </html>
770        "#;
771
772        let library = detect_drag_library(html, &[]);
773        assert_eq!(library, DragLibrary::JQueryUI);
774    }
775
776    #[test]
777    fn test_detect_dnd_kit_from_js() {
778        let js = vec!["import { useDraggable, useDroppable } from '@dnd-kit/core';".to_string()];
779        let library = detect_drag_library("<html><body></body></html>", &js);
780        assert_eq!(library, DragLibrary::DndKit);
781    }
782
783    #[test]
784    fn test_detect_html5_native() {
785        let html = r#"
786        <html>
787        <body>
788            <div draggable="true" data-item-id="x1">Drag me</div>
789            <div draggable="true" data-item-id="x2">Drag me too</div>
790            <div data-drop-zone="zone-1" ondrop="handleDrop(event)">Drop here</div>
791        </body>
792        </html>
793        "#;
794
795        let library = detect_drag_library(html, &[]);
796        assert_eq!(library, DragLibrary::Html5Native);
797    }
798
799    #[test]
800    fn test_discover_drag_from_platform_unknown() {
801        let actions = discover_drag_from_platform("unknown-site.example.org");
802        assert!(actions.is_empty());
803    }
804
805    #[test]
806    fn test_discover_drag_actions_with_rbd_and_api() {
807        let html = r#"
808        <html>
809        <head><link rel="canonical" href="https://myapp.example.com/board" /></head>
810        <body>
811            <div data-rbd-droppable-id="col-1">
812                <div data-rbd-draggable-id="card-1">Card 1</div>
813                <div data-rbd-draggable-id="card-2">Card 2</div>
814            </div>
815        </body>
816        </html>
817        "#;
818
819        let js = vec![r#"
820            const onDragEnd = (result) => {
821                fetch('/api/cards/reorder', {
822                    method: 'POST',
823                    body: JSON.stringify({ cardId: result.draggableId, column: result.destination.droppableId })
824                });
825            };
826        "#.to_string()];
827
828        let actions = discover_drag_actions(html, &js);
829        assert!(!actions.is_empty());
830
831        let action = &actions[0];
832        assert_eq!(action.drag_library, DragLibrary::ReactBeautifulDnd);
833        assert_eq!(action.draggable_selector, "[data-rbd-draggable-id]");
834        assert_eq!(action.drop_zone_selector, "[data-rbd-droppable-id]");
835        assert!(action.api_endpoint.is_some());
836        assert!(action.confidence > 0.5);
837    }
838
839    #[test]
840    fn test_extract_domain_from_html() {
841        let html = r#"
842        <html>
843        <head>
844            <base href="https://trello.com/b/abc123" />
845        </head>
846        <body></body>
847        </html>
848        "#;
849
850        let domain = extract_domain_from_html(html);
851        assert_eq!(domain, Some("trello.com".to_string()));
852    }
853
854    #[test]
855    fn test_compute_confidence_ranges() {
856        // Known library + API endpoint should be high confidence.
857        let high = compute_confidence(
858            &DragLibrary::ReactBeautifulDnd,
859            &Some(ApiEndpoint {
860                url: "/api/reorder".to_string(),
861                method: "POST".to_string(),
862                body_template: None,
863            }),
864            "[data-rbd-draggable-id]",
865        );
866        assert!(high >= 0.90, "expected >= 0.90, got {high}");
867
868        // Unknown library with no API should be zero.
869        let low = compute_confidence(&DragLibrary::Unknown, &None, "");
870        assert!((low - 0.0).abs() < f32::EPSILON, "expected 0.0, got {low}");
871    }
872}