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