1use 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 pub query: String,
32 #[arg(short, long, default_value = DEFAULT_GRAPH)]
35 pub graph: PathBuf,
36 #[arg(long, value_parser = parse_timeout)]
39 pub timeout: Option<f64>,
40 #[arg(long, value_enum, default_value_t = QueryFormat::Human)]
42 pub format: QueryFormat,
43 #[arg(long)]
46 pub require_fresh: bool,
47 #[arg(long)]
54 pub parallel: bool,
55}
56
57const MAX_TIMEOUT_SECS: f64 = 1e9;
62
63fn 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
83fn 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
97pub enum QueryFormat {
98 #[default]
100 Human,
101 Csv,
105 Json,
107}
108
109#[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#[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
152pub(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
164pub(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 let mut opts = ExecuteOptions::eager(¶ms)
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 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
220fn 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
241fn 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
254fn 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
267fn 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 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 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 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(¶ms);
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 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 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 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 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 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 #[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(¶llel_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}