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}