halley-cli 0.1.0

Command-line interface for interacting with and controlling the Halley Wayland compositor.
use halley_ipc::{
    CompositorRequest, DpmsCommand, MonitorFocusDirection, MonitorFocusTarget, NodeMoveDirection,
    NodeSelector, Request, TrailTarget,
};

use crate::cmd::{
    bearings::parse_bearings_request, capture::parse_capture_request,
    cluster::parse_cluster_request, monitor::parse_monitor_request, node::parse_node_request,
    stack::parse_stack_request, tile::parse_tile_request, trail::parse_trail_request,
};
use crate::help::HelpTopic;

pub(crate) enum ParseOutcome {
    Request(Request),
    Help(HelpTopic),
}

pub(crate) struct UsageError {
    pub(crate) message: String,
    pub(crate) help: HelpTopic,
}

impl UsageError {
    pub(crate) fn new(message: impl Into<String>, help: HelpTopic) -> Self {
        Self {
            message: message.into(),
            help,
        }
    }
}

pub(crate) fn parse_request(args: &[String]) -> Result<ParseOutcome, UsageError> {
    if args.is_empty() {
        return Ok(ParseOutcome::Help(HelpTopic::Top));
    }

    match args[0].as_str() {
        "help" | "--help" | "-h" => Ok(ParseOutcome::Help(HelpTopic::Top)),
        "quit" => parse_leaf_command(
            &args[1..],
            HelpTopic::Quit,
            Request::Compositor(CompositorRequest::Quit),
        ),
        "reload" => parse_leaf_command(
            &args[1..],
            HelpTopic::Reload,
            Request::Compositor(CompositorRequest::Reload),
        ),
        "outputs" => parse_leaf_command(
            &args[1..],
            HelpTopic::Outputs,
            Request::Compositor(CompositorRequest::Outputs),
        ),
        "capture" => parse_capture_request(&args[1..]),
        "dpms" => parse_dpms(&args[1..]),
        "node" => parse_node_request(&args[1..]),
        "trail" => parse_trail_request(&args[1..]),
        "monitor" => parse_monitor_request(&args[1..]),
        "bearings" => parse_bearings_request(&args[1..]),
        "cluster" => parse_cluster_request(&args[1..]),
        "stack" => parse_stack_request(&args[1..]),
        "tile" => parse_tile_request(&args[1..]),
        other => Err(UsageError::new(
            format!("unknown command: {other}"),
            HelpTopic::Top,
        )),
    }
}

pub(crate) fn parse_leaf_command(
    args: &[String],
    help: HelpTopic,
    request: Request,
) -> Result<ParseOutcome, UsageError> {
    if args.is_empty() {
        return Ok(ParseOutcome::Request(request));
    }
    if contains_help_flag(args) {
        return Ok(ParseOutcome::Help(help));
    }
    Err(UsageError::new(
        format!("unexpected argument: {}", args[0]),
        help,
    ))
}

fn parse_dpms(args: &[String]) -> Result<ParseOutcome, UsageError> {
    if args.is_empty() || contains_help_flag(args) {
        return Ok(ParseOutcome::Help(HelpTopic::Dpms));
    }

    let mut positionals = Vec::new();
    let mut output = None;
    let mut index = 0usize;
    while index < args.len() {
        match args[index].as_str() {
            "-o" | "--output" => {
                index += 1;
                let Some(value) = args.get(index) else {
                    return Err(UsageError::new(
                        "missing value for -o/--output",
                        HelpTopic::Dpms,
                    ));
                };
                output = Some(value.clone());
            }
            other if other.starts_with('-') => {
                return Err(UsageError::new(
                    format!("unknown option for dpms: {other}"),
                    HelpTopic::Dpms,
                ));
            }
            _ => positionals.push(args[index].clone()),
        }
        index += 1;
    }

    let Some(command) = positionals.first() else {
        return Ok(ParseOutcome::Help(HelpTopic::Dpms));
    };
    if positionals.len() > 1 {
        return Err(UsageError::new(
            format!("unexpected argument: {}", positionals[1]),
            HelpTopic::Dpms,
        ));
    }
    let command = match command.as_str() {
        "off" => DpmsCommand::Off,
        "on" => DpmsCommand::On,
        "toggle" => DpmsCommand::Toggle,
        other => {
            return Err(UsageError::new(
                format!("unknown dpms command: {other}"),
                HelpTopic::Dpms,
            ));
        }
    };

    Ok(ParseOutcome::Request(Request::Compositor(
        CompositorRequest::Dpms { command, output },
    )))
}

