Skip to main content

kglite_cli/
lib.rs

1//! Shared implementation of the `kglite` CLI.
2//!
3//! `kglite` is an interactive Cypher shell for `.kgl` knowledge graphs, in the
4//! spirit of the `sqlite3` CLI: open a single file, run queries and
5//! dot-commands from the terminal, no Python or server required.
6//!
7//! Pure-Rust binary over `kglite::api::*` (no libpython link), mirroring the
8//! kglite-bolt-server / kglite-mcp-server crate pattern.
9
10mod 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/// Interactive Cypher shell for kglite `.kgl` graphs.
41#[derive(Parser, Debug)]
42#[command(name = "kglite", version, about)]
43#[command(args_conflicts_with_subcommands = true)]
44struct Cli {
45    /// Path to a `.kgl` file to open. If omitted (or the file does not exist
46    /// yet), the shell starts with a fresh in-memory graph.
47    graph: Option<PathBuf>,
48    #[command(subcommand)]
49    command: Option<Command>,
50}
51
52#[derive(Subcommand, Debug)]
53enum Command {
54    /// Install or remove the bundled code-review skill for an agent host.
55    Skill {
56        #[command(subcommand)]
57        command: skill::SkillCommand,
58    },
59    /// Run a read-only Cypher query against a `.kgl` graph and print the result.
60    Query {
61        /// Path to the `.kgl` file.
62        graph: PathBuf,
63        /// Cypher query string.
64        query: String,
65        /// Output format.
66        #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
67        format: OutputFormat,
68    },
69    /// Run a write-capable Cypher statement against a `.kgl` graph.
70    Write {
71        /// Path to the `.kgl` file.
72        graph: PathBuf,
73        /// Cypher statement.
74        query: String,
75        /// Output format.
76        #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
77        format: OutputFormat,
78        /// Persist the graph after a successful statement.
79        #[arg(long)]
80        save: bool,
81        /// Comma-separated node-type whitelist for CREATE/SET mutations.
82        #[arg(long)]
83        write_scope: Option<String>,
84        /// Git SHA to stamp on auto_timestamp types.
85        #[arg(long)]
86        git_sha: Option<String>,
87        /// Actor id to stamp on auto_timestamp types.
88        #[arg(long)]
89        modified_by: Option<String>,
90    },
91    /// Print the dependency frontier from `CALL ready_set(...)`.
92    ReadySet {
93        /// Path to the `.kgl` file.
94        graph: PathBuf,
95        /// Dependency relationship type.
96        #[arg(long, default_value = "DEPENDS_ON")]
97        relationship: String,
98        /// Done predicate over `n`, for example: `n.status = "done"`.
99        #[arg(long)]
100        done: String,
101        /// Optional node type to include in the frontier.
102        #[arg(long)]
103        node_type: Option<String>,
104        /// Output format.
105        #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
106        format: OutputFormat,
107    },
108    /// Print the XML graph description used by agents for structure discovery.
109    Describe {
110        /// Path to the `.kgl` file.
111        graph: PathBuf,
112        /// Comma-separated node types for focused detail.
113        #[arg(long)]
114        types: Option<String>,
115        /// Search node types by name.
116        #[arg(long)]
117        type_search: Option<String>,
118        /// Include connection overview.
119        #[arg(long)]
120        connections: bool,
121        /// Comma-separated connection types for deep-dive detail.
122        #[arg(long)]
123        connection_types: Option<String>,
124        /// Include compact Cypher reference.
125        #[arg(long)]
126        cypher: bool,
127        /// Comma-separated Cypher topics for detailed docs.
128        #[arg(long)]
129        cypher_topics: Option<String>,
130        /// Include compact fluent API reference.
131        #[arg(long)]
132        fluent: bool,
133        /// Comma-separated fluent API topics for detailed docs.
134        #[arg(long)]
135        fluent_topics: Option<String>,
136        /// Max `(source_type, target_type)` pairs for connection deep-dives.
137        #[arg(long)]
138        max_pairs: Option<usize>,
139        /// Truncate long sample strings to this many characters.
140        #[arg(long, default_value_t = 40)]
141        sample_truncate: usize,
142    },
143    /// Keep one graph loaded and process JSONL requests on stdin.
144    Session {
145        /// Path to the `.kgl` file.
146        graph: PathBuf,
147        /// Default output format for query/write responses.
148        #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
149        format: OutputFormat,
150        /// Save the graph when the session exits successfully.
151        #[arg(long)]
152        save_on_exit: bool,
153        /// Comma-separated node-type whitelist for write requests.
154        #[arg(long)]
155        write_scope: Option<String>,
156        /// Git SHA to stamp on auto_timestamp types for write requests.
157        #[arg(long)]
158        git_sha: Option<String>,
159        /// Actor id to stamp on auto_timestamp types for write requests.
160        #[arg(long)]
161        modified_by: Option<String>,
162    },
163    /// Print a deterministic, human-readable text projection of a `.kgl` to
164    /// stdout — the canonical form for a git `textconv` diff filter. Set up:
165    /// `git config diff.kglite.textconv "kglite export-text"` +
166    /// `echo '*.kgl diff=kglite' >> .gitattributes`.
167    ExportText {
168        /// Path to the `.kgl` file.
169        file: PathBuf,
170    },
171    /// Show what changed between two `.kgl` graphs — a structural delta over the
172    /// deterministic text projection: `-` lines dropped from A, `+` lines added
173    /// in B (a node/edge whose properties changed shows as a `-`/`+` pair).
174    Diff {
175        /// The "before" `.kgl`.
176        a: PathBuf,
177        /// The "after" `.kgl`.
178        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
206/// Run the CLI over an explicit argument vector, including the program name.
207///
208/// The standalone binary and the `pip install kglite` wheel shim both call
209/// this entry point, so command parsing and behavior cannot drift.
210pub 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
351/// A fresh in-memory graph. `new_dir_graph_in_mode` returns `Result<_, String>`
352/// (not an `Error`), so adapt it into `anyhow` explicitly.
353fn 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, &params)
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, &params, &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, &params)?;
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, &params, &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}