1mod exec;
11mod format;
12mod helper;
13mod migrate;
14mod repl;
15
16use std::collections::HashMap;
17use std::ffi::OsString;
18use std::io::{self, BufRead, Write};
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::Duration;
22
23use anyhow::{Context, Result};
24use clap::{Parser, Subcommand, ValueEnum};
25use kglite::api::introspection::{
26 compute_description, ConnectionDetail, CypherDetail, FluentDetail,
27};
28use kglite::api::io::{
29 load_file, open_or_create_graph, save_graph, GraphFileIdentity, GraphWriterLease,
30 OpenDisposition,
31};
32use kglite::api::storage::{new_dir_graph_in_mode, StorageMode};
33use kglite::api::{DirGraph, Value};
34
35use crate::exec::QueryOptions;
36use crate::format::Mode;
37
38const WRITE_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
39
40#[derive(Parser, Debug)]
42#[command(name = "kglite", version, about)]
43#[command(args_conflicts_with_subcommands = true)]
44struct Cli {
45 graph: Option<PathBuf>,
48 #[command(subcommand)]
49 command: Option<Command>,
50}
51
52#[derive(Subcommand, Debug)]
53enum Command {
54 Query {
56 graph: PathBuf,
58 query: String,
60 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
62 format: OutputFormat,
63 #[arg(long)]
67 parallel: bool,
68 },
69 Write {
71 graph: PathBuf,
73 query: String,
75 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
77 format: OutputFormat,
78 #[arg(long)]
80 save: bool,
81 #[arg(long)]
85 write_scope: Option<String>,
86 #[arg(long)]
88 git_sha: Option<String>,
89 #[arg(long)]
91 modified_by: Option<String>,
92 },
93 ReadySet {
95 graph: PathBuf,
97 #[arg(long, default_value = "DEPENDS_ON")]
99 relationship: String,
100 #[arg(long)]
102 done: String,
103 #[arg(long)]
105 node_type: Option<String>,
106 #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
108 format: OutputFormat,
109 },
110 Describe {
112 graph: PathBuf,
114 #[arg(long)]
116 types: Option<String>,
117 #[arg(long)]
119 type_search: Option<String>,
120 #[arg(long)]
122 connections: bool,
123 #[arg(long)]
125 connection_types: Option<String>,
126 #[arg(long)]
128 cypher: bool,
129 #[arg(long)]
131 cypher_topics: Option<String>,
132 #[arg(long)]
134 fluent: bool,
135 #[arg(long)]
137 fluent_topics: Option<String>,
138 #[arg(long)]
140 max_pairs: Option<usize>,
141 #[arg(long, default_value_t = 40)]
143 sample_truncate: usize,
144 },
145 Session {
147 graph: PathBuf,
149 #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
151 format: OutputFormat,
152 #[arg(long)]
154 save_on_exit: bool,
155 #[arg(long)]
159 write_scope: Option<String>,
160 #[arg(long)]
162 git_sha: Option<String>,
163 #[arg(long)]
165 modified_by: Option<String>,
166 },
167 ExportText {
172 file: PathBuf,
174 },
175 Diff {
179 a: PathBuf,
181 b: PathBuf,
183 },
184 ExportSqlite {
188 graph: PathBuf,
190 output: Option<PathBuf>,
192 },
193 Migrate {
197 graph: PathBuf,
199 directory: PathBuf,
201 #[arg(long)]
203 dry_run: bool,
204 },
205 SchemaVersion {
208 graph: PathBuf,
210 #[arg(long)]
214 set: Option<u32>,
215 },
216}
217
218#[derive(Clone, Copy, Debug, Default, ValueEnum)]
219enum OutputFormat {
220 #[default]
221 Table,
222 Csv,
223 Json,
224}
225
226impl From<OutputFormat> for Mode {
227 fn from(value: OutputFormat) -> Self {
228 match value {
229 OutputFormat::Table => Mode::Table,
230 OutputFormat::Csv => Mode::Csv,
231 OutputFormat::Json => Mode::Json,
232 }
233 }
234}
235
236fn open_text(path: &Path) -> Result<String> {
237 let p = path.to_string_lossy().to_string();
238 let g = load_file(&p).with_context(|| format!("failed to open {p}"))?;
239 Ok(kglite::api::io::to_text(&g))
240}
241
242pub fn run<I, T>(args: I) -> Result<()>
247where
248 I: IntoIterator<Item = T>,
249 T: Into<OsString> + Clone,
250{
251 let cli = Cli::parse_from(args);
252
253 if let Some(Command::Query {
254 graph,
255 query,
256 format,
257 parallel,
258 }) = &cli.command
259 {
260 run_query(graph, query, (*format).into(), *parallel)?;
261 return Ok(());
262 }
263 if let Some(Command::Write {
264 graph,
265 query,
266 format,
267 save,
268 write_scope,
269 git_sha,
270 modified_by,
271 }) = &cli.command
272 {
273 run_write(
274 graph,
275 query,
276 (*format).into(),
277 *save,
278 write_scope.as_deref(),
279 git_sha.clone(),
280 modified_by.clone(),
281 )?;
282 return Ok(());
283 }
284 if let Some(Command::ReadySet {
285 graph,
286 relationship,
287 done,
288 node_type,
289 format,
290 }) = &cli.command
291 {
292 run_ready_set(
293 graph,
294 relationship,
295 done,
296 node_type.as_deref(),
297 (*format).into(),
298 )?;
299 return Ok(());
300 }
301 if let Some(Command::Describe {
302 graph,
303 types,
304 type_search,
305 connections,
306 connection_types,
307 cypher,
308 cypher_topics,
309 fluent,
310 fluent_topics,
311 max_pairs,
312 sample_truncate,
313 }) = &cli.command
314 {
315 run_describe(
316 graph,
317 DescribeOptions {
318 types: parse_csv(types.as_deref()),
319 type_search: type_search.clone(),
320 connections: detail_connections(*connections, connection_types.as_deref()),
321 cypher: detail_cypher(*cypher, cypher_topics.as_deref()),
322 fluent: detail_fluent(*fluent, fluent_topics.as_deref()),
323 max_pairs: *max_pairs,
324 sample_truncate: Some(*sample_truncate),
325 },
326 )?;
327 return Ok(());
328 }
329 if let Some(Command::Session {
330 graph,
331 format,
332 save_on_exit,
333 write_scope,
334 git_sha,
335 modified_by,
336 }) = &cli.command
337 {
338 run_session(
339 graph,
340 (*format).into(),
341 *save_on_exit,
342 write_scope.as_deref(),
343 git_sha.clone(),
344 modified_by.clone(),
345 )?;
346 return Ok(());
347 }
348 if let Some(Command::ExportText { file }) = &cli.command {
349 print!("{}", open_text(file)?);
350 return Ok(());
351 }
352 if let Some(Command::Diff { a, b }) = &cli.command {
353 let (ta, tb) = (open_text(a)?, open_text(b)?);
354 let a_lines: std::collections::BTreeSet<&str> =
355 ta.lines().filter(|l| !l.trim().is_empty()).collect();
356 let b_lines: std::collections::BTreeSet<&str> =
357 tb.lines().filter(|l| !l.trim().is_empty()).collect();
358 for l in a_lines.difference(&b_lines) {
359 println!("-{}", l.trim_start());
360 }
361 for l in b_lines.difference(&a_lines) {
362 println!("+{}", l.trim_start());
363 }
364 return Ok(());
365 }
366 if let Some(Command::ExportSqlite { graph, output }) = &cli.command {
367 return run_export_sqlite(graph, output.as_deref());
368 }
369 if let Some(Command::Migrate {
370 graph,
371 directory,
372 dry_run,
373 }) = &cli.command
374 {
375 return migrate::run(graph, directory, *dry_run);
376 }
377 if let Some(Command::SchemaVersion { graph, set }) = &cli.command {
378 return match set {
379 Some(version) => migrate::set_version(graph, *version),
380 None => migrate::print_version(graph),
381 };
382 }
383
384 let (graph, source, source_identity) = match &cli.graph {
385 Some(path) => {
386 let p = path.to_string_lossy().to_string();
387 let opened = open_or_create_graph(path, Some(StorageMode::Memory))
388 .with_context(|| format!("failed to open or create {}", path.display()))?;
389 if opened.disposition == OpenDisposition::Created {
390 eprintln!("note: {p} does not exist — starting an empty in-memory graph");
391 }
392 let source = (opened.disposition == OpenDisposition::Opened).then_some(p);
393 let identity = source.as_ref().map(|_| opened.identity);
394 (opened.graph, source, identity)
395 }
396 None => (Arc::new(fresh_graph()?), None, None),
397 };
398
399 repl::run(graph, source.as_deref(), source_identity)
400}
401
402fn fresh_graph() -> Result<DirGraph> {
405 new_dir_graph_in_mode(StorageMode::Memory, None)
406 .map_err(|e| anyhow::anyhow!("failed to create an in-memory graph: {e}"))
407}
408
409fn load_graph(path: &Path) -> Result<Arc<DirGraph>> {
410 let p = path.to_string_lossy().to_string();
411 load_file(&p).with_context(|| format!("failed to open {p}"))
412}
413
414fn run_export_sqlite(graph_path: &Path, output: Option<&Path>) -> Result<()> {
418 let graph = load_graph(graph_path)?;
419 let sql = kglite::api::io::to_sqlite_dump(&graph, None)
420 .map_err(|e| anyhow::anyhow!("SQL export failed: {e}"))?;
421 match output {
422 Some(path) => {
423 std::fs::write(path, &sql)
424 .with_context(|| format!("failed to write {}", path.display()))?;
425 eprintln!("wrote {} ({} bytes)", path.display(), sql.len());
426 }
427 None => exec::write_stdout(&sql)?,
428 }
429 Ok(())
430}
431
432fn run_query(path: &Path, query: &str, mode: Mode, parallel: bool) -> Result<()> {
433 let graph = load_graph(path)?;
434 let (_, is_mutation) = kglite::api::cypher::parse_with_mutation_check(query)
435 .map_err(|e| anyhow::anyhow!("Cypher parse error: {e}"))?;
436 if is_mutation {
437 anyhow::bail!("query is read-only; use `kglite write` for mutations");
438 }
439 let params: HashMap<String, Value> = HashMap::new();
440 let options = QueryOptions {
441 parallel,
442 ..QueryOptions::default()
443 };
444 let outcome = exec::execute_readonly(&graph, query, ¶ms, &options)
445 .with_context(|| "Cypher execution failed")?;
446 exec::write_stdout(&exec::render_outcome(
447 mode,
448 &outcome,
449 format::stdout_cell_cap(),
450 ))?;
451 Ok(())
452}
453
454fn run_write(
455 path: &Path,
456 query: &str,
457 mode: Mode,
458 persist: bool,
459 write_scope: Option<&str>,
460 git_sha: Option<String>,
461 modified_by: Option<String>,
462) -> Result<()> {
463 let _lease = if persist {
464 Some(GraphWriterLease::acquire(path, WRITE_LOCK_TIMEOUT)?)
465 } else {
466 None
467 };
468 let mut graph = open_or_create_graph(path, persist.then_some(StorageMode::Memory))
469 .with_context(|| format!("failed to open or create {}", path.display()))?
470 .graph;
471 let params: HashMap<String, Value> = HashMap::new();
472 let options = QueryOptions {
473 write_scope: exec::parse_write_scope(write_scope),
474 git_sha,
475 modified_by,
476 ..QueryOptions::default()
477 };
478 let outcome = exec::execute(&mut graph, query, ¶ms, &options)
479 .with_context(|| "Cypher execution failed")?;
480 if persist {
481 let p = path.to_string_lossy().to_string();
482 save_graph(&mut graph, &p).map_err(|e| anyhow::anyhow!("failed to save {p}: {e}"))?;
483 }
484 exec::write_stdout(&exec::render_outcome(
485 mode,
486 &outcome,
487 format::stdout_cell_cap(),
488 ))?;
489 Ok(())
490}
491
492fn run_ready_set(
493 path: &Path,
494 relationship: &str,
495 done: &str,
496 node_type: Option<&str>,
497 mode: Mode,
498) -> Result<()> {
499 let mut config = vec![
500 format!("relationship: '{}'", cypher_string(relationship)),
501 format!("done: '{}'", cypher_string(done)),
502 ];
503 if let Some(node_type) = node_type {
504 config.push(format!("node_type: '{}'", cypher_string(node_type)));
505 }
506 let query = format!(
507 "CALL ready_set({{{}}}) YIELD node, dependency_count \
508 RETURN node.id AS id, node.title AS title, dependency_count \
509 ORDER BY dependency_count, id",
510 config.join(", ")
511 );
512 run_query(path, &query, mode, false)
513}
514
515struct DescribeOptions {
516 types: Option<Vec<String>>,
517 type_search: Option<String>,
518 connections: ConnectionDetail,
519 cypher: CypherDetail,
520 fluent: FluentDetail,
521 max_pairs: Option<usize>,
522 sample_truncate: Option<usize>,
523}
524
525fn run_describe(path: &Path, options: DescribeOptions) -> Result<()> {
526 let graph = load_graph(path)?;
527 let description = describe_graph(&graph, &options)?;
528 exec::write_stdout(&description)?;
529 Ok(())
530}
531
532fn describe_graph(graph: &Arc<DirGraph>, options: &DescribeOptions) -> Result<String> {
533 compute_description(
534 graph,
535 options.types.as_deref(),
536 &options.connections,
537 &options.cypher,
538 &options.fluent,
539 options.type_search.as_deref(),
540 options.max_pairs,
541 options.sample_truncate,
542 )
543 .map_err(|e| anyhow::anyhow!("describe failed: {e}"))
544}
545
546fn run_session(
547 path: &Path,
548 default_mode: Mode,
549 save_on_exit: bool,
550 write_scope: Option<&str>,
551 git_sha: Option<String>,
552 modified_by: Option<String>,
553) -> Result<()> {
554 let lease = if save_on_exit {
555 Some(GraphWriterLease::acquire(path, WRITE_LOCK_TIMEOUT)?)
556 } else {
557 None
558 };
559 let mut graph = open_or_create_graph(path, save_on_exit.then_some(StorageMode::Memory))
560 .with_context(|| format!("failed to open or create {}", path.display()))?
561 .graph;
562 let mut source_identity = GraphFileIdentity::capture(path)?;
563 let base_options = QueryOptions {
564 write_scope: exec::parse_write_scope(write_scope),
565 git_sha,
566 modified_by,
567 ..QueryOptions::default()
568 };
569 let stdin = io::stdin();
570 for line in stdin.lock().lines() {
571 let line = line?;
572 let line = line.trim();
573 if line.is_empty() {
574 continue;
575 }
576 match handle_session_line(
577 &mut graph,
578 path,
579 line,
580 default_mode,
581 &base_options,
582 &mut source_identity,
583 lease.is_some(),
584 ) {
585 SessionAction::Continue(value) => write_json_line(value)?,
586 SessionAction::Exit(value) => {
587 write_json_line(value)?;
588 if save_on_exit {
589 save_loaded_graph(&mut graph, path, &mut source_identity, lease.is_some())?;
590 }
591 return Ok(());
592 }
593 }
594 }
595 if save_on_exit {
596 save_loaded_graph(&mut graph, path, &mut source_identity, lease.is_some())?;
597 }
598 Ok(())
599}
600
601enum SessionAction {
602 Continue(serde_json::Value),
603 Exit(serde_json::Value),
604}
605
606fn handle_session_line(
607 graph: &mut Arc<DirGraph>,
608 path: &Path,
609 line: &str,
610 default_mode: Mode,
611 base_options: &QueryOptions,
612 source_identity: &mut GraphFileIdentity,
613 lease_held: bool,
614) -> SessionAction {
615 let request: serde_json::Value = match serde_json::from_str(line) {
616 Ok(v) => v,
617 Err(e) => {
618 return SessionAction::Continue(json_error("parse", format!("invalid JSON: {e}")));
619 }
620 };
621 let op = request
622 .get("op")
623 .and_then(|v| v.as_str())
624 .unwrap_or("query");
625 let request_id = request.get("id").cloned();
626 let result = match op {
627 "query" => session_query(graph, &request, mode_from_request(&request, default_mode)),
628 "write" => session_write(
629 graph,
630 &request,
631 mode_from_request(&request, default_mode),
632 base_options,
633 ),
634 "describe" => session_describe(graph, &request),
635 "save" => save_loaded_graph(graph, path, source_identity, lease_held)
636 .map(|()| serde_json::json!({"ok": true, "op": "save"})),
637 "help" => Ok(session_help()),
638 "exit" | "quit" => {
639 let mut value = serde_json::json!({"ok": true, "op": op});
640 insert_request_id(&mut value, request_id);
641 return SessionAction::Exit(value);
642 }
643 other => Err(anyhow::anyhow!(
644 "unknown op {other:?}; valid ops: {} — send {{\"op\":\"help\"}} for details",
645 session_op_names()
646 )),
647 };
648 SessionAction::Continue(match result {
649 Ok(mut value) => {
650 if let Some(obj) = value.as_object_mut() {
651 obj.entry("op").or_insert_with(|| serde_json::json!(op));
652 }
653 insert_request_id(&mut value, request_id);
654 value
655 }
656 Err(e) => {
657 let mut value = json_error(op, e.to_string());
658 insert_request_id(&mut value, request_id);
659 value
660 }
661 })
662}
663
664const SESSION_OPS: &[(&str, &str)] = &[
669 (
670 "query",
671 "run a read-only Cypher query — {\"op\":\"query\",\"query\":\"MATCH …\",\"format\":\"json|table|csv\"}",
672 ),
673 (
674 "write",
675 "run a write Cypher statement — {\"op\":\"write\",\"query\":\"CREATE …\"} \
676 (optional: \"write_scope\":[\"Type\"], \"git_sha\", \"modified_by\")",
677 ),
678 (
679 "describe",
680 "describe the graph for agents — {\"op\":\"describe\"} \
681 (optional: \"types\", \"type_search\", \"connections\", \"cypher\", \"fluent\", \"max_pairs\")",
682 ),
683 (
684 "save",
685 "save the loaded graph back to its file — {\"op\":\"save\"}",
686 ),
687 ("help", "list these ops — {\"op\":\"help\"}"),
688 (
689 "exit",
690 "end the session (alias: \"quit\") — {\"op\":\"exit\"}",
691 ),
692];
693
694fn session_op_names() -> String {
697 let mut names: Vec<&str> = SESSION_OPS.iter().map(|(name, _)| *name).collect();
698 names.push("quit");
699 names.join(", ")
700}
701
702fn session_help() -> serde_json::Value {
704 let ops: Vec<serde_json::Value> = SESSION_OPS
705 .iter()
706 .map(|(name, description)| serde_json::json!({"op": name, "description": description}))
707 .collect();
708 serde_json::json!({
709 "ok": true,
710 "protocol": "one JSON request object per line on stdin, one JSON response per line on stdout; \
711 every response echoes \"op\" and, when the request carried one, its \"id\". \
712 A response is {\"ok\":true, …} or {\"ok\":false,\"error\":\"…\"}.",
713 "ops": ops,
714 })
715}
716
717fn session_query(
718 graph: &Arc<DirGraph>,
719 request: &serde_json::Value,
720 mode: Mode,
721) -> Result<serde_json::Value> {
722 let query = request_string(request, "query")?;
723 let (_, is_mutation) = kglite::api::cypher::parse_with_mutation_check(&query)
724 .map_err(|e| anyhow::anyhow!("Cypher parse error: {e}"))?;
725 if is_mutation {
726 anyhow::bail!("query is read-only; use op=write for mutations");
727 }
728 let params = HashMap::new();
729 let outcome = exec::execute_readonly(graph, &query, ¶ms, &QueryOptions::default())?;
730 Ok(session_outcome_response(mode, &outcome))
731}
732
733fn session_write(
734 graph: &mut Arc<DirGraph>,
735 request: &serde_json::Value,
736 mode: Mode,
737 base_options: &QueryOptions,
738) -> Result<serde_json::Value> {
739 let query = request_string(request, "query")?;
740 let params = HashMap::new();
741 let options = QueryOptions {
742 write_scope: request
743 .get("write_scope")
744 .and_then(json_string_vec)
745 .map(|v| v.into_iter().collect())
746 .or_else(|| base_options.write_scope.clone()),
747 git_sha: request
748 .get("git_sha")
749 .and_then(|v| v.as_str().map(str::to_string))
750 .or_else(|| base_options.git_sha.clone()),
751 modified_by: request
752 .get("modified_by")
753 .and_then(|v| v.as_str().map(str::to_string))
754 .or_else(|| base_options.modified_by.clone()),
755 ..QueryOptions::default()
756 };
757 let outcome = exec::execute(graph, &query, ¶ms, &options)?;
758 Ok(session_outcome_response(mode, &outcome))
759}
760
761fn session_describe(
762 graph: &Arc<DirGraph>,
763 request: &serde_json::Value,
764) -> Result<serde_json::Value> {
765 let options = describe_options_from_json(request)?;
766 Ok(serde_json::json!({
767 "ok": true,
768 "description": describe_graph(graph, &options)?,
769 }))
770}
771
772fn save_loaded_graph(
773 graph: &mut Arc<DirGraph>,
774 path: &Path,
775 source_identity: &mut GraphFileIdentity,
776 lease_held: bool,
777) -> Result<()> {
778 let _lease = (!lease_held)
779 .then(|| GraphWriterLease::acquire(path, WRITE_LOCK_TIMEOUT))
780 .transpose()?;
781 let current = GraphFileIdentity::capture(path)?;
782 if current != *source_identity {
783 anyhow::bail!(
784 "refusing to overwrite {}: it changed since this session loaded it",
785 path.display()
786 );
787 }
788 let p = path.to_string_lossy().to_string();
789 save_graph(graph, &p).map_err(|e| anyhow::anyhow!("failed to save {p}: {e}"))?;
790 *source_identity = GraphFileIdentity::capture(path)?;
791 Ok(())
792}
793
794fn write_json_line(value: serde_json::Value) -> Result<()> {
795 let mut stdout = io::stdout().lock();
796 serde_json::to_writer(&mut stdout, &value)?;
797 stdout.write_all(b"\n")?;
798 stdout.flush()?;
799 Ok(())
800}
801
802fn session_outcome_response(
803 mode: Mode,
804 outcome: &kglite::api::session::ExecuteOutcome,
805) -> serde_json::Value {
806 if mode == Mode::Json {
807 serde_json::json!({
808 "ok": true,
809 "rows": exec::outcome_rows_json(outcome),
810 })
811 } else {
812 serde_json::json!({
813 "ok": true,
814 "output": exec::render_outcome(mode, outcome, None),
817 })
818 }
819}
820
821fn insert_request_id(value: &mut serde_json::Value, request_id: Option<serde_json::Value>) {
822 let Some(id) = request_id else {
823 return;
824 };
825 if let Some(obj) = value.as_object_mut() {
826 obj.entry("id").or_insert(id);
827 }
828}
829
830fn json_error(op: &str, message: String) -> serde_json::Value {
831 serde_json::json!({"ok": false, "op": op, "error": message})
832}
833
834fn request_string(request: &serde_json::Value, key: &str) -> Result<String> {
835 request
836 .get(key)
837 .and_then(|v| v.as_str())
838 .map(str::to_string)
839 .ok_or_else(|| anyhow::anyhow!("missing string field {key:?}"))
840}
841
842fn mode_from_request(request: &serde_json::Value, default_mode: Mode) -> Mode {
843 request
844 .get("format")
845 .and_then(|v| v.as_str())
846 .and_then(Mode::parse)
847 .unwrap_or(default_mode)
848}
849
850fn describe_options_from_json(request: &serde_json::Value) -> Result<DescribeOptions> {
851 Ok(DescribeOptions {
852 types: request.get("types").and_then(json_string_vec),
853 type_search: request
854 .get("type_search")
855 .and_then(|v| v.as_str().map(str::to_string)),
856 connections: detail_from_json(request.get("connections"), detail_connections(false, None))?,
857 cypher: detail_from_json(request.get("cypher"), detail_cypher(false, None))?,
858 fluent: detail_from_json(request.get("fluent"), detail_fluent(false, None))?,
859 max_pairs: request
860 .get("max_pairs")
861 .and_then(|v| v.as_u64())
862 .map(|n| n as usize),
863 sample_truncate: request
864 .get("sample_truncate")
865 .and_then(|v| v.as_u64())
866 .map(|n| n as usize)
867 .or(Some(40)),
868 })
869}
870
871fn json_string_vec(value: &serde_json::Value) -> Option<Vec<String>> {
872 if let Some(s) = value.as_str() {
873 return parse_csv(Some(s));
874 }
875 value.as_array().map(|items| {
876 items
877 .iter()
878 .filter_map(|v| v.as_str().map(str::to_string))
879 .collect()
880 })
881}
882
883trait DetailFromTopics: Sized {
884 fn off() -> Self;
885 fn overview() -> Self;
886 fn topics(topics: Vec<String>) -> Self;
887}
888
889impl DetailFromTopics for ConnectionDetail {
890 fn off() -> Self {
891 ConnectionDetail::Off
892 }
893 fn overview() -> Self {
894 ConnectionDetail::Overview
895 }
896 fn topics(topics: Vec<String>) -> Self {
897 ConnectionDetail::Topics(topics)
898 }
899}
900
901impl DetailFromTopics for CypherDetail {
902 fn off() -> Self {
903 CypherDetail::Off
904 }
905 fn overview() -> Self {
906 CypherDetail::Overview
907 }
908 fn topics(topics: Vec<String>) -> Self {
909 CypherDetail::Topics(topics)
910 }
911}
912
913impl DetailFromTopics for FluentDetail {
914 fn off() -> Self {
915 FluentDetail::Off
916 }
917 fn overview() -> Self {
918 FluentDetail::Overview
919 }
920 fn topics(topics: Vec<String>) -> Self {
921 FluentDetail::Topics(topics)
922 }
923}
924
925fn detail_from_json<T: DetailFromTopics>(
926 value: Option<&serde_json::Value>,
927 default: T,
928) -> Result<T> {
929 match value {
930 None | Some(serde_json::Value::Null) => Ok(default),
931 Some(serde_json::Value::Bool(false)) => Ok(T::off()),
932 Some(serde_json::Value::Bool(true)) => Ok(T::overview()),
933 Some(serde_json::Value::Object(obj)) => detail_from_object(obj),
934 Some(v) => json_string_vec(v)
935 .map(T::topics)
936 .ok_or_else(|| anyhow::anyhow!("detail must be bool, string, string array, or object")),
937 }
938}
939
940fn detail_from_object<T: DetailFromTopics>(
941 obj: &serde_json::Map<String, serde_json::Value>,
942) -> Result<T> {
943 if let Some(types) = obj
944 .get("types")
945 .or_else(|| obj.get("topics"))
946 .or_else(|| obj.get("names"))
947 {
948 return json_string_vec(types)
949 .map(T::topics)
950 .ok_or_else(|| anyhow::anyhow!("detail topics must be string or string array"));
951 }
952
953 let detail = obj
954 .get("detail")
955 .or_else(|| obj.get("mode"))
956 .and_then(|v| v.as_str())
957 .unwrap_or("overview");
958 match detail {
959 "off" | "none" | "false" => Ok(T::off()),
960 "overview" | "true" => Ok(T::overview()),
961 "topics" | "types" => obj
962 .get("value")
963 .or_else(|| obj.get("values"))
964 .and_then(json_string_vec)
965 .map(T::topics)
966 .ok_or_else(|| {
967 anyhow::anyhow!("detail='{detail}' requires value as string or string array")
968 }),
969 other => Err(anyhow::anyhow!(
970 "unknown detail {other:?}; use off, overview, or topics"
971 )),
972 }
973}
974
975fn parse_csv(raw: Option<&str>) -> Option<Vec<String>> {
976 raw.map(|s| {
977 s.split(',')
978 .map(str::trim)
979 .filter(|part| !part.is_empty())
980 .map(str::to_string)
981 .collect()
982 })
983 .filter(|v: &Vec<String>| !v.is_empty())
984}
985
986fn detail_connections(overview: bool, topics: Option<&str>) -> ConnectionDetail {
987 match parse_csv(topics) {
988 Some(v) => ConnectionDetail::Topics(v),
989 None if overview => ConnectionDetail::Overview,
990 None => ConnectionDetail::Off,
991 }
992}
993
994fn detail_cypher(overview: bool, topics: Option<&str>) -> CypherDetail {
995 match parse_csv(topics) {
996 Some(v) => CypherDetail::Topics(v),
997 None if overview => CypherDetail::Overview,
998 None => CypherDetail::Off,
999 }
1000}
1001
1002fn detail_fluent(overview: bool, topics: Option<&str>) -> FluentDetail {
1003 match parse_csv(topics) {
1004 Some(v) => FluentDetail::Topics(v),
1005 None if overview => FluentDetail::Overview,
1006 None => FluentDetail::Off,
1007 }
1008}
1009
1010fn cypher_string(s: &str) -> String {
1011 s.replace('\\', "\\\\").replace('\'', "\\'")
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::save_loaded_graph;
1017 use kglite::api::io::GraphFileIdentity;
1018 use kglite::api::DirGraph;
1019 use std::fs;
1020 use std::sync::Arc;
1021
1022 #[test]
1023 fn ad_hoc_save_rejects_lost_update() {
1024 let tmp = tempfile::tempdir().unwrap();
1025 let graph = tmp.path().join("demo.kgl");
1026 let mut initial = Arc::new(DirGraph::new());
1027 kglite::api::io::save_graph(&mut initial, &graph.to_string_lossy()).unwrap();
1028 let mut identity = GraphFileIdentity::capture(&graph).unwrap();
1029 let mut working = initial.clone();
1030
1031 fs::write(&graph, b"competing writer").unwrap();
1032 let error = save_loaded_graph(&mut working, &graph, &mut identity, false).unwrap_err();
1033
1034 assert!(error
1035 .to_string()
1036 .contains("changed since this session loaded"));
1037 assert_eq!(fs::read(&graph).unwrap(), b"competing writer");
1038 }
1039}