pub(crate) fn parse_output_option(
    args: &[String],
    help: HelpTopic,
) -> Result<Option<String>, UsageError> {
    let mut output = None;
    let mut index = 0usize;
    while index < args.len() {
        match args[index].as_str() {
            "-o" | "--output" => {
                index += 1;
                let Some(value) = args.get(index) else {
                    return Err(UsageError::new("missing value for -o/--output", help));
                };
                output = Some(value.clone());
            }
            "--json" => {}
            other => {
                return Err(UsageError::new(
                    format!("unexpected argument: {other}"),
                    help,
                ));
            }
        }
        index += 1;
    }
    Ok(output)
}

pub(crate) fn parse_selector_flags(
    args: &[String],
    help: HelpTopic,
) -> Result<(Option<NodeSelector>, Option<String>, bool), UsageError> {
    let mut selector = None;
    let mut output = None;
    let mut json = false;
    let mut index = 0usize;
    while index < args.len() {
        match args[index].as_str() {
            "-o" | "--output" => {
                index += 1;
                let Some(value) = args.get(index) else {
                    return Err(UsageError::new("missing value for -o/--output", help));
                };
                output = Some(value.clone());
            }
            "--json" => json = true,
            other if other.starts_with('-') => {
                return Err(UsageError::new(format!("unknown option: {other}"), help));
            }
            other => {
                if selector.is_some() {
                    return Err(UsageError::new(
                        format!("unexpected extra selector argument: {other}"),
                        help,
                    ));
                }
                selector = Some(parse_node_selector(other)?);
            }
        }
        index += 1;
    }
    Ok((selector, output, json))
}

#[cfg(test)]
mod tests {
    use super::{ParseOutcome, parse_request};

    #[test]
    fn stack_cycle_request_parses() {
        let args = vec![
            "stack".to_string(),
            "cycle".to_string(),
            "forward".to_string(),
        ];
        let outcome = match parse_request(&args) {
            Ok(outcome) => outcome,
            Err(err) => panic!("stack request should parse: {}", err.message),
        };

        match outcome {
            ParseOutcome::Request(halley_ipc::Request::Stack(
                halley_ipc::StackRequest::Cycle { direction, output },
            )) => {
                assert_eq!(direction, halley_ipc::StackCycleDirection::Forward);
                assert_eq!(output, None);
            }
            _ => panic!("unexpected parse outcome"),
        }
    }

    #[test]
    fn tile_focus_request_parses() {
        let args = vec!["tile".to_string(), "focus".to_string(), "left".to_string()];
        let outcome = match parse_request(&args) {
            Ok(outcome) => outcome,
            Err(err) => panic!("tile request should parse: {}", err.message),
        };

        match outcome {
            ParseOutcome::Request(halley_ipc::Request::Tile(halley_ipc::TileRequest::Focus {
                direction,
                output,
            })) => {
                assert!(matches!(direction, halley_ipc::NodeMoveDirection::Left));
                assert_eq!(output, None);
            }
            _ => panic!("unexpected parse outcome"),
        }
    }

    #[test]
    fn cluster_layout_cycle_request_parses() {
        let args = vec![
            "cluster".to_string(),
            "layout".to_string(),
            "cycle".to_string(),
        ];
        let outcome = match parse_request(&args) {
            Ok(outcome) => outcome,
            Err(err) => panic!("cluster request should parse: {}", err.message),
        };

        match outcome {
            ParseOutcome::Request(halley_ipc::Request::Cluster(
                halley_ipc::ClusterRequest::LayoutCycle { output },
            )) => {
                assert_eq!(output, None);
            }
            _ => panic!("unexpected parse outcome"),
        }
    }

