Skip to main content

codingest_cli/
query.rs

1//! `codingest query` — one-shot Cypher read over a saved `.kgl` artifact.
2//!
3//! Deliberately no implicit build: `codingest build <dir> && codingest query
4//! '<cypher>'` is shell composition, and loading a saved artifact is far
5//! cheaper than rebuilding it per query. Read-only callers need no writer
6//! lease and writers replace the `.kgl` atomically, so this composes with a
7//! concurrent rebuild or a `codingest-mcp --graph` serving the same file.
8//!
9//! Output is never truncated — row budgeting belongs to Cypher `LIMIT`. The
10//! MCP server's 15-row inline preview is a host-context budget; a pipe has no
11//! such constraint.
12
13use std::collections::HashMap;
14use std::io::Read;
15use std::path::{Path, PathBuf};
16use std::time::{Duration, Instant};
17
18use anyhow::{Context, Result};
19use clap::{Args, ValueEnum};
20use kglite::api::cypher::OutputFormat;
21use kglite::api::io::load_file;
22use kglite::api::param::kglite_value_to_json;
23use kglite::api::session::{execute_read, CsvImportPolicy, ExecuteOptions};
24use kglite::api::Value;
25
26use crate::code_tree_cli::DEFAULT_GRAPH;
27
28#[derive(Args, Debug)]
29pub struct QueryArgs {
30    /// Cypher query to run. `-` reads the query text from stdin.
31    pub query: String,
32    /// Graph artifact to query. The artifact is an input here, hence
33    /// `--graph` rather than `build`/`status`'s output-shaped `--output`.
34    #[arg(short, long, default_value = DEFAULT_GRAPH)]
35    pub graph: PathBuf,
36    /// Abort the query after this many seconds. Must be positive and finite;
37    /// omit the flag for no timeout.
38    #[arg(long, value_parser = parse_timeout)]
39    pub timeout: Option<f64>,
40    /// Result rendering. An in-query `FORMAT CSV` overrides this.
41    #[arg(long, value_enum, default_value_t = QueryFormat::Human)]
42    pub format: QueryFormat,
43    /// Refuse to query a graph that is not provably fresh (exit code 3)
44    /// instead of warning on stderr and running anyway.
45    #[arg(long)]
46    pub require_fresh: bool,
47    /// Permit the engine's parallel runtime for this query. Off by default:
48    /// one heavy analytical scan may use the whole machine, but nothing should
49    /// claim every core by omission. The flag is a *permission*, not an
50    /// instruction — only operators that can partition deterministically
51    /// honour it, and each still applies its own row-count gate, so a small
52    /// graph runs single-threaded either way and the rows are identical.
53    #[arg(long)]
54    pub parallel: bool,
55}
56
57/// Upper bound for `--timeout`, in seconds (~31.7 years). Past it a value is a
58/// typo or a unit mix-up, not a request — and the bound is what keeps the value
59/// inside `Duration`'s range: `Duration::from_secs_f64` *panics* on overflow
60/// exactly as it does on a negative or NaN input.
61const MAX_TIMEOUT_SECS: f64 = 1e9;
62
63/// The `--timeout` domain: strictly positive, finite, and representable.
64///
65/// Zero is rejected rather than given a meaning. "No timeout" is already spelled
66/// by omitting the flag, and "expire immediately" is not something a caller asks
67/// for on purpose — while `--timeout=$SECS` with an unset or zeroed variable is
68/// a routine shell accident. Failing it as a usage error beats failing every
69/// such run with a plausible-looking `Query timed out`.
70///
71/// Shared by the clap parser and [`run_query`] so a directly constructed
72/// [`QueryArgs`] (unit tests, library callers) cannot reach the panic either.
73fn check_timeout(seconds: f64) -> Result<f64, String> {
74    if !seconds.is_finite() || seconds <= 0.0 || seconds > MAX_TIMEOUT_SECS {
75        return Err(format!(
76            "timeout must be a positive, finite number of seconds \
77             (at most {MAX_TIMEOUT_SECS:.0}), got {seconds}"
78        ));
79    }
80    Ok(seconds)
81}
82
83/// clap `value_parser` for `--timeout`, so a malformed value is a *usage* error
84/// (exit code 2, clap's own convention) reported before the value can reach
85/// `Duration::from_secs_f64` — which panics, exiting 101 and breaking the
86/// documented 0/1/2/3 contract.
87fn parse_timeout(raw: &str) -> Result<f64, String> {
88    let seconds: f64 = raw
89        .parse()
90        .map_err(|_| format!("`{raw}` is not a number of seconds"))?;
91    check_timeout(seconds)
92}
93
94/// Result rendering. A separate enum from `StatusFormat` because the variants
95/// differ — a query result has a CSV projection, a freshness verdict does not.
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
97pub enum QueryFormat {
98    /// Header line of column names, then one TSV row per result row.
99    #[default]
100    Human,
101    /// `CypherResult::to_csv()` — the same renderer as the MCP `FORMAT CSV`
102    /// export, but uncapped. Since kglite 0.16.6 the MCP inline body stops at
103    /// 200 data rows and appends a truncation notice; stdout gets every row.
104    Csv,
105    /// One compact `{"columns": [...], "rows": [[...]]}` object.
106    Json,
107}
108
109/// A rendered query result: the bytes destined for stdout plus what the
110/// printing shell reports on stderr. The freshness warning is returned as
111/// data, not printed here, so the decision of which stream it lands on stays
112/// in one place — and so tests need not capture stderr.
113#[derive(Debug)]
114pub(crate) struct QueryOutput {
115    pub(crate) stdout: String,
116    pub(crate) rows: usize,
117    pub(crate) warning: Option<String>,
118}
119
120/// A `--require-fresh` refusal. Typed so `exit_code_for` can distinguish it
121/// from an operational failure and give CI a dedicated exit code.
122#[derive(Debug)]
123pub struct StaleGraph {
124    pub graph: PathBuf,
125    pub reason: String,
126}
127
128impl std::fmt::Display for StaleGraph {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        write!(
131            f,
132            "--require-fresh: refusing to query {} — {}",
133            self.graph.display(),
134            self.reason
135        )
136    }
137}
138
139impl std::error::Error for StaleGraph {}
140
141pub(crate) fn run(args: &QueryArgs) -> Result<()> {
142    let query = read_query(&args.query, std::io::stdin().lock())?;
143    let output = run_query(args, &query)?;
144    if let Some(warning) = &output.warning {
145        eprintln!("warning: {warning}");
146    }
147    print!("{}", output.stdout);
148    eprintln!("{} row(s)", output.rows);
149    Ok(())
150}
151
152/// Resolve the positional query argument: `-` means "read stdin to EOF".
153pub(crate) fn read_query(spec: &str, mut stdin: impl Read) -> Result<String> {
154    if spec != "-" {
155        return Ok(spec.to_string());
156    }
157    let mut text = String::new();
158    stdin
159        .read_to_string(&mut text)
160        .context("could not read the query from stdin")?;
161    Ok(text)
162}
163
164/// Load the artifact named by `args`, run `query` read-only, and render it.
165pub(crate) fn run_query(args: &QueryArgs, query: &str) -> Result<QueryOutput> {
166    let graph_path = args.graph.as_path();
167    if !graph_path.exists() {
168        anyhow::bail!(
169            "graph artifact not found: {} — build one with `codingest build <dir>`",
170            graph_path.display()
171        );
172    }
173
174    let warning = freshness_warning(graph_path);
175    if let (Some(reason), true) = (warning.as_deref(), args.require_fresh) {
176        return Err(StaleGraph {
177            graph: graph_path.to_path_buf(),
178            reason: reason.to_string(),
179        }
180        .into());
181    }
182
183    let graph_text = graph_path.to_string_lossy().to_string();
184    let graph = load_file(&graph_text)
185        .with_context(|| format!("could not load graph artifact {}", graph_path.display()))?;
186
187    let params: HashMap<String, Value> = HashMap::new();
188    // `ExecuteOptions::eager` is mandatory here: the lazy path yields silently
189    // empty row sets for any caller without a lazy materializer.
190    let mut opts = ExecuteOptions::eager(&params)
191        .with_csv_import(CsvImportPolicy::LocalFilesystem)
192        .with_parallel(args.parallel);
193    if let Some(seconds) = args.timeout {
194        let seconds = check_timeout(seconds).map_err(|message| anyhow::anyhow!("{message}"))?;
195        opts.deadline = Some(Instant::now() + Duration::from_secs_f64(seconds));
196    }
197    let outcome = execute_read(&graph, query, &opts).map_err(|e| anyhow::anyhow!("{e}"))?;
198
199    // An in-query `FORMAT CSV` is a parser-level output switch that the MCP
200    // server honors over its own default rendering; honoring it here too keeps
201    // one query behaving the same on both interfaces.
202    let effective = if outcome.output_format == OutputFormat::Csv {
203        QueryFormat::Csv
204    } else {
205        args.format
206    };
207    let result = &outcome.result;
208    let stdout = match effective {
209        QueryFormat::Human => render_human(&result.columns, &result.rows),
210        QueryFormat::Csv => result.to_csv(),
211        QueryFormat::Json => render_json(&result.columns, &result.rows),
212    };
213    Ok(QueryOutput {
214        stdout,
215        rows: result.rows.len(),
216        warning,
217    })
218}
219
220/// `None` when the artifact is provably fresh; otherwise the text to warn with.
221///
222/// Note the third outcome: the sidecar check can *fail* rather than merely
223/// report staleness — `source_fingerprint` errors outright when the recorded
224/// source directory is unreadable or a recorded git rev no longer resolves,
225/// which is exactly what a `.kgl` copied to another machine hits. That is a
226/// "freshness unknown" warning, not a reason to refuse to query.
227fn freshness_warning(graph_path: &Path) -> Option<String> {
228    match crate::code_tree_cli::status(graph_path) {
229        Ok(report) => {
230            if report["fresh"] == serde_json::Value::Bool(true) {
231                return None;
232            }
233            let state = report["status"].as_str().unwrap_or("unknown");
234            let reason = report["reason"].as_str().unwrap_or("no reason recorded");
235            Some(format!("graph is {state}: {reason}"))
236        }
237        Err(error) => Some(format!("freshness could not be verified: {error}")),
238    }
239}
240
241/// One compact JSON object per query — the single-line convention
242/// `status --format json` already set.
243fn render_json(columns: &[String], rows: &[Vec<Value>]) -> String {
244    let payload = serde_json::json!({
245        "columns": columns,
246        "rows": rows
247            .iter()
248            .map(|row| row.iter().map(kglite_value_to_json).collect::<Vec<_>>())
249            .collect::<Vec<_>>(),
250    });
251    format!("{}\n", serde_json::to_string(&payload).expect("JSON value"))
252}
253
254/// Header line of column names, then one TSV row per result row — all rows.
255fn render_human(columns: &[String], rows: &[Vec<Value>]) -> String {
256    let mut out = String::new();
257    out.push_str(&columns.join("\t"));
258    out.push('\n');
259    for row in rows {
260        let cells: Vec<String> = row.iter().map(render_cell).collect();
261        out.push_str(&cells.join("\t"));
262        out.push('\n');
263    }
264    out
265}
266
267/// Strings print raw with the TSV-hostile control characters escaped; every
268/// other variant serializes through the engine's canonical JSON projection.
269fn render_cell(value: &Value) -> String {
270    match value {
271        Value::String(s) => s
272            .replace('\t', "\\t")
273            .replace('\n', "\\n")
274            .replace('\r', "\\r"),
275        other => kglite_value_to_json(other).to_string(),
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::code_tree_cli::{build, BuildArgs, StatusFormat};
283    use std::fs;
284
285    struct Fixture {
286        _dir: tempfile::TempDir,
287        source: PathBuf,
288        graph: PathBuf,
289    }
290
291    /// A one-file source tree built into a `.kgl` that sits *beside* the tree,
292    /// not inside it — so a test can delete the sources and exercise the
293    /// "freshness could not be verified" path with the artifact still loadable.
294    fn fixture() -> Fixture {
295        let dir = tempfile::tempdir().unwrap();
296        let source = dir.path().join("proj");
297        fs::create_dir(&source).unwrap();
298        fs::write(
299            source.join("Cargo.toml"),
300            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
301        )
302        .unwrap();
303        fs::create_dir(source.join("src")).unwrap();
304        fs::write(
305            source.join("src/lib.rs"),
306            "pub fn alpha() {}\npub fn beta() { alpha(); }\n",
307        )
308        .unwrap();
309        let graph = dir.path().join("demo.kgl");
310        build(&BuildArgs {
311            source: source.clone(),
312            output: Some(graph.clone()),
313            rev: None,
314            revs: vec![],
315            repo_root: None,
316            no_tests: false,
317            include_docs: false,
318            max_loc_per_file: None,
319            verbose: false,
320            format: StatusFormat::Json,
321        })
322        .unwrap();
323        Fixture {
324            _dir: dir,
325            source,
326            graph,
327        }
328    }
329
330    /// `QueryArgs` for a fixture graph. `query` is unused by `run_query` (the
331    /// text is passed separately, already resolved from stdin if it was `-`).
332    fn args(graph: &Path, format: QueryFormat) -> QueryArgs {
333        QueryArgs {
334            query: String::new(),
335            graph: graph.to_path_buf(),
336            timeout: None,
337            format,
338            require_fresh: false,
339            parallel: false,
340        }
341    }
342
343    #[test]
344    fn query_returns_rows_from_built_graph() {
345        let fx = fixture();
346        let out = run_query(
347            &args(&fx.graph, QueryFormat::Human),
348            "MATCH (f:Function) RETURN f.name, f.qualified_name ORDER BY f.name ASC",
349        )
350        .unwrap();
351        assert_eq!(
352            out.stdout,
353            "f.name\tf.qualified_name\n\
354             alpha\tcrate::src::alpha\n\
355             beta\tcrate::src::beta\n"
356        );
357        assert_eq!(out.rows, 2);
358    }
359
360    #[test]
361    fn query_renders_non_string_cells_as_json() {
362        let fx = fixture();
363        let counted = run_query(
364            &args(&fx.graph, QueryFormat::Human),
365            "MATCH (f:Function) RETURN count(f)",
366        )
367        .unwrap();
368        assert_eq!(counted.stdout, "count(f)\n2\n");
369        assert_eq!(counted.rows, 1);
370
371        let listed = run_query(
372            &args(&fx.graph, QueryFormat::Human),
373            "MATCH (f:File) RETURN f.path, labels(f)",
374        )
375        .unwrap();
376        assert_eq!(listed.stdout, "f.path\tlabels(f)\nsrc/lib.rs\t[\"File\"]\n");
377        assert_eq!(listed.rows, 1);
378    }
379
380    #[test]
381    fn render_cell_escapes_tsv_hostile_control_characters() {
382        assert_eq!(
383            render_cell(&Value::String("a\tb\nc\rd".to_string())),
384            "a\\tb\\nc\\rd"
385        );
386        assert_eq!(render_cell(&Value::Int64(-7)), "-7");
387        assert_eq!(render_cell(&Value::Null), "null");
388    }
389
390    #[test]
391    fn query_rejects_mutation_cypher() {
392        let fx = fixture();
393        let error = run_query(
394            &args(&fx.graph, QueryFormat::Human),
395            "CREATE (n:X {name: 'nope'})",
396        )
397        .unwrap_err()
398        .to_string();
399        assert!(
400            error.contains("execute_read called with a mutation query"),
401            "unexpected error: {error}"
402        );
403    }
404
405    #[test]
406    fn query_missing_graph_names_path_and_hint() {
407        let dir = tempfile::tempdir().unwrap();
408        let missing = dir.path().join("absent.kgl");
409        let error = run_query(
410            &args(&missing, QueryFormat::Human),
411            "MATCH (f:File) RETURN f.path",
412        )
413        .unwrap_err()
414        .to_string();
415        assert!(
416            error.contains(&missing.display().to_string()),
417            "error omits the path: {error}"
418        );
419        assert!(
420            error.contains("codingest build"),
421            "error omits the build hint: {error}"
422        );
423    }
424
425    /// Independently execute `query` and hand back the raw engine result, so a
426    /// format test can compare the CLI's rendering against the engine's own
427    /// projection rather than against a hand-copied string.
428    fn engine_result(graph_path: &Path, query: &str) -> kglite::api::cypher::CypherResult {
429        let graph = load_file(&graph_path.to_string_lossy()).unwrap();
430        let params: HashMap<String, Value> = HashMap::new();
431        let opts = ExecuteOptions::eager(&params);
432        execute_read(&graph, query, &opts).unwrap().result
433    }
434
435    #[test]
436    fn query_format_json_parses_and_matches() {
437        let fx = fixture();
438        let out = run_query(
439            &args(&fx.graph, QueryFormat::Json),
440            "MATCH (f:Function) RETURN f.name, f.qualified_name ORDER BY f.name ASC",
441        )
442        .unwrap();
443        assert!(out.stdout.ends_with('\n'));
444        assert_eq!(out.stdout.lines().count(), 1, "JSON must be one line");
445        let parsed: serde_json::Value = serde_json::from_str(&out.stdout).unwrap();
446        assert_eq!(
447            parsed,
448            serde_json::json!({
449                "columns": ["f.name", "f.qualified_name"],
450                "rows": [
451                    ["alpha", "crate::src::alpha"],
452                    ["beta", "crate::src::beta"],
453                ],
454            })
455        );
456        assert_eq!(out.rows, 2);
457    }
458
459    #[test]
460    fn query_format_json_projects_non_string_cells_naturally() {
461        let fx = fixture();
462        let out = run_query(
463            &args(&fx.graph, QueryFormat::Json),
464            "MATCH (f:File) RETURN f.path, labels(f)",
465        )
466        .unwrap();
467        let parsed: serde_json::Value = serde_json::from_str(&out.stdout).unwrap();
468        assert_eq!(
469            parsed["rows"],
470            serde_json::json!([["src/lib.rs", ["File"]]]),
471        );
472    }
473
474    #[test]
475    fn query_format_csv_equals_result_to_csv() {
476        let fx = fixture();
477        let query = "MATCH (f:Function) RETURN f.name, f.qualified_name ORDER BY f.name ASC";
478        let out = run_query(&args(&fx.graph, QueryFormat::Csv), query).unwrap();
479        let expected = engine_result(&fx.graph, query).to_csv();
480        assert_eq!(out.stdout, expected);
481        assert_eq!(
482            out.stdout,
483            "f.name,f.qualified_name\nalpha,crate::src::alpha\nbeta,crate::src::beta\n"
484        );
485        assert_eq!(out.rows, 2);
486    }
487
488    #[test]
489    fn query_inline_format_csv_overrides_flag() {
490        let fx = fixture();
491        // Two columns deliberately: a one-column CSV is byte-identical to the
492        // TSV rendering, so the assertion would also pass on a Human fallback.
493        let query = "MATCH (f:Function) RETURN f.name, f.qualified_name \
494                     ORDER BY f.name ASC FORMAT CSV";
495        for flag in [QueryFormat::Json, QueryFormat::Human] {
496            let out = run_query(&args(&fx.graph, flag), query).unwrap();
497            assert_eq!(
498                out.stdout,
499                "f.name,f.qualified_name\nalpha,crate::src::alpha\nbeta,crate::src::beta\n",
500                "--format {flag:?} survived an in-query FORMAT CSV"
501            );
502        }
503    }
504
505    #[test]
506    fn query_explain_renders_rows() {
507        let fx = fixture();
508        let out = run_query(
509            &args(&fx.graph, QueryFormat::Human),
510            "EXPLAIN MATCH (f:Function) RETURN f.name",
511        )
512        .unwrap();
513        assert!(out.rows > 0, "EXPLAIN produced no plan rows");
514        assert!(
515            out.stdout.lines().count() > 1,
516            "no rendered plan: {:?}",
517            out
518        );
519    }
520
521    const ROWS: &str = "MATCH (f:Function) RETURN f.name ORDER BY f.name ASC";
522    const EXPECTED: &str = "f.name\nalpha\nbeta\n";
523
524    #[test]
525    fn query_fresh_graph_has_no_warning() {
526        let fx = fixture();
527        let out = run_query(&args(&fx.graph, QueryFormat::Human), ROWS).unwrap();
528        assert_eq!(out.warning, None);
529        assert_eq!(out.stdout, EXPECTED);
530    }
531
532    #[test]
533    fn query_warns_on_stale_graph() {
534        let fx = fixture();
535        fs::write(fx.source.join("src/lib.rs"), "pub fn gamma() {}\n").unwrap();
536        let out = run_query(&args(&fx.graph, QueryFormat::Human), ROWS).unwrap();
537        assert_eq!(
538            out.warning.as_deref(),
539            Some("graph is stale: source changed since the graph was built")
540        );
541        // The stale graph is still queried — rows come from the artifact, not
542        // from the source that moved underneath it.
543        assert_eq!(out.stdout, EXPECTED);
544        assert_eq!(out.rows, 2);
545    }
546
547    #[test]
548    fn query_missing_sidecar_warns_but_runs() {
549        let fx = fixture();
550        let sidecar = fx.graph.with_extension("kgl.meta.json");
551        assert!(sidecar.exists(), "fixture sidecar missing: {sidecar:?}");
552        fs::remove_file(&sidecar).unwrap();
553        let out = run_query(&args(&fx.graph, QueryFormat::Human), ROWS).unwrap();
554        assert_eq!(
555            out.warning.as_deref(),
556            Some("graph is missing: graph artifact or metadata sidecar is missing")
557        );
558        assert_eq!(out.stdout, EXPECTED);
559    }
560
561    #[test]
562    fn query_unverifiable_freshness_warns_but_runs() {
563        let fx = fixture();
564        // A `.kgl` whose recorded source tree is gone — the copied-artifact
565        // case. `status()` errors rather than reporting stale; that must
566        // degrade to "freshness unknown", not kill the query.
567        fs::remove_dir_all(&fx.source).unwrap();
568        let out = run_query(&args(&fx.graph, QueryFormat::Human), ROWS).unwrap();
569        let warning = out.warning.expect("no warning for an unverifiable graph");
570        assert!(
571            warning.starts_with("freshness could not be verified: "),
572            "unexpected warning: {warning}"
573        );
574        assert_eq!(out.stdout, EXPECTED);
575    }
576
577    #[test]
578    fn query_require_fresh_errors_on_stale() {
579        let fx = fixture();
580        fs::write(fx.source.join("src/lib.rs"), "pub fn gamma() {}\n").unwrap();
581        let mut strict = args(&fx.graph, QueryFormat::Human);
582        strict.require_fresh = true;
583        let error = run_query(&strict, ROWS).unwrap_err();
584        let stale = error
585            .downcast_ref::<StaleGraph>()
586            .expect("--require-fresh did not produce a typed StaleGraph");
587        assert_eq!(stale.graph, fx.graph);
588        assert_eq!(
589            stale.reason,
590            "graph is stale: source changed since the graph was built"
591        );
592        assert_eq!(crate::exit_code_for(&error), 3);
593    }
594
595    #[test]
596    fn query_require_fresh_passes_on_fresh_graph() {
597        let fx = fixture();
598        let mut strict = args(&fx.graph, QueryFormat::Human);
599        strict.require_fresh = true;
600        let out = run_query(&strict, ROWS).unwrap();
601        assert_eq!(out.stdout, EXPECTED);
602    }
603
604    #[test]
605    fn exit_code_for_maps_stale_to_3_and_other_to_1() {
606        let stale: anyhow::Error = StaleGraph {
607            graph: PathBuf::from("/tmp/demo.kgl"),
608            reason: "source changed".to_string(),
609        }
610        .into();
611        assert_eq!(crate::exit_code_for(&stale), 3);
612        assert_eq!(crate::exit_code_for(&anyhow::anyhow!("bad cypher")), 1);
613        assert_eq!(
614            crate::exit_code_for(&std::io::Error::from(std::io::ErrorKind::NotFound).into()),
615            1
616        );
617    }
618
619    #[test]
620    fn parse_timeout_rejects_the_values_that_panic_duration() {
621        // Each of these exited 101 through `Duration::from_secs_f64` before the
622        // value_parser existed: negative, NaN, and past `Duration`'s range.
623        for raw in ["-1", "nan", "-0.5", "inf", "1e30", "0", "-0"] {
624            assert!(parse_timeout(raw).is_err(), "--timeout={raw} was accepted");
625        }
626        assert!(parse_timeout("banana").is_err());
627        assert_eq!(parse_timeout("0.000001"), Ok(0.000001));
628        assert_eq!(parse_timeout("30"), Ok(30.0));
629        assert_eq!(parse_timeout("1e9"), Ok(MAX_TIMEOUT_SECS));
630    }
631
632    #[test]
633    fn run_query_rejects_an_out_of_domain_timeout_without_panicking() {
634        // `QueryArgs` built in code bypasses clap, so the guard has to live in
635        // `run_query` too — this is the call that used to panic.
636        let fx = fixture();
637        for seconds in [-1.0, f64::NAN, 0.0, 1e30, f64::INFINITY] {
638            let mut bad = args(&fx.graph, QueryFormat::Human);
639            bad.timeout = Some(seconds);
640            let error = run_query(&bad, ROWS).unwrap_err().to_string();
641            assert!(
642                error.contains("timeout must be a positive, finite number"),
643                "unexpected error for {seconds}: {error}"
644            );
645        }
646    }
647
648    /// The `--parallel` opt-in is plumbed through to `ExecuteOptions`, and
649    /// opting in never changes the answer.
650    ///
651    /// This fixture cannot demonstrate a speedup and does not try to: the
652    /// engine's fan-out gate needs thousands of candidate rows, so a
653    /// two-function graph runs single-threaded whichever way the flag is set.
654    /// What is asserted is the contract the flag has to keep — same columns,
655    /// same rows, same order — because a `true` here is a *permission* handed
656    /// to the planner, not a different query.
657    #[test]
658    fn parallel_opt_in_is_wired_and_returns_the_same_rows() {
659        let fx = fixture();
660        let serial = run_query(&args(&fx.graph, QueryFormat::Human), ROWS).unwrap();
661        let mut parallel_args = args(&fx.graph, QueryFormat::Human);
662        parallel_args.parallel = true;
663        let parallel = run_query(&parallel_args, ROWS).unwrap();
664        assert_eq!(parallel.stdout, serial.stdout);
665        assert_eq!(parallel.rows, serial.rows);
666        assert_eq!(serial.rows, 2, "fixture lost its functions");
667    }
668
669    #[test]
670    fn query_reads_query_from_stdin_dash() {
671        let piped = "MATCH (f:File) RETURN f.path\n";
672        assert_eq!(read_query("-", piped.as_bytes()).unwrap(), piped);
673        assert_eq!(
674            read_query("MATCH (n) RETURN n", piped.as_bytes()).unwrap(),
675            "MATCH (n) RETURN n"
676        );
677    }
678}