Skip to main content

cortex_runtime/cli/
query_cmd.rs

1//! `cortex query <domain>` — query a mapped site for matching pages.
2
3use crate::cli::output;
4use crate::intelligence::cache::MapCache;
5use crate::map::types::{FeatureRange, NodeQuery, PageType, FEAT_PRICE, FEAT_RATING};
6use anyhow::{bail, Result};
7
8/// Run the query command.
9pub async fn run(
10    domain: &str,
11    page_type: Option<&str>,
12    price_lt: Option<f32>,
13    rating_gt: Option<f32>,
14    limit: u32,
15    feature_filters: &[String],
16) -> Result<()> {
17    // Load cached map
18    let mut cache = MapCache::default_cache()?;
19    let map = match cache.load_map(domain)? {
20        Some(m) => m,
21        None => {
22            if output::is_json() {
23                output::print_json(&serde_json::json!({
24                    "error": "no_map",
25                    "message": format!("No cached map for '{domain}'"),
26                    "hint": format!("Run: cortex map {domain}")
27                }));
28                return Ok(());
29            }
30            bail!("No map found for '{domain}'. Run 'cortex map {domain}' first.");
31        }
32    };
33
34    // Build query
35    let mut feature_ranges = Vec::new();
36
37    let page_types = page_type.map(|t| {
38        let pt = parse_page_type(t);
39        if matches!(pt, PageType::Unknown) && !output::is_quiet() {
40            eprintln!(
41                "  Warning: unknown page type '{t}'. Did you mean one of: home, product_detail, article, search_results, login?"
42            );
43        }
44        vec![pt]
45    });
46
47    if let Some(price) = price_lt {
48        feature_ranges.push(FeatureRange {
49            dimension: FEAT_PRICE,
50            min: None,
51            max: Some(price),
52        });
53    }
54
55    if let Some(rating) = rating_gt {
56        feature_ranges.push(FeatureRange {
57            dimension: FEAT_RATING,
58            min: Some(rating),
59            max: None,
60        });
61    }
62
63    // Parse --feature "48<300" style filters
64    for f in feature_filters {
65        if let Some(range) = parse_feature_filter(f) {
66            feature_ranges.push(range);
67        } else if !output::is_quiet() {
68            eprintln!("  Warning: could not parse feature filter '{f}'. Use format: \"48<300\" or \"52>0.8\"");
69        }
70    }
71
72    let query = NodeQuery {
73        page_types,
74        feature_ranges,
75        limit: limit as usize,
76        ..Default::default()
77    };
78
79    let results = map.filter(&query);
80
81    if output::is_json() {
82        let items: Vec<serde_json::Value> = results
83            .iter()
84            .map(|m| {
85                serde_json::json!({
86                    "index": m.index,
87                    "url": m.url,
88                    "page_type": format!("{:?}", m.page_type),
89                    "confidence": m.confidence,
90                })
91            })
92            .collect();
93        output::print_json(&serde_json::json!({
94            "domain": domain,
95            "total": results.len(),
96            "results": items,
97        }));
98        return Ok(());
99    }
100
101    if results.is_empty() {
102        if !output::is_quiet() {
103            eprintln!("  No matching pages found. Try broader filters.");
104        }
105        return Ok(());
106    }
107
108    if !output::is_quiet() {
109        let total = results.len();
110        if total > limit as usize {
111            eprintln!(
112                "  Found {} matching pages. Showing first {} (use --limit to change).",
113                total, limit
114            );
115        } else {
116            eprintln!("  Found {} matching page(s):", total);
117        }
118        eprintln!();
119
120        for m in &results {
121            let truncated_url = if m.url.len() > 50 {
122                format!("{}...", &m.url[..47])
123            } else {
124                m.url.clone()
125            };
126            eprintln!(
127                "    [{:>5}] {:<20} {:<50} conf: {:.2}",
128                m.index,
129                format!("{:?}", m.page_type),
130                truncated_url,
131                m.confidence,
132            );
133        }
134    }
135
136    Ok(())
137}
138
139/// Parse a page type string to the enum, supporting both human and hex formats.
140fn parse_page_type(s: &str) -> PageType {
141    // Try hex format first (e.g., "0x04")
142    if let Some(hex_str) = s.strip_prefix("0x") {
143        if let Ok(n) = u8::from_str_radix(hex_str, 16) {
144            return PageType::from_u8(n);
145        }
146    }
147
148    // Try decimal
149    if let Ok(n) = s.parse::<u8>() {
150        return PageType::from_u8(n);
151    }
152
153    // Named types
154    match s.to_lowercase().as_str() {
155        "home" => PageType::Home,
156        "product" | "product_detail" => PageType::ProductDetail,
157        "product_listing" | "listing" | "category" => PageType::ProductListing,
158        "article" | "blog" | "post" => PageType::Article,
159        "search" | "search_results" => PageType::SearchResults,
160        "login" | "signin" => PageType::Login,
161        "cart" | "basket" => PageType::Cart,
162        "checkout" => PageType::Checkout,
163        "account" | "profile" => PageType::Account,
164        "docs" | "documentation" => PageType::Documentation,
165        "form" | "form_page" => PageType::FormPage,
166        "about" | "about_page" => PageType::AboutPage,
167        "contact" | "contact_page" => PageType::ContactPage,
168        "faq" => PageType::Faq,
169        "pricing" | "pricing_page" => PageType::PricingPage,
170        _ => PageType::Unknown,
171    }
172}
173
174/// Parse a feature filter like "48<300" or "52>0.8".
175fn parse_feature_filter(s: &str) -> Option<FeatureRange> {
176    // Try "<" separator
177    if let Some(pos) = s.find('<') {
178        let dim: usize = s[..pos].trim().parse().ok()?;
179        let val: f32 = s[pos + 1..].trim().parse().ok()?;
180        if dim > 127 {
181            return None;
182        }
183        return Some(FeatureRange {
184            dimension: dim,
185            min: None,
186            max: Some(val),
187        });
188    }
189
190    // Try ">" separator
191    if let Some(pos) = s.find('>') {
192        let dim: usize = s[..pos].trim().parse().ok()?;
193        let val: f32 = s[pos + 1..].trim().parse().ok()?;
194        if dim > 127 {
195            return None;
196        }
197        return Some(FeatureRange {
198            dimension: dim,
199            min: Some(val),
200            max: None,
201        });
202    }
203
204    None
205}