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        /// Opt in to the parallel runtime for this query. A hint: operators
64        /// that cannot partition deterministically, and queries below the
65        /// engine's runtime size gate, still run sequentially.
66        #[arg(long)]
67        parallel: bool,
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. Node writes (CREATE, MERGE, SET,
82        /// REMOVE, DELETE, DETACH DELETE, node-type DDL) are judged by the node's
83        /// stored type; a relationship write needs at least one endpoint in the list.
84        #[arg(long)]
85        write_scope: Option<String>,
86        /// Git SHA to stamp on auto_timestamp types.
87        #[arg(long)]
88        git_sha: Option<String>,
89        /// Actor id to stamp on auto_timestamp types.
90        #[arg(long)]
91        modified_by: Option<String>,
92    },
93    /// Print the dependency frontier from `CALL ready_set(...)`.
94    ReadySet {
95        /// Path to the `.kgl` file.
96        graph: PathBuf,
97        /// Dependency relationship type.
98        #[arg(long, default_value = "DEPENDS_ON")]
99        relationship: String,
100        /// Done predicate over `n`, for example: `n.status = "done"`.
101        #[arg(long)]
102        done: String,
103        /// Optional node type to include in the frontier.
104        #[arg(long)]
105        node_type: Option<String>,
106        /// Output format.
107        #[arg(long, value_enum, default_value_t = OutputFormat::Table)]
108        format: OutputFormat,
109    },
110    /// Print the XML graph description used by agents for structure discovery.
111    Describe {
112        /// Path to the `.kgl` file.
113        graph: PathBuf,
114        /// Comma-separated node types for focused detail.
115        #[arg(long)]
116        types: Option<String>,
117        /// Search node types by name.
118        #[arg(long)]
119        type_search: Option<String>,
120        /// Include connection overview.
121        #[arg(long)]
122        connections: bool,
123        /// Comma-separated connection types for deep-dive detail.
124        #[arg(long)]
125        connection_types: Option<String>,
126        /// Include compact Cypher reference.
127        #[arg(long)]
128        cypher: bool,
129        /// Comma-separated Cypher topics for detailed docs.
130        #[arg(long)]
131        cypher_topics: Option<String>,
132        /// Include compact fluent API reference.
133        #[arg(long)]
134        fluent: bool,
135        /// Comma-separated fluent API topics for detailed docs.
136        #[arg(long)]
137        fluent_topics: Option<String>,
138        /// Max `(source_type, target_type)` pairs for connection deep-dives.
139        #[arg(long)]
140        max_pairs: Option<usize>,
141        /// Truncate long sample strings to this many characters.
142        #[arg(long, default_value_t = 40)]
143        sample_truncate: usize,
144    },
145    /// Keep one graph loaded and process JSONL requests on stdin.
146    Session {
147        /// Path to the `.kgl` file.
148        graph: PathBuf,
149        /// Default output format for query/write responses.
150        #[arg(long, value_enum, default_value_t = OutputFormat::Json)]
151        format: OutputFormat,
152        /// Save the graph when the session exits successfully.
153        #[arg(long)]
154        save_on_exit: bool,
155        /// Comma-separated node-type whitelist for write requests. Node writes are
156        /// judged by the node's stored type; a relationship write needs at least one
157        /// endpoint in the list.
158        #[arg(long)]
159        write_scope: Option<String>,
160        /// Git SHA to stamp on auto_timestamp types for write requests.
161        #[arg(long)]
162        git_sha: Option<String>,
163        /// Actor id to stamp on auto_timestamp types for write requests.
164        #[arg(long)]
165        modified_by: Option<String>,
166    },
167    /// Print a deterministic, human-readable text projection of a `.kgl` to
168    /// stdout — the canonical form for a git `textconv` diff filter. Set up:
169    /// `git config diff.kglite.textconv "kglite export-text"` +
170    /// `echo '*.kgl diff=kglite' >> .gitattributes`.
171    ExportText {
172        /// Path to the `.kgl` file.
173        file: PathBuf,
174    },
175    /// Show what changed between two `.kgl` graphs — a structural delta over the
176    /// deterministic text projection: `-` lines dropped from A, `+` lines added
177    /// in B (a node/edge whose properties changed shows as a `-`/`+` pair).
178    Diff {
179        /// The "before" `.kgl`.
180        a: PathBuf,
181        /// The "after" `.kgl`.
182        b: PathBuf,
183    },
184    /// Export a `.kgl` as a SQLite-dialect SQL script — the no-lock-in exit.
185    /// Node types become tables, connection types become link tables. Ingest
186    /// with: `sqlite3 target.db < dump.sql`.
187    ExportSqlite {
188        /// Path to the `.kgl` file.
189        graph: PathBuf,
190        /// Where to write the SQL script. Omit to write to stdout.
191        output: Option<PathBuf>,
192    },
193    /// Apply pending Cypher migrations and advance the graph's user-schema
194    /// version. Migrations are `<version>_<name>.cypher` files in one
195    /// directory, applied in ascending version order; a re-run is a no-op.
196    Migrate {
197        /// Path to the `.kgl` file.
198        graph: PathBuf,
199        /// Directory holding the `<version>_<name>.cypher` migrations.
200        directory: PathBuf,
201        /// Print the plan without applying or saving anything.
202        #[arg(long)]
203        dry_run: bool,
204    },
205    /// Print the graph's user-schema version (the caller's own data-model
206    /// revision, distinct from the `.kgl` format version).
207    SchemaVersion {
208        /// Path to the `.kgl` file.
209        graph: PathBuf,
210        /// Stamp this version instead of printing — the "adopt migrations on an
211        /// existing graph" operation. Runs nothing; it asserts that the data
212        /// already has the shape migrations up to this version produce.
213        #[arg(long)]
214        set: Option<u32>,
215    },
216}
217
218#[derive(Clone, Copy, Debug, Default, ValueEnum)]
219enum OutputFormat {
220    #[default]
221    Table,
222    Csv,
223    Json,
224}
225
226impl From<OutputFormat> for Mode {
227    fn from(value: OutputFormat) -> Self {
228        match value {
229            OutputFormat::Table => Mode::Table,
230            OutputFormat::Csv => Mode::Csv,
231            OutputFormat::Json => Mode::Json,
232        }
233    }
234}
235
236fn open_text(path: &Path) -> Result<String> {
237    let p = path.to_string_lossy().to_string();
238    let g = load_file(&p).with_context(|| format!("failed to open {p}"))?;
239    Ok(kglite::api::io::to_text(&g))
240}
241
242/// Run the CLI over an explicit argument vector, including the program name.
243///
244/// The standalone binary and the `pip install kglite` wheel shim both call
245/// this entry point, so command parsing and behavior cannot drift.
246pub fn run<I, T>(args: I) -> Result<()>
247where
248    I: IntoIterator<Item = T>,
249    T: Into<OsString> + Clone,
250{
251    let cli = Cli::parse_from(args);
252
253    if let Some(Command::Query {
254        graph,
255        query,
256        format,
257        parallel,
258    }) = &cli.command
259    {
260        run_query(graph, query, (*format).into(), *parallel)?;
261        return Ok(());
262    }
263    if let Some(Command::Write {
264        graph,
265        query,
266        format,
267        save,
268        write_scope,
269        git_sha,
270        modified_by,
271    }) = &cli.command
272    {
273        run_write(
274            graph,
275            query,
276            (*format).into(),
277            *save,
278            write_scope.as_deref(),
279            git_sha.clone(),
280            modified_by.clone(),
281        )?;
282        return Ok(());
283    }
284    if let Some(Command::ReadySet {
285        graph,
286        relationship,
287        done,
288        node_type,
289        format,
290    }) = &cli.command
291    {
292        run_ready_set(
293            graph,
294            relationship,
295            done,
296            node_type.as_deref(),
297            (*format).into(),
298        )?;
299        return Ok(());
300    }
301    if let Some(Command::Describe {
302        graph,
303        types,
304        type_search,
305        connections,
306        connection_types,
307        cypher,
308        cypher_topics,
309        fluent,
310        fluent_topics,
311        max_pairs,
312        sample_truncate,
313    }) = &cli.command
314    {
315        run_describe(
316            graph,
317            DescribeOptions {
318                types: parse_csv(types.as_deref()),
319                type_search: type_search.clone(),
320                connections: detail_connections(*connections, connection_types.as_deref()),
321                cypher: detail_cypher(*cypher, cypher_topics.as_deref()),
322                fluent: detail_fluent(*fluent, fluent_topics.as_deref()),
323                max_pairs: *max_pairs,
324                sample_truncate: Some(*sample_truncate),
325            },
326        )?;
327        return Ok(());
328    }
329    if let Some(Command::Session {
330        graph,
331        format,
332        save_on_exit,
333        write_scope,
334        git_sha,
335        modified_by,
336    }) = &cli.command
337    {
338        run_session(
339            graph,
340            (*format).into(),
341            *save_on_exit,
342            write_scope.as_deref(),
343            git_sha.clone(),
344            modified_by.clone(),
345        )?;
346        return Ok(());
347    }
348    if let Some(Command::ExportText { file }) = &cli.command {
349        print!("{}", open_text(file)?);
350        return Ok(());
351    }
352    if let Some(Command::Diff { a, b }) = &cli.command {
353        let (ta, tb) = (open_text(a)?, open_text(b)?);
354        let a_lines: std::collections::BTreeSet<&str> =
355            ta.lines().filter(|l| !l.trim().is_empty()).collect();
356        let b_lines: std::collections::BTreeSet<&str> =
357            tb.lines().filter(|l| !l.trim().is_empty()).collect();
358        for l in a_lines.difference(&b_lines) {
359            println!("-{}", l.trim_start());
360        }
361        for l in b_lines.difference(&a_lines) {
362            println!("+{}", l.trim_start());
363        }
364        return Ok(());
365    }
366    if let Some(Command::ExportSqlite { graph, output }) = &cli.command {
367        return run_export_sqlite(graph, output.as_deref());
368    }
369    if let Some(Command::Migrate {
370        graph,
371        directory,
372        dry_run,
373    }) = &cli.command
374    {
375        return migrate::run(graph, directory, *dry_run);
376    }
377    if let Some(Command::SchemaVersion { graph, set }) = &cli.command {
378        return match set {
379            Some(version) => migrate::set_version(graph, *version),
380            None => migrate::print_version(graph),
381        };
382    }
383
384    let (graph, source, source_identity) = match &cli.graph {
385        Some(path) => {
386            let p = path.to_string_lossy().to_string();
387            let opened = open_or_create_graph(path, Some(StorageMode::Memory))
388                .with_context(|| format!("failed to open or create {}", path.display()))?;
389            if opened.disposition == OpenDisposition::Created {
390                eprintln!("note: {p} does not exist — starting an empty in-memory graph");
391            }
392            let source = (opened.disposition == OpenDisposition::Opened).then_some(p);
393            let identity = source.as_ref().map(|_| opened.identity);
394            (opened.graph, source, identity)
395        }
396        None => (Arc::new(fresh_graph()?), None, None),
397    };
398
399    repl::run(graph, source.as_deref(), source_identity)
400}
401
402/// A fresh in-memory graph. `new_dir_graph_in_mode` returns `Result<_, String>`
403/// (not an `Error`), so adapt it into `anyhow` explicitly.
404fn fresh_graph() -> Result<DirGraph> {
405    new_dir_graph_in_mode(StorageMode::Memory, None)
406        .map_err(|e| anyhow::anyhow!("failed to create an in-memory graph: {e}"))
407}
408
409fn load_graph(path: &Path) -> Result<Arc<DirGraph>> {
410    let p = path.to_string_lossy().to_string();
411    load_file(&p).with_context(|| format!("failed to open {p}"))
412}
413
414/// Write the SQL projection of a graph to a file, or to stdout when no output
415/// path is given — the same shape as `export-text`, so the dump can be piped
416/// straight into `sqlite3`.
417fn run_export_sqlite(graph_path: &Path, output: Option<&Path>) -> Result<()> {
418    let graph = load_graph(graph_path)?;
419    let sql = kglite::api::io::to_sqlite_dump(&graph, None)
420        .map_err(|e| anyhow::anyhow!("SQL export failed: {e}"))?;
421    match output {
422        Some(path) => {
423            std::fs::write(path, &sql)
424                .with_context(|| format!("failed to write {}", path.display()))?;
425            eprintln!("wrote {} ({} bytes)", path.display(), sql.len());
426        }
427        None => exec::write_stdout(&sql)?,
428    }
429    Ok(())
430}
431
432fn run_query(path: &Path, query: &str, mode: Mode, parallel: bool) -> Result<()> {
433    let graph = load_graph(path)?;
434    let (_, is_mutation) = kglite::api::cypher::parse_with_mutation_check(query)
435        .map_err(|e| anyhow::anyhow!("Cypher parse error: {e}"))?;
436    if is_mutation {
437        anyhow::bail!("query is read-only; use `kglite write` for mutations");
438    }
439    let params: HashMap<String, Value> = HashMap::new();
440    let options = QueryOptions {
441        parallel,
442        ..QueryOptions::default()
443    };
444    let outcome = exec::execute_readonly(&graph, query, &params, &options)
445        .with_context(|| "Cypher execution failed")?;
446    exec::write_stdout(&exec::render_outcome(
447        mode,
448        &outcome,
449        format::stdout_cell_cap(),
450    ))?;
451    Ok(())
452}
453
454fn run_write(
455    path: &Path,
456    query: &str,
457    mode: Mode,
458    persist: bool,
459    write_scope: Option<&str>,
460    git_sha: Option<String>,
461    modified_by: Option<String>,
462) -> Result<()> {
463    let _lease = if persist {
464        Some(GraphWriterLease::acquire(path, WRITE_LOCK_TIMEOUT)?)
465    } else {
466        None
467    };
468    let mut graph = open_or_create_graph(path, persist.then_some(StorageMode::Memory))
469        .with_context(|| format!("failed to open or create {}", path.display()))?
470        .graph;
471    let params: HashMap<String, Value> = HashMap::new();
472    let options = QueryOptions {
473        write_scope: exec::parse_write_scope(write_scope),
474        git_sha,
475        modified_by,
476        ..QueryOptions::default()
477    };
478    let outcome = exec::execute(&mut graph, query, &params, &options)
479        .with_context(|| "Cypher execution failed")?;
480    if persist {
481        let p = path.to_string_lossy().to_string();
482        save_graph(&mut graph, &p).map_err(|e| anyhow::anyhow!("failed to save {p}: {e}"))?;
483    }
484    exec::write_stdout(&exec::render_outcome(
485        mode,
486        &outcome,
487        format::stdout_cell_cap(),
488    ))?;
489    Ok(())
490}
491
492fn run_ready_set(
493    path: &Path,
494    relationship: &str,
495    done: &str,
496    node_type: Option<&str>,
497    mode: Mode,
498) -> Result<()> {
499    let mut config = vec![
500        format!("relationship: '{}'", cypher_string(relationship)),
501        format!("done: '{}'", cypher_string(done)),
502    ];
503    if let Some(node_type) = node_type {
504        config.push(format!("node_type: '{}'", cypher_string(node_type)));
505    }
506    let query = format!(
507        "CALL ready_set({{{}}}) YIELD node, dependency_count \
508         RETURN node.id AS id, node.title AS title, dependency_count \
509         ORDER BY dependency_count, id",
510        config.join(", ")
511    );
512    run_query(path, &query, mode, false)
513}
514
515struct DescribeOptions {
516    types: Option<Vec<String>>,
517    type_search: Option<String>,
518    connections: ConnectionDetail,
519    cypher: CypherDetail,
520    fluent: FluentDetail,
521    max_pairs: Option<usize>,
522    sample_truncate: Option<usize>,
523}
524
525fn run_describe(path: &Path, options: DescribeOptions) -> Result<()> {
526    let graph = load_graph(path)?;
527    let description = describe_graph(&graph, &options)?;
528    exec::write_stdout(&description)?;
529    Ok(())
530}
531
532fn describe_graph(graph: &Arc<DirGraph>, options: &DescribeOptions) -> Result<String> {
533    compute_description(
534        graph,
535        options.types.as_deref(),
536        &options.connections,
537        &options.cypher,
538        &options.fluent,
539        options.type_search.as_deref(),
540        options.max_pairs,
541        options.sample_truncate,
542    )
543    .map_err(|e| anyhow::anyhow!("describe failed: {e}"))
544}
545
546fn run_session(
547    path: &Path,
548    default_mode: Mode,
549    save_on_exit: bool,
550    write_scope: Option<&str>,
551    git_sha: Option<String>,
552    modified_by: Option<String>,
553) -> Result<()> {
554    let lease = if save_on_exit {
555        Some(GraphWriterLease::acquire(path, WRITE_LOCK_TIMEOUT)?)
556    } else {
557        None
558    };
559    let mut graph = open_or_create_graph(path, save_on_exit.then_some(StorageMode::Memory))
560        .with_context(|| format!("failed to open or create {}", path.display()))?
561        .graph;
562    let mut source_identity = GraphFileIdentity::capture(path)?;
563    let base_options = QueryOptions {
564        write_scope: exec::parse_write_scope(write_scope),
565        git_sha,
566        modified_by,
567        ..QueryOptions::default()
568    };
569    let stdin = io::stdin();
570    for line in stdin.lock().lines() {
571        let line = line?;
572        let line = line.trim();
573        if line.is_empty() {
574            continue;
575        }
576        match handle_session_line(
577            &mut graph,
578            path,
579            line,
580            default_mode,
581            &base_options,
582            &mut source_identity,
583            lease.is_some(),
584        ) {
585            SessionAction::Continue(value) => write_json_line(value)?,
586            SessionAction::Exit(value) => {
587                write_json_line(value)?;
588                if save_on_exit {
589                    save_loaded_graph(&mut graph, path, &mut source_identity, lease.is_some())?;
590                }
591                return Ok(());
592            }
593        }
594    }
595    if save_on_exit {
596        save_loaded_graph(&mut graph, path, &mut source_identity, lease.is_some())?;
597    }
598    Ok(())
599}
600
601enum SessionAction {
602    Continue(serde_json::Value),
603    Exit(serde_json::Value),
604}
605
606fn handle_session_line(
607    graph: &mut Arc<DirGraph>,
608    path: &Path,
609    line: &str,
610    default_mode: Mode,
611    base_options: &QueryOptions,
612    source_identity: &mut GraphFileIdentity,
613    lease_held: bool,
614) -> SessionAction {
615    let request: serde_json::Value = match serde_json::from_str(line) {
616        Ok(v) => v,
617        Err(e) => {
618            return SessionAction::Continue(json_error("parse", format!("invalid JSON: {e}")));
619        }
620    };
621    let op = request
622        .get("op")
623        .and_then(|v| v.as_str())
624        .unwrap_or("query");
625    let request_id = request.get("id").cloned();
626    let result = match op {
627        "query" => session_query(graph, &request, mode_from_request(&request, default_mode)),
628        "write" => session_write(
629            graph,
630            &request,
631            mode_from_request(&request, default_mode),
632            base_options,
633        ),
634        "describe" => session_describe(graph, &request),
635        "save" => save_loaded_graph(graph, path, source_identity, lease_held)
636            .map(|()| serde_json::json!({"ok": true, "op": "save"})),
637        "help" => Ok(session_help()),
638        "exit" | "quit" => {
639            let mut value = serde_json::json!({"ok": true, "op": op});
640            insert_request_id(&mut value, request_id);
641            return SessionAction::Exit(value);
642        }
643        other => Err(anyhow::anyhow!(
644            "unknown op {other:?}; valid ops: {} — send {{\"op\":\"help\"}} for details",
645            session_op_names()
646        )),
647    };
648    SessionAction::Continue(match result {
649        Ok(mut value) => {
650            if let Some(obj) = value.as_object_mut() {
651                obj.entry("op").or_insert_with(|| serde_json::json!(op));
652            }
653            insert_request_id(&mut value, request_id);
654            value
655        }
656        Err(e) => {
657            let mut value = json_error(op, e.to_string());
658            insert_request_id(&mut value, request_id);
659            value
660        }
661    })
662}
663
664/// The JSONL session protocol's op table: name + a one-line description of the
665/// request shape. Served by `{"op":"help"}` and named in the unknown-op error,
666/// so the protocol is discoverable from inside the protocol — an agent driving
667/// the session over a pipe has no other way to learn it.
668const SESSION_OPS: &[(&str, &str)] = &[
669    (
670        "query",
671        "run a read-only Cypher query — {\"op\":\"query\",\"query\":\"MATCH …\",\"format\":\"json|table|csv\"}",
672    ),
673    (
674        "write",
675        "run a write Cypher statement — {\"op\":\"write\",\"query\":\"CREATE …\"} \
676         (optional: \"write_scope\":[\"Type\"], \"git_sha\", \"modified_by\")",
677    ),
678    (
679        "describe",
680        "describe the graph for agents — {\"op\":\"describe\"} \
681         (optional: \"types\", \"type_search\", \"connections\", \"cypher\", \"fluent\", \"max_pairs\")",
682    ),
683    (
684        "save",
685        "save the loaded graph back to its file — {\"op\":\"save\"}",
686    ),
687    ("help", "list these ops — {\"op\":\"help\"}"),
688    (
689        "exit",
690        "end the session (alias: \"quit\") — {\"op\":\"exit\"}",
691    ),
692];
693
694/// Comma-separated op names for the unknown-op error, `quit` included because
695/// it is accepted even though `exit` is the documented spelling.
696fn session_op_names() -> String {
697    let mut names: Vec<&str> = SESSION_OPS.iter().map(|(name, _)| *name).collect();
698    names.push("quit");
699    names.join(", ")
700}
701
702/// `{"op":"help"}` — the op table as a normal `ok:true` response.
703fn session_help() -> serde_json::Value {
704    let ops: Vec<serde_json::Value> = SESSION_OPS
705        .iter()
706        .map(|(name, description)| serde_json::json!({"op": name, "description": description}))
707        .collect();
708    serde_json::json!({
709        "ok": true,
710        "protocol": "one JSON request object per line on stdin, one JSON response per line on stdout; \
711                     every response echoes \"op\" and, when the request carried one, its \"id\". \
712                     A response is {\"ok\":true, …} or {\"ok\":false,\"error\":\"…\"}.",
713        "ops": ops,
714    })
715}
716
717fn session_query(
718    graph: &Arc<DirGraph>,
719    request: &serde_json::Value,
720    mode: Mode,
721) -> Result<serde_json::Value> {
722    let query = request_string(request, "query")?;
723    let (_, is_mutation) = kglite::api::cypher::parse_with_mutation_check(&query)
724        .map_err(|e| anyhow::anyhow!("Cypher parse error: {e}"))?;
725    if is_mutation {
726        anyhow::bail!("query is read-only; use op=write for mutations");
727    }
728    let params = HashMap::new();
729    let outcome = exec::execute_readonly(graph, &query, &params, &QueryOptions::default())?;
730    Ok(session_outcome_response(mode, &outcome))
731}
732
733fn session_write(
734    graph: &mut Arc<DirGraph>,
735    request: &serde_json::Value,
736    mode: Mode,
737    base_options: &QueryOptions,
738) -> Result<serde_json::Value> {
739    let query = request_string(request, "query")?;
740    let params = HashMap::new();
741    let options = QueryOptions {
742        write_scope: request
743            .get("write_scope")
744            .and_then(json_string_vec)
745            .map(|v| v.into_iter().collect())
746            .or_else(|| base_options.write_scope.clone()),
747        git_sha: request
748            .get("git_sha")
749            .and_then(|v| v.as_str().map(str::to_string))
750            .or_else(|| base_options.git_sha.clone()),
751        modified_by: request
752            .get("modified_by")
753            .and_then(|v| v.as_str().map(str::to_string))
754            .or_else(|| base_options.modified_by.clone()),
755        ..QueryOptions::default()
756    };
757    let outcome = exec::execute(graph, &query, &params, &options)?;
758    Ok(session_outcome_response(mode, &outcome))
759}
760
761fn session_describe(
762    graph: &Arc<DirGraph>,
763    request: &serde_json::Value,
764) -> Result<serde_json::Value> {
765    let options = describe_options_from_json(request)?;
766    Ok(serde_json::json!({
767        "ok": true,
768        "description": describe_graph(graph, &options)?,
769    }))
770}
771
772fn save_loaded_graph(
773    graph: &mut Arc<DirGraph>,
774    path: &Path,
775    source_identity: &mut GraphFileIdentity,
776    lease_held: bool,
777) -> Result<()> {
778    let _lease = (!lease_held)
779        .then(|| GraphWriterLease::acquire(path, WRITE_LOCK_TIMEOUT))
780        .transpose()?;
781    let current = GraphFileIdentity::capture(path)?;
782    if current != *source_identity {
783        anyhow::bail!(
784            "refusing to overwrite {}: it changed since this session loaded it",
785            path.display()
786        );
787    }
788    let p = path.to_string_lossy().to_string();
789    save_graph(graph, &p).map_err(|e| anyhow::anyhow!("failed to save {p}: {e}"))?;
790    *source_identity = GraphFileIdentity::capture(path)?;
791    Ok(())
792}
793
794fn write_json_line(value: serde_json::Value) -> Result<()> {
795    let mut stdout = io::stdout().lock();
796    serde_json::to_writer(&mut stdout, &value)?;
797    stdout.write_all(b"\n")?;
798    stdout.flush()?;
799    Ok(())
800}
801
802fn session_outcome_response(
803    mode: Mode,
804    outcome: &kglite::api::session::ExecuteOutcome,
805) -> serde_json::Value {
806    if mode == Mode::Json {
807        serde_json::json!({
808            "ok": true,
809            "rows": exec::outcome_rows_json(outcome),
810        })
811    } else {
812        serde_json::json!({
813            "ok": true,
814            // The session speaks a machine protocol: a rendered table here is
815            // still data a caller parses, so it is never width-truncated.
816            "output": exec::render_outcome(mode, outcome, None),
817        })
818    }
819}
820
821fn insert_request_id(value: &mut serde_json::Value, request_id: Option<serde_json::Value>) {
822    let Some(id) = request_id else {
823        return;
824    };
825    if let Some(obj) = value.as_object_mut() {
826        obj.entry("id").or_insert(id);
827    }
828}
829
830fn json_error(op: &str, message: String) -> serde_json::Value {
831    serde_json::json!({"ok": false, "op": op, "error": message})
832}
833
834fn request_string(request: &serde_json::Value, key: &str) -> Result<String> {
835    request
836        .get(key)
837        .and_then(|v| v.as_str())
838        .map(str::to_string)
839        .ok_or_else(|| anyhow::anyhow!("missing string field {key:?}"))
840}
841
842fn mode_from_request(request: &serde_json::Value, default_mode: Mode) -> Mode {
843    request
844        .get("format")
845        .and_then(|v| v.as_str())
846        .and_then(Mode::parse)
847        .unwrap_or(default_mode)
848}
849
850fn describe_options_from_json(request: &serde_json::Value) -> Result<DescribeOptions> {
851    Ok(DescribeOptions {
852        types: request.get("types").and_then(json_string_vec),
853        type_search: request
854            .get("type_search")
855            .and_then(|v| v.as_str().map(str::to_string)),
856        connections: detail_from_json(request.get("connections"), detail_connections(false, None))?,
857        cypher: detail_from_json(request.get("cypher"), detail_cypher(false, None))?,
858        fluent: detail_from_json(request.get("fluent"), detail_fluent(false, None))?,
859        max_pairs: request
860            .get("max_pairs")
861            .and_then(|v| v.as_u64())
862            .map(|n| n as usize),
863        sample_truncate: request
864            .get("sample_truncate")
865            .and_then(|v| v.as_u64())
866            .map(|n| n as usize)
867            .or(Some(40)),
868    })
869}
870
871fn json_string_vec(value: &serde_json::Value) -> Option<Vec<String>> {
872    if let Some(s) = value.as_str() {
873        return parse_csv(Some(s));
874    }
875    value.as_array().map(|items| {
876        items
877            .iter()
878            .filter_map(|v| v.as_str().map(str::to_string))
879            .collect()
880    })
881}
882
883trait DetailFromTopics: Sized {
884    fn off() -> Self;
885    fn overview() -> Self;
886    fn topics(topics: Vec<String>) -> Self;
887}
888
889impl DetailFromTopics for ConnectionDetail {
890    fn off() -> Self {
891        ConnectionDetail::Off
892    }
893    fn overview() -> Self {
894        ConnectionDetail::Overview
895    }
896    fn topics(topics: Vec<String>) -> Self {
897        ConnectionDetail::Topics(topics)
898    }
899}
900
901impl DetailFromTopics for CypherDetail {
902    fn off() -> Self {
903        CypherDetail::Off
904    }
905    fn overview() -> Self {
906        CypherDetail::Overview
907    }
908    fn topics(topics: Vec<String>) -> Self {
909        CypherDetail::Topics(topics)
910    }
911}
912
913impl DetailFromTopics for FluentDetail {
914    fn off() -> Self {
915        FluentDetail::Off
916    }
917    fn overview() -> Self {
918        FluentDetail::Overview
919    }
920    fn topics(topics: Vec<String>) -> Self {
921        FluentDetail::Topics(topics)
922    }
923}
924
925fn detail_from_json<T: DetailFromTopics>(
926    value: Option<&serde_json::Value>,
927    default: T,
928) -> Result<T> {
929    match value {
930        None | Some(serde_json::Value::Null) => Ok(default),
931        Some(serde_json::Value::Bool(false)) => Ok(T::off()),
932        Some(serde_json::Value::Bool(true)) => Ok(T::overview()),
933        Some(serde_json::Value::Object(obj)) => detail_from_object(obj),
934        Some(v) => json_string_vec(v)
935            .map(T::topics)
936            .ok_or_else(|| anyhow::anyhow!("detail must be bool, string, string array, or object")),
937    }
938}
939
940fn detail_from_object<T: DetailFromTopics>(
941    obj: &serde_json::Map<String, serde_json::Value>,
942) -> Result<T> {
943    if let Some(types) = obj
944        .get("types")
945        .or_else(|| obj.get("topics"))
946        .or_else(|| obj.get("names"))
947    {
948        return json_string_vec(types)
949            .map(T::topics)
950            .ok_or_else(|| anyhow::anyhow!("detail topics must be string or string array"));
951    }
952
953    let detail = obj
954        .get("detail")
955        .or_else(|| obj.get("mode"))
956        .and_then(|v| v.as_str())
957        .unwrap_or("overview");
958    match detail {
959        "off" | "none" | "false" => Ok(T::off()),
960        "overview" | "true" => Ok(T::overview()),
961        "topics" | "types" => obj
962            .get("value")
963            .or_else(|| obj.get("values"))
964            .and_then(json_string_vec)
965            .map(T::topics)
966            .ok_or_else(|| {
967                anyhow::anyhow!("detail='{detail}' requires value as string or string array")
968            }),
969        other => Err(anyhow::anyhow!(
970            "unknown detail {other:?}; use off, overview, or topics"
971        )),
972    }
973}
974
975fn parse_csv(raw: Option<&str>) -> Option<Vec<String>> {
976    raw.map(|s| {
977        s.split(',')
978            .map(str::trim)
979            .filter(|part| !part.is_empty())
980            .map(str::to_string)
981            .collect()
982    })
983    .filter(|v: &Vec<String>| !v.is_empty())
984}
985
986fn detail_connections(overview: bool, topics: Option<&str>) -> ConnectionDetail {
987    match parse_csv(topics) {
988        Some(v) => ConnectionDetail::Topics(v),
989        None if overview => ConnectionDetail::Overview,
990        None => ConnectionDetail::Off,
991    }
992}
993
994fn detail_cypher(overview: bool, topics: Option<&str>) -> CypherDetail {
995    match parse_csv(topics) {
996        Some(v) => CypherDetail::Topics(v),
997        None if overview => CypherDetail::Overview,
998        None => CypherDetail::Off,
999    }
1000}
1001
1002fn detail_fluent(overview: bool, topics: Option<&str>) -> FluentDetail {
1003    match parse_csv(topics) {
1004        Some(v) => FluentDetail::Topics(v),
1005        None if overview => FluentDetail::Overview,
1006        None => FluentDetail::Off,
1007    }
1008}
1009
1010fn cypher_string(s: &str) -> String {
1011    s.replace('\\', "\\\\").replace('\'', "\\'")
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::save_loaded_graph;
1017    use kglite::api::io::GraphFileIdentity;
1018    use kglite::api::DirGraph;
1019    use std::fs;
1020    use std::sync::Arc;
1021
1022    #[test]
1023    fn ad_hoc_save_rejects_lost_update() {
1024        let tmp = tempfile::tempdir().unwrap();
1025        let graph = tmp.path().join("demo.kgl");
1026        let mut initial = Arc::new(DirGraph::new());
1027        kglite::api::io::save_graph(&mut initial, &graph.to_string_lossy()).unwrap();
1028        let mut identity = GraphFileIdentity::capture(&graph).unwrap();
1029        let mut working = initial.clone();
1030
1031        fs::write(&graph, b"competing writer").unwrap();
1032        let error = save_loaded_graph(&mut working, &graph, &mut identity, false).unwrap_err();
1033
1034        assert!(error
1035            .to_string()
1036            .contains("changed since this session loaded"));
1037        assert_eq!(fs::read(&graph).unwrap(), b"competing writer");
1038    }
1039}