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