Skip to main content

fraiseql_cli/commands/
cost.rs

1//! Cost command - lightweight complexity scoring for queries
2//!
3//! Usage: fraiseql cost `<query>` `[--json]`
4
5use anyhow::Result;
6use fraiseql_core::graphql::{complexity::RequestValidator, parse_query};
7use serde::Serialize;
8
9use crate::output::CommandResult;
10
11/// Response with cost estimation
12#[derive(Debug, Serialize)]
13pub struct CostResponse {
14    /// The GraphQL query being analyzed
15    pub query:            String,
16    /// Complexity score based on query depth and breadth (pagination-aware)
17    pub complexity_score: usize,
18    /// Estimated execution cost
19    pub estimated_cost:   usize,
20    /// Maximum query depth
21    pub depth:            usize,
22    /// Number of aliased fields
23    pub alias_count:      usize,
24}
25
26/// Run cost command (minimal complexity analysis)
27///
28/// # Errors
29///
30/// Returns an error if the query cannot be parsed or if complexity analysis
31/// fails. Also propagates errors from JSON serialization of the response.
32pub fn run(query: &str) -> Result<CommandResult> {
33    // Validate query syntax
34    let _parsed = parse_query(query)?;
35
36    // AST-based complexity analysis
37    let validator = RequestValidator::default();
38    let metrics = validator.analyze(query)?;
39
40    let response = CostResponse {
41        query:            query.to_string(),
42        complexity_score: metrics.complexity,
43        estimated_cost:   metrics.depth * 25, // Rough cost estimation
44        depth:            metrics.depth,
45        alias_count:      metrics.alias_count,
46    };
47
48    Ok(CommandResult::success("cost", serde_json::to_value(&response)?))
49}
50
51#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn test_cost_simple_query() {
58        let query = "query { users { id } }";
59        let result = run(query);
60
61        let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok for simple query: {e}"));
62        assert_eq!(cmd_result.status, "success");
63    }
64
65    #[test]
66    fn test_cost_invalid_query_fails() {
67        let query = "query { invalid {";
68        let result = run(query);
69
70        assert!(result.is_err(), "expected Err for invalid query, got: {result:?}");
71    }
72
73    #[test]
74    fn test_cost_provides_score() {
75        let query = "query { users { id name } }";
76        let result = run(query);
77
78        let cmd_result = result.unwrap_or_else(|e| panic!("expected Ok for score query: {e}"));
79        if let Some(data) = cmd_result.data {
80            assert!(data["complexity_score"].is_number());
81        }
82    }
83
84    #[test]
85    fn test_cost_more_fields_higher_score() {
86        let few_fields = run("query { users { id } }").unwrap();
87        let many_fields = run("query { users { id name email phone address } }").unwrap();
88
89        let few_score = few_fields
90            .data
91            .as_ref()
92            .and_then(|d| d["complexity_score"].as_u64())
93            .unwrap_or(0);
94        let many_score = many_fields
95            .data
96            .as_ref()
97            .and_then(|d| d["complexity_score"].as_u64())
98            .unwrap_or(0);
99
100        assert!(many_score >= few_score);
101    }
102
103    #[test]
104    fn test_cost_nested_has_higher_score() {
105        let shallow = run("query { users { id } }").unwrap();
106        let deep = run("query { users { posts { comments { author } } } }").unwrap();
107
108        let shallow_score =
109            shallow.data.as_ref().and_then(|d| d["complexity_score"].as_u64()).unwrap_or(0);
110        let deep_score =
111            deep.data.as_ref().and_then(|d| d["complexity_score"].as_u64()).unwrap_or(0);
112
113        assert!(deep_score > shallow_score);
114    }
115}