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 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/// 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    /// Run a read-only Cypher query against a `.kgl` graph and print the result.
55    Query {
56        /// Path to the `.kgl` file.
57        graph: PathBuf,
58        /// Cypher query string.
59        query: String,
60        /// Output format.
61        #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
62        format: OutputFormat,
63    },
64    /// Run a write-capable Cypher statement against a `.kgl` graph.
65    Write {
66        /// Path to the `.kgl` file.
67        graph: PathBuf,
68        /// Cypher statement.
69        query: String,
70        /// Output format.
71        #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
72        format: OutputFormat,
73        /// Persist the graph after a successful statement.
74        #[arg(long)]
75        save: bool,
76        /// Comma-separated node-type whitelist for CREATE/SET mutations.
77        #[arg(long)]
78        write_scope: Option<String>,
79        /// Git SHA to stamp on auto_timestamp types.
80        #[arg(long)]
81        git_sha: Option<String>,
82        /// Actor id to stamp on auto_timestamp types.
83        #[arg(long)]
84        modified_by: Option<String>,
85    },
86    /// Print the dependency frontier from `CALL ready_set(...)`.
87    ReadySet {
88        /// Path to the `.kgl` file.
89        graph: PathBuf,
90        /// Dependency relationship type.
91        #[arg(long, default_value = "DEPENDS_ON")]
92        relationship: String,
93        /// Done predicate over `n`, for example: `n.status = "done"`.
94        #[arg(long)]
95        done: String,
96        /// Optional node type to include in the frontier.
97        #[arg(long)]
98        node_type: Option<String>,
99        /// Output format.
100        #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
101        format: OutputFormat,
102    },
103    /// Print the XML graph description used by agents for structure discovery.
104    Describe {
105        /// Path to the `.kgl` file.
106        graph: PathBuf,
107        /// Comma-separated node types for focused detail.
108        #[arg(long)]
109        types: Option<String>,
110        /// Search node types by name.
111        #[arg(long)]
112        type_search: Option<String>,
113        /// Include connection overview.
114        #[arg(long)]
115        connections: bool,
116        /// Comma-separated connection types for deep-dive detail.
117        #[arg(long)]
118        connection_types: Option<String>,
119        /// Include compact Cypher reference.
120        #[arg(long)]
121        cypher: bool,
122        /// Comma-separated Cypher topics for detailed docs.
123        #[arg(long)]
124        cypher_topics: Option<String>,
125        /// Include compact fluent API reference.
126        #[arg(long)]
127        fluent: bool,
128        /// Comma-separated fluent API topics for detailed docs.
129        #[arg(long)]
130        fluent_topics: Option<String>,
131        /// Max `(source_type, target_type)` pairs for connection deep-dives.
132        #[arg(long)]
133        max_pairs: Option<usize>,
134        /// Truncate long sample strings to this many characters.
135        #[arg(long, default_value_t = 40)]
136        sample_truncate: usize,
137    },
138    /// Keep one graph loaded and process JSONL requests on stdin.
139    Session {
140        /// Path to the `.kgl` file.
141        graph: PathBuf,
142        /// Default output format for query/write responses.
143        #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
144        format: OutputFormat,
145        /// Save the graph when the session exits successfully.
146        #[arg(long)]
147        save_on_exit: bool,
148        /// Comma-separated node-type whitelist for write requests.
149        #[arg(long)]
150        write_scope: Option<String>,
151        /// Git SHA to stamp on auto_timestamp types for write requests.
152        #[arg(long)]
153        git_sha: Option<String>,
154        /// Actor id to stamp on auto_timestamp types for write requests.
155        #[arg(long)]
156        modified_by: Option<String>,
157    },
158    /// Print a deterministic, human-readable text projection of a `.kgl` to
159    /// stdout — the canonical form for a git `textconv` diff filter. Set up:
160    /// `git config diff.kglite.textconv "kglite export-text"` +
161    /// `echo '*.kgl diff=kglite' >> .gitattributes`.
162    ExportText {
163        /// Path to the `.kgl` file.
164        file: PathBuf,
165    },
166    /// Show what changed between two `.kgl` graphs — a structural delta over the
167    /// deterministic text projection: `-` lines dropped from A, `+` lines added
168    /// in B (a node/edge whose properties changed shows as a `-`/`+` pair).
169    Diff {
170        /// The "before" `.kgl`.
171        a: PathBuf,
172        /// The "after" `.kgl`.
173        b: PathBuf,
174    },
175    /// Export a `.kgl` as a SQLite-dialect SQL script — the no-lock-in exit.
176    /// Node types become tables, connection types become link tables. Ingest
177    /// with: `sqlite3 target.db < dump.sql`.
178    ExportSqlite {
179        /// Path to the `.kgl` file.
180        graph: PathBuf,
181        /// Where to write the SQL script. Omit to write to stdout.
182        output: Option<PathBuf>,
183    },
184    /// Apply pending Cypher migrations and advance the graph's user-schema
185    /// version. Migrations are `<version>_<name>.cypher` files in one
186    /// directory, applied in ascending version order; a re-run is a no-op.
187    Migrate {
188        /// Path to the `.kgl` file.
189        graph: PathBuf,
190        /// Directory holding the `<version>_<name>.cypher` migrations.
191        directory: PathBuf,
192        /// Print the plan without applying or saving anything.
193        #[arg(long)]
194        dry_run: bool,
195    },
196    /// Print the graph's user-schema version (the caller's own data-model
197    /// revision, distinct from the `.kgl` format version).
198    SchemaVersion {
199        /// Path to the `.kgl` file.
200        graph: PathBuf,
201        /// Stamp this version instead of printing — the "adopt migrations on an
202        /// existing graph" operation. Runs nothing; it asserts that the data
203        /// already has the shape migrations up to this version produce.
204        #[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
233/// Run the CLI over an explicit argument vector, including the program name.
234///
235/// The standalone binary and the `pip install kglite` wheel shim both call
236/// this entry point, so command parsing and behavior cannot drift.
237pub 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
392/// A fresh in-memory graph. `new_dir_graph_in_mode` returns `Result<_, String>`
393/// (not an `Error`), so adapt it into `anyhow` explicitly.
394fn 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
404/// Write the SQL projection of a graph to a file, or to stdout when no output
405/// path is given — the same shape as `export-text`, so the dump can be piped
406/// straight into `sqlite3`.
407fn 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, &params)
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, &params, &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, &params)?;
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, &params, &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}