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