    #[test]
    fn cluster_list_request_parses() {
        let args = vec!["cluster".to_string(), "list".to_string()];
        let outcome = match parse_request(&args) {
            Ok(outcome) => outcome,
            Err(err) => panic!("cluster list request should parse: {}", err.message),
        };

        match outcome {
            ParseOutcome::Request(halley_ipc::Request::Cluster(
                halley_ipc::ClusterRequest::List { output },
            )) => {
                assert_eq!(output, None);
            }
            _ => panic!("unexpected parse outcome"),
        }
    }

    #[test]
    fn cluster_inspect_request_parses_default_current() {
        let args = vec!["cluster".to_string(), "inspect".to_string()];
        let outcome = match parse_request(&args) {
            Ok(outcome) => outcome,
            Err(err) => panic!("cluster inspect request should parse: {}", err.message),
        };

        match outcome {
            ParseOutcome::Request(halley_ipc::Request::Cluster(
                halley_ipc::ClusterRequest::Inspect { target, output },
            )) => {
                assert!(target.is_none());
                assert_eq!(output, None);
            }
            _ => panic!("unexpected parse outcome"),
        }
    }

    #[test]
    fn cluster_inspect_request_parses_id_target() {
        let args = vec![
            "cluster".to_string(),
            "inspect".to_string(),
            "2".to_string(),
        ];
        let outcome = match parse_request(&args) {
            Ok(outcome) => outcome,
            Err(err) => panic!("cluster inspect id request should parse: {}", err.message),
        };

        match outcome {
            ParseOutcome::Request(halley_ipc::Request::Cluster(
                halley_ipc::ClusterRequest::Inspect { target, output },
            )) => {
                assert!(matches!(target, Some(halley_ipc::ClusterTarget::Id(2))));
                assert_eq!(output, None);
            }
            _ => panic!("unexpected parse outcome"),
        }
    }
}

pub(crate) fn parse_node_selector(text: &str) -> Result<NodeSelector, UsageError> {
    if text.eq_ignore_ascii_case("focused") {
        return Ok(NodeSelector::Focused);
    }
    if text.eq_ignore_ascii_case("latest") {
        return Ok(NodeSelector::Latest);
    }
    if let Ok(id) = text.parse::<u64>() {
        return Ok(NodeSelector::Id(id));
    }
    if let Some(value) = text.strip_prefix("id:") {
        return value.parse::<u64>().map(NodeSelector::Id).map_err(|_| {
            UsageError::new(format!("invalid node id selector: {text}"), HelpTopic::Node)
        });
    }
    if let Some(value) = text.strip_prefix("title:") {
        return Ok(NodeSelector::Title(value.to_string()));
    }
    if let Some(value) = text.strip_prefix("app:") {
        return Ok(NodeSelector::App(value.to_string()));
    }
    Err(UsageError::new(
        format!("unknown node selector: {text}"),
        HelpTopic::Node,
    ))
}

pub(crate) fn parse_trail_target(text: &str) -> Result<TrailTarget, UsageError> {
    if let Ok(index) = text.parse::<usize>() {
        return Ok(TrailTarget::Index(index));
    }
    Ok(TrailTarget::Selector(parse_node_selector(text)?))
}

pub(crate) fn parse_move_direction(text: &str) -> Result<NodeMoveDirection, UsageError> {
    match text {
        "left" => Ok(NodeMoveDirection::Left),
        "right" => Ok(NodeMoveDirection::Right),
        "up" => Ok(NodeMoveDirection::Up),
        "down" => Ok(NodeMoveDirection::Down),
        other => Err(UsageError::new(
            format!("unknown move direction: {other}"),
            HelpTopic::NodeMove,
        )),
    }
}

pub(crate) fn parse_monitor_focus_target(text: &str) -> MonitorFocusTarget {
    match text {
        "left" => MonitorFocusTarget::Direction(MonitorFocusDirection::Left),
        "right" => MonitorFocusTarget::Direction(MonitorFocusDirection::Right),
        "up" => MonitorFocusTarget::Direction(MonitorFocusDirection::Up),
        "down" => MonitorFocusTarget::Direction(MonitorFocusDirection::Down),
        other => MonitorFocusTarget::Output(other.to_string()),
    }
}

pub(crate) fn contains_help_flag(args: &[String]) -> bool {
    args.iter().any(|arg| arg == "-h" || arg == "--help")
}