forgewright 0.2.0

Standalone UI automation — CDP for browsers, UIA for Windows desktop apps
Documentation
// selector.rs — UIA selector parser
//
// Syntax:
//   name:Business Number          → match by Name property
//   aid:txtBN                     → match by AutomationId
//   type:Edit                     → match by ControlType
//   class:TextBox                 → match by ClassName
//   name:Save&type:Button         → AND (both must match)
//   name:MainWindow > type:Edit   → tree descent (parent > child)
//   type:Edit[2]                  → index (0-based) among matches
//   name:*Business*               → wildcard (contains)
//
// Examples:
//   forgewright drive --target uia://MyApp click "name:Save"
//   forgewright drive --target uia://MyApp type "aid:txtBN" "749963633"
//   forgewright drive --target uia://MyApp click "name:File > name:Save As"

/// One atomic condition: property = value
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Condition {
    Name(String),
    AutomationId(String),
    ControlType(String),
    ClassName(String),
}

/// A step in the selector tree: one or more AND'd conditions + optional index
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Step {
    pub conditions: Vec<Condition>,
    pub index: Option<usize>,
}

/// Full parsed selector: a chain of steps separated by >
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Selector {
    pub steps: Vec<Step>,
}

impl Selector {
    pub fn is_single_step(&self) -> bool {
        self.steps.len() == 1
    }
}

/// Parse a selector string into structured form
#[allow(dead_code)]
pub fn parse(input: &str) -> Result<Selector, String> {
    let input = input.trim();
    if input.is_empty() {
        return Err("empty selector".into());
    }

    let raw_steps: Vec<&str> = input.split('>').collect();
    let mut steps = Vec::with_capacity(raw_steps.len());

    for raw in raw_steps {
        let raw = raw.trim();
        if raw.is_empty() {
            return Err("empty step in selector (double > ?)".into());
        }
        steps.push(parse_step(raw)?);
    }

    Ok(Selector { steps })
}

fn parse_step(input: &str) -> Result<Step, String> {
    // Check for trailing index: type:Edit[2]
    let (body, index) = if let Some(bracket) = input.rfind('[') {
        if input.ends_with(']') {
            let idx_str = &input[bracket + 1..input.len() - 1];
            let idx = idx_str
                .parse::<usize>()
                .map_err(|_| format!("invalid index: [{}]", idx_str))?;
            (&input[..bracket], Some(idx))
        } else {
            (input, None)
        }
    } else {
        (input, None)
    };

    // Split on & for AND conditions
    let parts: Vec<&str> = body.split('&').collect();
    let mut conditions = Vec::with_capacity(parts.len());

    for part in parts {
        let part = part.trim();
        if let Some(val) = part.strip_prefix("name:") {
            conditions.push(Condition::Name(val.to_string()));
        } else if let Some(val) = part.strip_prefix("aid:") {
            conditions.push(Condition::AutomationId(val.to_string()));
        } else if let Some(val) = part.strip_prefix("type:") {
            conditions.push(Condition::ControlType(val.to_string()));
        } else if let Some(val) = part.strip_prefix("class:") {
            conditions.push(Condition::ClassName(val.to_string()));
        } else {
            // Default: treat bare string as name search
            conditions.push(Condition::Name(part.to_string()));
        }
    }

    if conditions.is_empty() {
        return Err("step has no conditions".into());
    }

    Ok(Step { conditions, index })
}

/// Map a control type name to its UIA constant ID
#[allow(dead_code)]
pub fn control_type_id(name: &str) -> Option<i32> {
    match name.to_lowercase().as_str() {
        "button" => Some(50000),
        "calendar" => Some(50001),
        "checkbox" => Some(50002),
        "combobox" => Some(50003),
        "edit" => Some(50004),
        "hyperlink" => Some(50005),
        "image" => Some(50006),
        "listitem" => Some(50007),
        "list" => Some(50008),
        "menu" => Some(50009),
        "menubar" => Some(50010),
        "menuitem" => Some(50011),
        "progressbar" => Some(50012),
        "radiobutton" => Some(50013),
        "scrollbar" => Some(50014),
        "slider" => Some(50015),
        "spinner" => Some(50016),
        "statusbar" => Some(50017),
        "tab" => Some(50018),
        "tabitem" => Some(50019),
        "text" => Some(50020),
        "toolbar" => Some(50021),
        "tooltip" => Some(50022),
        "tree" => Some(50023),
        "treeitem" => Some(50024),
        "custom" => Some(50025),
        "group" => Some(50026),
        "thumb" => Some(50027),
        "datagrid" => Some(50028),
        "dataitem" => Some(50029),
        "document" => Some(50030),
        "splitbutton" => Some(50031),
        "window" => Some(50032),
        "pane" => Some(50033),
        "header" => Some(50034),
        "headeritem" => Some(50035),
        "table" => Some(50036),
        "titlebar" => Some(50037),
        "separator" => Some(50038),
        _ => None,
    }
}

/// Reverse: control type ID to human name
#[allow(dead_code)]
pub fn control_type_name(id: i32) -> &'static str {
    match id {
        50000 => "Button",
        50001 => "Calendar",
        50002 => "CheckBox",
        50003 => "ComboBox",
        50004 => "Edit",
        50005 => "Hyperlink",
        50006 => "Image",
        50007 => "ListItem",
        50008 => "List",
        50009 => "Menu",
        50010 => "MenuBar",
        50011 => "MenuItem",
        50012 => "ProgressBar",
        50013 => "RadioButton",
        50014 => "ScrollBar",
        50015 => "Slider",
        50016 => "Spinner",
        50017 => "StatusBar",
        50018 => "Tab",
        50019 => "TabItem",
        50020 => "Text",
        50021 => "ToolBar",
        50022 => "ToolTip",
        50023 => "Tree",
        50024 => "TreeItem",
        50025 => "Custom",
        50026 => "Group",
        50027 => "Thumb",
        50028 => "DataGrid",
        50029 => "DataItem",
        50030 => "Document",
        50031 => "SplitButton",
        50032 => "Window",
        50033 => "Pane",
        50034 => "Header",
        50035 => "HeaderItem",
        50036 => "Table",
        50037 => "TitleBar",
        50038 => "Separator",
        _ => "Unknown",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_simple_name() {
        let sel = parse("name:Business Number").unwrap();
        assert_eq!(sel.steps.len(), 1);
        assert!(matches!(&sel.steps[0].conditions[0], Condition::Name(n) if n == "Business Number"));
    }

    #[test]
    fn parse_and_condition() {
        let sel = parse("name:Save&type:Button").unwrap();
        assert_eq!(sel.steps[0].conditions.len(), 2);
    }

    #[test]
    fn parse_tree_descent() {
        let sel = parse("name:Main > type:Tab > aid:txtField").unwrap();
        assert_eq!(sel.steps.len(), 3);
    }

    #[test]
    fn parse_with_index() {
        let sel = parse("type:Edit[2]").unwrap();
        assert_eq!(sel.steps[0].index, Some(2));
    }

    #[test]
    fn parse_bare_string_is_name() {
        let sel = parse("Save").unwrap();
        assert!(matches!(&sel.steps[0].conditions[0], Condition::Name(n) if n == "Save"));
    }

    #[test]
    fn control_type_lookup() {
        assert_eq!(control_type_id("Edit"), Some(50004));
        assert_eq!(control_type_id("button"), Some(50000));
        assert_eq!(control_type_id("nonsense"), None);
    }
}