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