linear-cli 0.3.9

A powerful CLI for Linear.app - manage issues, projects, cycles, and more from your terminal
use anyhow::Result;
use clap::Subcommand;
use serde_json::{json, Map, Value};
use std::io::{self, BufRead};

use crate::api::LinearClient;
use crate::output::{print_json_owned, OutputOptions};
use crate::pagination::{paginate_nodes, PaginationOptions};

#[derive(Subcommand)]
pub enum ApiCommands {
    /// Execute a raw GraphQL query
    #[command(after_help = r#"EXAMPLES:
    linear api query '{ viewer { id name email } }'
    linear api query '{ teams { nodes { id key name } } }'
    linear api query -v teamId=abc123 'query($teamId: String!) { team(id: $teamId) { name } }'
    echo '{ viewer { id } }' | linear api query -"#)]
    Query {
        /// GraphQL query string. Use "-" to read from stdin.
        query: String,

        /// Variables as key=value pairs (e.g. -v id=abc123 -v name=test)
        #[arg(short = 'v', long = "variable", value_name = "KEY=VALUE")]
        variables: Vec<String>,

        /// Auto-paginate through all results (requires nodes + pageInfo in query)
        #[arg(long)]
        paginate: bool,

        /// JSON path to nodes array (dot-separated, e.g. data.issues.nodes)
        #[arg(long, default_value = "")]
        nodes_path: String,

        /// JSON path to pageInfo object (dot-separated, e.g. data.issues.pageInfo)
        #[arg(long, default_value = "")]
        page_info_path: String,
    },
    /// Execute a raw GraphQL mutation
    #[command(after_help = r#"EXAMPLES:
    linear api mutate -v title="New Issue" -v teamId=abc123 \
        'mutation($title: String!, $teamId: String!) { issueCreate(input: { title: $title, teamId: $teamId }) { issue { id identifier } } }'
    cat mutation.graphql | linear api mutate -v id=abc123 -"#)]
    Mutate {
        /// GraphQL mutation string. Use "-" to read from stdin.
        query: String,

        /// Variables as key=value pairs (e.g. -v id=abc123 -v name=test)
        #[arg(short = 'v', long = "variable", value_name = "KEY=VALUE")]
        variables: Vec<String>,
    },
}

pub async fn handle(cmd: ApiCommands, output: &OutputOptions) -> Result<()> {
    match cmd {
        ApiCommands::Query {
            query,
            variables,
            paginate,
            nodes_path,
            page_info_path,
        } => run_query(&query, &variables, paginate, &nodes_path, &page_info_path, output).await,
        ApiCommands::Mutate { query, variables } => run_mutate(&query, &variables, output).await,
    }
}

fn read_query(input: &str) -> Result<String> {
    if input == "-" {
        let stdin = io::stdin();
        let lines: Vec<String> = stdin.lock().lines().map_while(Result::ok).collect();
        Ok(lines.join("\n"))
    } else {
        Ok(input.to_string())
    }
}

fn parse_variables(vars: &[String]) -> Result<Option<Value>> {
    if vars.is_empty() {
        return Ok(None);
    }

    let mut map = Map::new();
    for var in vars {
        let (key, value) = var
            .split_once('=')
            .ok_or_else(|| anyhow::anyhow!("Invalid variable format '{}'. Use key=value.", var))?;

        // Try to parse as JSON value (number, bool, null, object, array)
        // Fall back to string if parsing fails
        let json_value = serde_json::from_str(value).unwrap_or_else(|_| json!(value));
        map.insert(key.to_string(), json_value);
    }

    Ok(Some(Value::Object(map)))
}

async fn run_query(
    query_str: &str,
    variables: &[String],
    paginate: bool,
    nodes_path: &str,
    page_info_path: &str,
    output: &OutputOptions,
) -> Result<()> {
    let query = read_query(query_str)?;
    let vars = parse_variables(variables)?;
    let client = LinearClient::new()?;

    if paginate {
        // Parse paths
        let nodes: Vec<&str> = if nodes_path.is_empty() {
            // Try to auto-detect from query
            anyhow::bail!(
                "--paginate requires --nodes-path and --page-info-path.\n\
                 Example: --nodes-path data.issues.nodes --page-info-path data.issues.pageInfo"
            );
        } else {
            nodes_path.split('.').collect()
        };

        let page_info: Vec<&str> = if page_info_path.is_empty() {
            anyhow::bail!(
                "--paginate requires --page-info-path.\n\
                 Example: --page-info-path data.issues.pageInfo"
            );
        } else {
            page_info_path.split('.').collect()
        };

        let base_vars = if let Some(Value::Object(m)) = vars {
            m
        } else {
            Map::new()
        };

        let pagination = PaginationOptions {
            all: true,
            page_size: Some(50),
            ..Default::default()
        };

        let nodes_refs: Vec<&str> = nodes.to_vec();
        let page_info_refs: Vec<&str> = page_info.to_vec();

        let results = paginate_nodes(
            &client,
            &query,
            base_vars,
            &nodes_refs,
            &page_info_refs,
            &pagination,
            50,
        )
        .await?;

        print_json_owned(json!(results), output)?;
    } else {
        let result = client.query(&query, vars).await?;
        print_json_owned(result, output)?;
    }

    Ok(())
}

async fn run_mutate(
    query_str: &str,
    variables: &[String],
    output: &OutputOptions,
) -> Result<()> {
    let query = read_query(query_str)?;
    let vars = parse_variables(variables)?;
    let client = LinearClient::new()?;

    let result = client.mutate(&query, vars).await?;
    print_json_owned(result, output)?;

    Ok(())
}

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

    #[test]
    fn test_parse_variables_empty() {
        let result = parse_variables(&[]).unwrap();
        assert!(result.is_none());
    }

    #[test]
    fn test_parse_variables_string() {
        let vars = vec!["name=hello".to_string()];
        let result = parse_variables(&vars).unwrap().unwrap();
        assert_eq!(result["name"], json!("hello"));
    }

    #[test]
    fn test_parse_variables_number() {
        let vars = vec!["count=42".to_string()];
        let result = parse_variables(&vars).unwrap().unwrap();
        assert_eq!(result["count"], json!(42));
    }

    #[test]
    fn test_parse_variables_bool() {
        let vars = vec!["active=true".to_string()];
        let result = parse_variables(&vars).unwrap().unwrap();
        assert_eq!(result["active"], json!(true));
    }

    #[test]
    fn test_parse_variables_multiple() {
        let vars = vec![
            "name=test".to_string(),
            "count=5".to_string(),
            "active=false".to_string(),
        ];
        let result = parse_variables(&vars).unwrap().unwrap();
        assert_eq!(result["name"], json!("test"));
        assert_eq!(result["count"], json!(5));
        assert_eq!(result["active"], json!(false));
    }

    #[test]
    fn test_parse_variables_invalid() {
        let vars = vec!["invalid".to_string()];
        assert!(parse_variables(&vars).is_err());
    }

    #[test]
    fn test_parse_variables_json_object() {
        let vars = vec![r#"filter={"name":{"eq":"test"}}"#.to_string()];
        let result = parse_variables(&vars).unwrap().unwrap();
        assert_eq!(result["filter"]["name"]["eq"], json!("test"));
    }

    #[test]
    fn test_read_query_direct() {
        let q = read_query("{ viewer { id } }").unwrap();
        assert_eq!(q, "{ viewer { id } }");
    }
}