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