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}
48
49const MAX_TIMEOUT_SECS: f64 = 1e9;
54
55fn 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
75fn 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
89pub enum QueryFormat {
90 #[default]
92 Human,
93 Csv,
95 Json,
97}
98
99#[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#[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
142pub(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
154pub(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 let mut opts = ExecuteOptions::eager(¶ms).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 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
208fn 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
229fn 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
242fn 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
255fn 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 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 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 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(¶ms);
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 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 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 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 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 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}