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