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