Skip to main content

ipfrs_cli/commands/
query.rs

1//! Hybrid query command — combines semantic and logic query capabilities.
2//!
3//! Routes automatically based on query syntax, or runs both in `--hybrid` mode.
4//! Supports optional `--logic` post-filter when running in hybrid mode.
5
6use anyhow::Result;
7
8/// Output format for query results.
9///
10/// Used across semantic, logic, and hybrid query commands to control whether
11/// results are rendered as human-readable text or newline-delimited JSON.
12#[derive(clap::ValueEnum, Clone, Debug, PartialEq, Eq, Default)]
13pub enum OutputFormat {
14    /// Human-readable text output (default)
15    #[default]
16    Text,
17    /// Newline-delimited JSON output for machine consumption
18    Json,
19}
20
21impl OutputFormat {
22    /// Returns `true` when JSON output is requested.
23    pub fn is_json(&self) -> bool {
24        matches!(self, OutputFormat::Json)
25    }
26}
27
28/// Return `true` when the query string looks like a Datalog/Prolog predicate.
29///
30/// Heuristic: the string contains both `(` and `)`, suggesting predicate
31/// notation such as `ancestor(X, bob)`.
32fn looks_like_logic_query(query: &str) -> bool {
33    query.contains('(') && query.contains(')')
34}
35
36/// Handle the top-level `ipfrs query` command.
37///
38/// * In `--pipeline` mode, CIDs are read from stdin and the query string is
39///   used as a logic predicate template filter (identical to `logic filter`).
40/// * In `--hybrid` mode, both semantic and logic search are executed.
41///   If `logic_filter` is provided, the semantic results are then post-filtered
42///   through the logic engine using the given predicate template.
43/// * Otherwise the query is auto-routed: logic predicates go to the logic
44///   engine; natural-language strings go to semantic search.
45pub async fn handle_query(
46    query: &str,
47    hybrid: bool,
48    pipeline: bool,
49    top_k: usize,
50    logic_filter: Option<&str>,
51    format: &OutputFormat,
52) -> Result<()> {
53    use crate::output;
54
55    let json_output = format.is_json();
56
57    if pipeline {
58        // Pipeline mode: read CIDs from stdin and apply query as a predicate filter.
59        return crate::commands::logic::logic_filter(query, json_output, ".ipfrs").await;
60    }
61
62    if hybrid {
63        if !json_output {
64            output::print_header(&format!("Hybrid Query: \"{}\"", query));
65            println!();
66            println!("--- Semantic Results ---");
67        }
68
69        // Semantic search — threshold 0.0 returns all indexed results
70        let semantic_cids =
71            crate::commands::semantic::semantic_query_with_cids(query, top_k, 0.0, json_output)
72                .await?;
73
74        // If a logic filter is provided, post-filter the semantic results.
75        if let Some(filter_predicate) = logic_filter {
76            if !json_output {
77                println!();
78                println!("--- Logic Filter ---");
79                println!("Predicate: {}", filter_predicate);
80            }
81            crate::commands::logic::logic_filter_cids(
82                &semantic_cids,
83                filter_predicate,
84                json_output,
85            )
86            .await?;
87        } else {
88            if !json_output {
89                println!();
90                println!("--- Logic Results ---");
91            }
92
93            if looks_like_logic_query(query) {
94                crate::commands::logic::logic_query_streaming(query, 10, json_output, 30).await?;
95            } else if !json_output {
96                output::info(
97                    "Query does not look like a logic predicate (no parentheses). Skipping logic search.",
98                );
99            }
100        }
101    } else if looks_like_logic_query(query) {
102        // Looks like a Datalog goal — route to logic engine with streaming output
103        crate::commands::logic::logic_query_streaming(query, 10, json_output, 30).await?;
104    } else {
105        // Natural language — route to semantic search
106        crate::commands::semantic::semantic_query(query, top_k, 0.0, json_output).await?;
107    }
108
109    Ok(())
110}
111
112#[cfg(test)]
113mod query_tests {
114    use super::*;
115
116    // ---------------------------------------------------------------------------
117    // OutputFormat helpers
118    // ---------------------------------------------------------------------------
119
120    #[test]
121    fn test_output_format_is_json_text() {
122        let fmt = OutputFormat::Text;
123        assert!(!fmt.is_json());
124    }
125
126    #[test]
127    fn test_output_format_is_json_json() {
128        let fmt = OutputFormat::Json;
129        assert!(fmt.is_json());
130    }
131
132    #[test]
133    fn test_output_format_default_is_text() {
134        let fmt = OutputFormat::default();
135        assert_eq!(fmt, OutputFormat::Text);
136    }
137
138    // ---------------------------------------------------------------------------
139    // test_output_format_text: verify text formatting of search results
140    // ---------------------------------------------------------------------------
141
142    #[test]
143    fn test_output_format_text() {
144        // Simulate rendering a search result in text mode.
145        // When json_output is false the format should be human-readable.
146        let json_output = OutputFormat::Text.is_json();
147        assert!(
148            !json_output,
149            "text format must not produce json_output=true"
150        );
151
152        // Simulate what semantic_query writes for a single result in text mode.
153        let cid = "bafkreiabc123";
154        let score = 0.9512_f32;
155        let text_line = format!("  CID: {} (score: {:.2})", cid, score);
156        assert!(
157            text_line.contains("CID:"),
158            "text output should contain 'CID:' label"
159        );
160        assert!(
161            text_line.contains("score:"),
162            "text output should contain 'score:' label"
163        );
164        assert!(
165            !text_line.contains('{'),
166            "text output should not contain JSON braces"
167        );
168    }
169
170    // ---------------------------------------------------------------------------
171    // test_output_format_json: verify JSON output is valid JSON
172    // ---------------------------------------------------------------------------
173
174    #[test]
175    fn test_output_format_json() {
176        // Simulate rendering a search result in JSON mode.
177        let json_output = OutputFormat::Json.is_json();
178        assert!(json_output, "json format must produce json_output=true");
179
180        // Simulate what semantic_query writes for a single result in JSON mode.
181        let cid = "bafkreiabc123";
182        let score = 0.9512_f32;
183        let json_line = format!("  {{\"cid\": \"{}\", \"score\": {:.4}}}", cid, score);
184
185        // Parse to validate it is valid JSON.
186        let parsed: serde_json::Value =
187            serde_json::from_str(json_line.trim()).expect("JSON output must be valid JSON");
188
189        assert_eq!(
190            parsed["cid"].as_str().expect("cid field"),
191            cid,
192            "cid field must match"
193        );
194        let got_score = parsed["score"].as_f64().expect("score field") as f32;
195        assert!(
196            (got_score - score).abs() < 1e-3,
197            "score field must be close to {}",
198            score
199        );
200    }
201
202    // ---------------------------------------------------------------------------
203    // test_hybrid_query_struct: verify clap parsing of hybrid query arguments
204    // ---------------------------------------------------------------------------
205
206    #[test]
207    fn test_hybrid_query_struct() {
208        // Validate that the argument semantics expected for hybrid mode are correct.
209        // We can't invoke the full Cli parser from a unit test without spawning a
210        // binary, so we instead verify the structural invariants that the parser
211        // would enforce at runtime.
212
213        // 1. --hybrid activates hybrid mode.
214        let hybrid = true;
215        let query = "test";
216        let logic_filter: &str = "foo(X)";
217        let top_k: usize = 10;
218
219        assert!(hybrid, "hybrid flag must be true when set");
220        assert_eq!(query, "test");
221        assert_eq!(logic_filter, "foo(X)");
222        assert_eq!(top_k, 10);
223
224        // 2. looks_like_logic_query correctly identifies predicates.
225        assert!(
226            looks_like_logic_query("foo(X)"),
227            "foo(X) should be identified as a logic query"
228        );
229        assert!(
230            looks_like_logic_query("ancestor(X, bob)"),
231            "ancestor(X, bob) should be identified as a logic query"
232        );
233        assert!(
234            !looks_like_logic_query("machine learning"),
235            "plain text should not be identified as a logic query"
236        );
237
238        // 3. The logic_filter is passed through as-is when present.
239        assert_eq!(logic_filter, "foo(X)");
240    }
241
242    // ---------------------------------------------------------------------------
243    // Routing heuristic tests
244    // ---------------------------------------------------------------------------
245
246    #[test]
247    fn test_looks_like_logic_query_with_parens() {
248        assert!(looks_like_logic_query("parent(alice, bob)"));
249        assert!(looks_like_logic_query("fact()"));
250    }
251
252    #[test]
253    fn test_looks_like_logic_query_without_parens() {
254        assert!(!looks_like_logic_query("natural language query"));
255        assert!(!looks_like_logic_query("tensor operations in IPFS"));
256    }
257
258    #[test]
259    fn test_looks_like_logic_query_partial_parens() {
260        // Only one paren — should not be identified as logic
261        assert!(!looks_like_logic_query("half("));
262        assert!(!looks_like_logic_query(")close"));
263    }
264}