LCOV - code coverage report
Current view: top level - dashql-cli/src/agent - client.rs Coverage Total Hit
Test: DashQL 915828b Lines: 94.2 % 399 376
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : //! The commands that agents call: they reach the open DashQL session through the agent server
       2              : 
       3              : use crate::agent::commands::{usage_lines, vis_commands};
       4              : use crate::agent::config::AgentConfig;
       5              : use crate::agent::http;
       6              : use crate::agent::instructions::{cli_guide, page_features, Page};
       7              : use crate::agent::protocol::ErrorCode;
       8              : use base64::Engine;
       9              : use serde_json::{json, Map, Value};
      10              : use std::io::{Read, Write};
      11              : 
      12              : /// Exit codes: 0 ok, 1 the command failed, 2 not connected or usage
      13              : pub const EXIT_OK: u8 = 0;
      14              : pub const EXIT_FAILED: u8 = 1;
      15              : pub const EXIT_USAGE: u8 = 2;
      16              : 
      17              : pub const NOT_RUNNING_MESSAGE: &str =
      18              :     "The DashQL agent server is not running: start DashQL, or run `dashql agent serve`";
      19              : 
      20              : /// The usage of `dashql agent`
      21           14 : pub fn usage() -> String {
      22           14 :     format!(
      23              :         "Usage: dashql agent <command> [options]
      24              : 
      25              : Commands for the DashQL session that is open in the browser or the desktop app:
      26              : {}
      27              :   serve [--exit-with-stdin]           Run the agent server in the foreground, --exit-with-stdin stops it when
      28              :                                       stdin closes, for an app that runs it as a child process
      29              : 
      30              : Options:
      31              :   --session <id>   Target session, default: $DASHQL_SESSION, then the focused session
      32              :   --json           Print structured output",
      33           14 :         usage_lines()
      34              :     )
      35           14 : }
      36              : 
      37              : /// The output of a command
      38              : #[derive(Debug, PartialEq, Eq)]
      39              : pub struct CliOutput {
      40              :     pub stdout: String,
      41              :     pub stderr: String,
      42              :     pub exit_code: u8,
      43              : }
      44              : 
      45              : impl CliOutput {
      46           26 :     fn ok(stdout: String) -> Self {
      47           26 :         CliOutput { stdout: with_newline(stdout), stderr: String::new(), exit_code: EXIT_OK }
      48           26 :     }
      49              : 
      50            6 :     fn error(message: &str, exit_code: u8) -> Self {
      51            6 :         CliOutput { stdout: String::new(), stderr: with_newline(message.to_string()), exit_code }
      52            6 :     }
      53              : 
      54           14 :     fn usage(message: &str) -> Self {
      55           14 :         CliOutput { stdout: String::new(), stderr: format!("{message}\n\n{}\n", usage()), exit_code: EXIT_USAGE }
      56           14 :     }
      57              : }
      58              : 
      59           32 : fn with_newline(text: String) -> String {
      60           32 :     if text.is_empty() || text.ends_with('\n') {
      61           17 :         text
      62              :     } else {
      63           15 :         text + "\n"
      64              :     }
      65           32 : }
      66              : 
      67              : /// The options of the commands, they may appear anywhere
      68              : #[derive(Debug, Default, PartialEq, Eq)]
      69              : pub struct Options {
      70              :     pub session: Option<String>,
      71              :     pub json: bool,
      72              :     pub file: Option<String>,
      73              :     pub connection: Option<String>,
      74              :     pub max_rows: Option<String>,
      75              :     pub page: Option<String>,
      76              :     pub out: Option<String>,
      77              :     pub width: Option<String>,
      78              :     pub height: Option<String>,
      79              :     pub scale: Option<String>,
      80              :     pub exit_with_stdin: bool,
      81              :     pub force: bool,
      82              :     pub help: bool,
      83              :     pub positionals: Vec<String>,
      84              : }
      85              : 
      86           45 : pub fn parse_args(argv: &[String]) -> Result<Options, String> {
      87           45 :     let mut options = Options::default();
      88           45 :     let mut args = argv.iter();
      89          141 :     while let Some(arg) = args.next() {
      90           98 :         let (name, inline) = match arg.split_once('=') {
      91            2 :             Some((name, value)) if name.starts_with("--") => (name, Some(value.to_string())),
      92           96 :             _ => (arg.as_str(), None),
      93              :         };
      94           98 :         let target = match name {
      95           98 :             "--session" => Some(&mut options.session),
      96           94 :             "--file" => Some(&mut options.file),
      97           93 :             "--connection" => Some(&mut options.connection),
      98           92 :             "--max-rows" => Some(&mut options.max_rows),
      99           90 :             "--page" => Some(&mut options.page),
     100           90 :             "--out" => Some(&mut options.out),
     101           89 :             "--width" => Some(&mut options.width),
     102           88 :             "--height" => Some(&mut options.height),
     103           87 :             "--scale" => Some(&mut options.scale),
     104           86 :             _ => None,
     105              :         };
     106           98 :         if let Some(target) = target {
     107           12 :             let value = inline.or_else(|| args.next().cloned());
     108           12 :             *target = Some(value.ok_or(format!("option {name} needs a value"))?);
     109           11 :             continue;
     110           86 :         }
     111           83 :         match name {
     112           86 :             "--json" if inline.is_none() => options.json = true,
     113           84 :             "--exit-with-stdin" if inline.is_none() => options.exit_with_stdin = true,
     114           84 :             "--force" if inline.is_none() => options.force = true,
     115           83 :             "-h" | "--help" if inline.is_none() => options.help = true,
     116           83 :             "--" => options.positionals.extend(args.by_ref().cloned()),
     117           83 :             a if a.starts_with('-') && a != "-" => return Err(format!("unknown option {arg}")),
     118           82 :             _ => options.positionals.push(arg.clone()),
     119              :         }
     120              :     }
     121           43 :     Ok(options)
     122           45 : }
     123              : 
     124            5 : fn parse_positive(value: &str, name: &str) -> Result<u64, String> {
     125            5 :     value.parse().ok().filter(|n| *n > 0).ok_or(format!("{name} must be a positive integer"))
     126            5 : }
     127              : 
     128              : /// Where the command line tool finds the agent server and its input
     129              : pub struct CliEnvironment<'a> {
     130              :     /// The value of DASHQL_SESSION
     131              :     pub session: Option<String>,
     132              :     /// Read stdin, for edit without --file
     133              :     pub read_stdin: &'a dyn Fn() -> std::io::Result<String>,
     134              :     /// Read a file, for edit --file
     135              :     pub read_file: &'a dyn Fn(&str) -> Result<String, String>,
     136              :     /// Write a file, for image --out
     137              :     pub write_file: &'a dyn Fn(&str, &[u8]) -> Result<(), String>,
     138              :     /// Send a request to the agent server, the JSON body of the response, None if the server does not run
     139              :     pub request: &'a dyn Fn(&str, &str, Option<&str>) -> Option<Value>,
     140              : }
     141              : 
     142              : /// Send a request to the agent server on 127.0.0.1
     143            0 : pub fn request(config: &AgentConfig, method: &str, path: &str, body: Option<&str>) -> Option<Value> {
     144            0 :     let mut stream = std::net::TcpStream::connect(("127.0.0.1", config.port)).ok()?;
     145            0 :     let body = body.unwrap_or_default();
     146            0 :     let request = format!(
     147              :         "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\n\
     148              :          Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
     149              :         config.port,
     150              :         config.token,
     151            0 :         body.len()
     152              :     );
     153            0 :     stream.write_all(request.as_bytes()).ok()?;
     154            0 :     let mut response = Vec::new();
     155            0 :     stream.read_to_end(&mut response).ok()?;
     156            0 :     let (_, body) = http::parse_response(&response)?;
     157            0 :     serde_json::from_slice(body).ok()
     158            0 : }
     159              : 
     160              : /// Render the state of the server
     161            2 : pub fn format_status(status: &Value) -> String {
     162            2 :     let mut lines = vec![format!("Agent server on 127.0.0.1:{}", status["port"])];
     163            2 :     let clients = status["clients"].as_array().map(Vec::as_slice).unwrap_or_default();
     164            2 :     if clients.is_empty() {
     165            1 :         lines.push("No DashQL client is connected: open https://dashql.app and turn on agent access".into());
     166            1 :     }
     167           12 :     let text = |v: &Value| v.as_str().unwrap_or_default().to_string();
     168            2 :     for client in clients {
     169            1 :         let sessions = client["sessions"].as_array().map(Vec::as_slice).unwrap_or_default();
     170            1 :         let plural = if sessions.len() == 1 { "" } else { "s" };
     171            1 :         lines.push(format!(
     172              :             "Client {} ({}), {} session{plural}",
     173            1 :             text(&client["kind"]),
     174            1 :             text(&client["origin"]),
     175            1 :             sessions.len()
     176              :         ));
     177            1 :         for session in sessions {
     178            1 :             let focused = if session["id"] == client["focused"] { " (focused)" } else { "" };
     179            1 :             lines.push(format!("  {}  {}{focused}", text(&session["id"]), Value::from(text(&session["name"]))));
     180              :         }
     181              :     }
     182            2 :     for turn in status["turns"].as_array().into_iter().flatten() {
     183            8 :         let [harness, session, state, status] = ["harness", "session", "state", "status"].map(|k| text(&turn[k]));
     184            4 :         let picks: Vec<_> = ["model", "effort"].iter().filter_map(|k| turn[*k].as_str()).collect();
     185            2 :         let picks = if picks.is_empty() { String::new() } else { format!(" ({})", picks.join(", ")) };
     186            2 :         lines.push(format!("Turn of {harness}{picks} in {session}: {state}, {status}"));
     187              :     }
     188            2 :     lines.join("\n")
     189            2 : }
     190              : 
     191              : /// The arguments of a call
     192           33 : fn call_args(
     193           33 :     command: &str,
     194           33 :     rest: &[String],
     195           33 :     options: &Options,
     196           33 :     cli: &CliEnvironment,
     197           33 : ) -> Result<Map<String, Value>, String> {
     198           33 :     let mut args = Map::new();
     199           33 :     let one = |what: &str| match rest {
     200           21 :         [script] => Ok(Value::from(script.clone())),
     201            2 :         _ => Err(format!("{} takes one {what}", command.replace('.', " "))),
     202           23 :     };
     203            7 :     match command {
     204           33 :         "scripts" if rest.is_empty() => {}
     205            0 :         "scripts" => return Err("scripts takes no arguments".into()),
     206           26 :         "read" | "lint" | "vis.read" => {
     207            7 :             args.insert("script".into(), one("script")?);
     208              :         }
     209           19 :         "edit" | "vis.edit" => {
     210            6 :             args.insert("script".into(), one("script")?);
     211            6 :             let text = match &options.file {
     212            1 :                 Some(file) => (cli.read_file)(file)?,
     213            5 :                 None => (cli.read_stdin)().map_err(|e| format!("stdin: {e}"))?,
     214              :             };
     215            5 :             args.insert((if command == "edit" { "text" } else { "spec" }).into(), text.into());
     216            5 :             if let (Some(connection), "edit") = (&options.connection, command) {
     217            1 :                 args.insert("connection".into(), connection.clone().into());
     218            4 :             }
     219              :         }
     220           13 :         "catalog" => match rest {
     221            1 :             [] => {}
     222            0 :             [connection] => {
     223            0 :                 args.insert("connection".into(), connection.clone().into());
     224            0 :             }
     225            0 :             _ => return Err("catalog takes at most one connection".into()),
     226              :         },
     227           12 :         "run" => {
     228            4 :             args.insert("script".into(), one("script")?);
     229            4 :             if let Some(max_rows) = &options.max_rows {
     230            2 :                 args.insert("maxRows".into(), parse_positive(max_rows, "--max-rows")?.into());
     231            2 :             }
     232            3 :             if options.force {
     233            1 :                 args.insert("force".into(), true.into());
     234            2 :             }
     235              :         }
     236            8 :         "result" => {
     237            1 :             args.insert("script".into(), one("script")?);
     238            1 :             if let Some(page) = &options.page {
     239            0 :                 args.insert("page".into(), parse_positive(page, "--page")?.into());
     240            1 :             }
     241              :         }
     242            7 :         "vis.image" => {
     243            5 :             args.insert("script".into(), one("script")?);
     244           14 :             for (name, value) in [("width", &options.width), ("height", &options.height), ("scale", &options.scale)] {
     245           14 :                 if let Some(value) = value {
     246            3 :                     args.insert(name.into(), parse_positive(value, &format!("--{name}"))?.into());
     247           11 :                 }
     248              :             }
     249              :         }
     250            2 :         _ => return Err(format!("Unknown command: {command}")),
     251              :     }
     252           26 :     Ok(args)
     253           33 : }
     254              : 
     255              : /// Run a command against the open DashQL session
     256           43 : pub fn run_cli(options: &Options, cli: &CliEnvironment) -> CliOutput {
     257           43 :     let Some((command, rest)) = options.positionals.split_first() else {
     258            1 :         return CliOutput::usage("Missing command");
     259              :     };
     260              :     // The vis commands are calls of their own: vis edit calls vis.edit
     261           42 :     let (command, rest) = match (command.as_str(), rest) {
     262           42 :         ("vis", [sub, rest @ ..]) if vis_commands().any(|c| c == sub) => (format!("vis.{sub}"), rest),
     263            2 :         ("vis", [sub, ..]) => return CliOutput::usage(&format!("Unknown vis command: {sub}")),
     264            1 :         ("vis", []) => {
     265            1 :             let commands: Vec<_> = vis_commands().collect();
     266            1 :             return CliOutput::usage(&format!("vis needs a command: {}", commands.join(", ")));
     267              :         }
     268           29 :         _ => (command.clone(), rest),
     269              :     };
     270              :     // The guide is the text of this binary, for the features of the page that holds the session
     271           39 :     if command == "guide" {
     272            5 :         if !rest.is_empty() {
     273            1 :             return CliOutput::usage("guide takes no arguments");
     274            4 :         }
     275            4 :         let session = options.session.clone().or(cli.session.clone()).filter(|s| !s.is_empty());
     276            4 :         let status = (cli.request)("GET", "/v1/status", None).filter(|body| body["ok"] == true);
     277            4 :         let features = status.and_then(|body| session_page(&body["status"], session.as_deref()));
     278            4 :         let text = cli_guide(features.as_deref().map_or(Page::Unknown, Page::Features));
     279            4 :         return CliOutput::ok(if options.json { pretty(&json!({ "text": text })) } else { text });
     280           34 :     }
     281           34 :     if command == "status" {
     282            1 :         let Some(body) = (cli.request)("GET", "/v1/status", None) else {
     283            1 :             return CliOutput::error(NOT_RUNNING_MESSAGE, EXIT_USAGE);
     284              :         };
     285            0 :         if body["ok"] != true {
     286            0 :             return failure(&body);
     287            0 :         }
     288            0 :         let status = &body["status"];
     289            0 :         return CliOutput::ok(if options.json { pretty(status) } else { format_status(status) });
     290           33 :     }
     291           33 :     let args = match call_args(&command, rest, options, cli) {
     292           26 :         Ok(args) => args,
     293            7 :         Err(e) => return CliOutput::usage(&e),
     294              :     };
     295           26 :     let session = options.session.clone().or(cli.session.clone()).filter(|s| !s.is_empty());
     296           26 :     let mut call = json!({ "command": command, "args": args });
     297           26 :     if let Some(session) = session {
     298            3 :         call["session"] = session.into();
     299           23 :     }
     300           26 :     let Some(body) = (cli.request)("POST", "/v1/call", Some(&call.to_string())) else {
     301            1 :         return CliOutput::error(NOT_RUNNING_MESSAGE, EXIT_USAGE);
     302              :     };
     303           25 :     if body["ok"] != true {
     304            2 :         return failure(&body);
     305           23 :     }
     306           23 :     let result = &body["result"];
     307           23 :     if command == "vis.image" {
     308            4 :         return write_image(result, rest, options, cli);
     309           19 :     }
     310           19 :     if options.json {
     311            1 :         let data = if result["data"].is_null() { json!({ "text": result["text"] }) } else { result["data"].clone() };
     312            1 :         return CliOutput::ok(pretty(&data));
     313           18 :     }
     314           18 :     CliOutput::ok(result["text"].as_str().unwrap_or_default().to_string())
     315           43 : }
     316              : 
     317              : /// The features of the page that holds the session, or of a page with a focused session, None without one
     318            3 : fn session_page(status: &Value, session: Option<&str>) -> Option<Vec<String>> {
     319            3 :     let clients = status["clients"].as_array()?;
     320            3 :     let client = match session {
     321            2 :         Some(session) => clients
     322            2 :             .iter()
     323            2 :             .find(|client| client["sessions"].as_array().is_some_and(|s| s.iter().any(|s| s["id"] == session))),
     324            1 :         None => clients.iter().find(|client| !client["focused"].is_null()),
     325            1 :     }?;
     326              :     // A server older than the features names none, its pages have the baseline
     327            2 :     Some(page_features(client["features"].as_array().map(|f| f.iter().filter_map(Value::as_str))))
     328            3 : }
     329              : 
     330              : /// Write the PNG of a vis image call to --out, default: <script>.png
     331            4 : fn write_image(result: &Value, rest: &[String], options: &Options, cli: &CliEnvironment) -> CliOutput {
     332            4 :     let png = result["data"]["png"].as_str().and_then(|png| base64::engine::general_purpose::STANDARD.decode(png).ok());
     333            4 :     let Some(png) = png else {
     334            2 :         return CliOutput::error("DashQL sent no PNG", EXIT_FAILED);
     335              :     };
     336            2 :     let file = options.out.clone().unwrap_or_else(|| format!("{}.png", rest[0].trim_end_matches(".sql")));
     337            2 :     if let Err(e) = (cli.write_file)(&file, &png) {
     338            0 :         return CliOutput::error(&e, EXIT_FAILED);
     339            2 :     }
     340            2 :     if options.json {
     341            1 :         let mut data = result["data"].clone();
     342            1 :         data["png"] = file.into();
     343            1 :         return CliOutput::ok(pretty(&data));
     344            1 :     }
     345            1 :     CliOutput::ok(format!("{}\nWrote {file}", result["text"].as_str().unwrap_or_default()))
     346            4 : }
     347              : 
     348            2 : fn pretty(value: &Value) -> String {
     349            2 :     serde_json::to_string_pretty(value).expect("JSON values serialize")
     350            2 : }
     351              : 
     352              : /// The output of a failed call: exit code 2 when DashQL is not connected or the call is malformed
     353            2 : fn failure(body: &Value) -> CliOutput {
     354            2 :     let error = &body["error"];
     355            2 :     let code = ErrorCode::parse(error["code"].as_str().unwrap_or_default());
     356            2 :     let exit_code = if matches!(code, ErrorCode::NotConnected | ErrorCode::Usage) { EXIT_USAGE } else { EXIT_FAILED };
     357            2 :     CliOutput::error(error["message"].as_str().unwrap_or("the call failed"), exit_code)
     358            2 : }
     359              : 
     360              : #[cfg(test)]
     361              : mod tests {
     362              :     use super::*;
     363              :     use std::cell::RefCell;
     364              : 
     365              :     struct Fake {
     366              :         response: Option<Value>,
     367              :         requests: RefCell<Vec<(String, String, Option<Value>)>>,
     368              :         written: RefCell<Vec<(String, Vec<u8>)>>,
     369              :     }
     370              : 
     371              :     impl Fake {
     372           17 :         fn new(response: Option<Value>) -> Self {
     373           17 :             Fake { response, requests: RefCell::new(Vec::new()), written: RefCell::new(Vec::new()) }
     374           17 :         }
     375              : 
     376           45 :         fn run(&self, argv: &[&str], session: Option<&str>, stdin: &str) -> CliOutput {
     377          107 :             let argv: Vec<String> = argv.iter().map(|a| a.to_string()).collect();
     378           45 :             let request = |method: &str, path: &str, body: Option<&str>| {
     379           31 :                 let body = body.map(|b| serde_json::from_str(b).unwrap());
     380           31 :                 self.requests.borrow_mut().push((method.into(), path.into(), body));
     381           31 :                 self.response.clone()
     382           31 :             };
     383           45 :             let stdin = stdin.to_string();
     384           45 :             let cli = CliEnvironment {
     385           45 :                 session: session.map(str::to_string),
     386            5 :                 read_stdin: &move || Ok(stdin.clone()),
     387            1 :                 read_file: &|file| Err(format!("{file}: not found")),
     388            2 :                 write_file: &|file, bytes| {
     389            2 :                     self.written.borrow_mut().push((file.to_string(), bytes.to_vec()));
     390            2 :                     Ok(())
     391            2 :                 },
     392           45 :                 request: &request,
     393              :             };
     394           45 :             match parse_args(&argv) {
     395           43 :                 Ok(options) => run_cli(&options, &cli),
     396            2 :                 Err(e) => CliOutput::usage(&e),
     397              :             }
     398           45 :         }
     399              : 
     400           21 :         fn body(&self, index: usize) -> Value {
     401           21 :             self.requests.borrow()[index].2.clone().unwrap()
     402           21 :         }
     403              :     }
     404              : 
     405              :     #[test]
     406            1 :     fn sends_a_call_and_prints_the_text() {
     407            1 :         let fake = Fake::new(Some(
     408            1 :             json!({ "ok": true, "session": "s", "result": { "text": "select 1", "data": { "text": "select 1" } } }),
     409            1 :         ));
     410            1 :         let output = fake.run(&["read", "q1"], None, "");
     411            1 :         assert_eq!(output, CliOutput { stdout: "select 1\n".into(), stderr: String::new(), exit_code: EXIT_OK });
     412            1 :         assert_eq!(fake.requests.borrow()[0].1, "/v1/call");
     413            1 :         assert_eq!(fake.body(0), json!({ "command": "read", "args": { "script": "q1" } }));
     414            1 :     }
     415              : 
     416              :     #[test]
     417            1 :     fn calls_every_command_of_the_session() {
     418              :         use crate::agent::commands::AGENT_COMMANDS;
     419            1 :         let fake = Fake::new(Some(json!({ "ok": true, "session": "s", "result": { "text": "" } })));
     420           12 :         for command in &AGENT_COMMANDS {
     421           12 :             let Some(call) = command.call() else { continue };
     422           10 :             let mut argv: Vec<&str> = command.name.split(' ').collect();
     423           10 :             if command.args.starts_with("<script>") {
     424            8 :                 argv.push("q1");
     425            8 :             }
     426           10 :             let output = fake.run(&argv, None, "select 1");
     427           10 :             assert_ne!(output.exit_code, EXIT_USAGE, "{}: {}", command.name, output.stderr);
     428           10 :             let calls = fake.requests.borrow().len();
     429           10 :             assert_eq!(fake.body(calls - 1)["command"], call.as_str());
     430              :         }
     431            1 :     }
     432              : 
     433              :     #[test]
     434            1 :     fn targets_the_session_of_the_environment_then_the_flag() {
     435            1 :         let fake = Fake::new(Some(json!({ "ok": true, "session": "s", "result": { "text": "" } })));
     436            1 :         fake.run(&["scripts"], Some("env-session"), "");
     437            1 :         assert_eq!(fake.body(0)["session"], "env-session");
     438            1 :         fake.run(&["scripts", "--session", "flag-session"], Some("env-session"), "");
     439            1 :         assert_eq!(fake.body(1)["session"], "flag-session");
     440            1 :         fake.run(&["--session=x", "scripts"], None, "");
     441            1 :         assert_eq!(fake.body(2)["session"], "x");
     442            1 :     }
     443              : 
     444              :     #[test]
     445            1 :     fn reads_the_text_of_an_edit_from_stdin() {
     446            1 :         let fake = Fake::new(Some(json!({ "ok": true, "session": "s", "result": { "text": "ok" } })));
     447            1 :         fake.run(&["edit", "q1"], None, "select 2");
     448            1 :         assert_eq!(fake.body(0)["args"], json!({ "script": "q1", "text": "select 2" }));
     449            1 :         let output = fake.run(&["edit", "q1", "--file", "missing.sql"], None, "");
     450            1 :         assert_eq!(output.exit_code, EXIT_USAGE);
     451            1 :         assert!(output.stderr.starts_with("missing.sql: not found"));
     452            1 :     }
     453              : 
     454              :     #[test]
     455            1 :     fn passes_the_connection_of_an_edit() {
     456            1 :         let fake = Fake::new(Some(json!({ "ok": true, "session": "s", "result": { "text": "ok" } })));
     457            1 :         fake.run(&["edit", "q1", "--connection", "lake"], None, "select 2");
     458            1 :         assert_eq!(fake.body(0)["args"], json!({ "script": "q1", "text": "select 2", "connection": "lake" }));
     459            1 :     }
     460              : 
     461              :     #[test]
     462            1 :     fn sends_the_vis_commands_as_calls_of_their_own() {
     463            1 :         let fake = Fake::new(Some(json!({ "ok": true, "session": "s", "result": { "text": "ok" } })));
     464            1 :         fake.run(&["vis", "edit", "q1"], None, "{\"mark\": \"bar\"}");
     465            1 :         let args = json!({ "script": "q1", "spec": "{\"mark\": \"bar\"}" });
     466            1 :         assert_eq!(fake.body(0), json!({ "command": "vis.edit", "args": args }));
     467            1 :         fake.run(&["vis", "read", "q1"], None, "");
     468            1 :         assert_eq!(fake.body(1), json!({ "command": "vis.read", "args": { "script": "q1" } }));
     469            1 :     }
     470              : 
     471              :     #[test]
     472            1 :     fn writes_the_png_of_an_image() {
     473            1 :         let data = json!({ "script": "q1", "width": 2, "height": 1, "png": "iVBORw==" });
     474            1 :         let fake =
     475            1 :             Fake::new(Some(json!({ "ok": true, "session": "s", "result": { "text": "Rendered", "data": data } })));
     476            1 :         let output = fake.run(&["vis", "image", "q1.sql", "--width", "400", "--scale=1"], None, "");
     477            1 :         let stdout = "Rendered\nWrote q1.png\n".to_string();
     478            1 :         assert_eq!(output, CliOutput { stdout, stderr: String::new(), exit_code: EXIT_OK });
     479            1 :         assert_eq!(fake.body(0)["args"], json!({ "script": "q1.sql", "width": 400, "scale": 1 }));
     480            1 :         assert_eq!(fake.written.borrow()[0], ("q1.png".to_string(), b"\x89PNG".to_vec()));
     481              : 
     482            1 :         let output = fake.run(&["vis", "image", "q1", "--out", "chart.png", "--json"], None, "");
     483            1 :         let printed: Value = serde_json::from_str(&output.stdout).unwrap();
     484            1 :         assert_eq!(printed, json!({ "script": "q1", "width": 2, "height": 1, "png": "chart.png" }));
     485            1 :         assert_eq!(fake.written.borrow()[1].0, "chart.png");
     486              : 
     487            1 :         let no_png = Fake::new(Some(json!({ "ok": true, "session": "s", "result": { "text": "Rendered" } })));
     488            1 :         assert_eq!(no_png.run(&["vis", "image", "q1"], None, "").exit_code, EXIT_FAILED);
     489            1 :         assert_eq!(fake.run(&["vis", "image", "q1", "--height", "0"], None, "").exit_code, EXIT_USAGE);
     490            1 :     }
     491              : 
     492              :     #[test]
     493            1 :     fn prints_the_guide_for_the_features_of_the_page() {
     494            3 :         let status = |features: Value| {
     495            3 :             let client = json!({ "id": "1", "kind": "web", "origin": "https://dashql.app", "sessions": [{ "id": "s1", "name": "S" }], "focused": "s1", "features": features });
     496            3 :             Some(json!({ "ok": true, "status": { "port": 1, "clients": [client], "turns": [] } }))
     497            3 :         };
     498            1 :         let current = Fake::new(status(json!(crate::agent::instructions::PAGE_FEATURES)));
     499            1 :         let output = current.run(&["guide"], None, "");
     500            1 :         assert_eq!(output.exit_code, EXIT_OK);
     501            1 :         assert!(output.stdout.contains("- Attachments are Parquet files of the session"));
     502            1 :         assert!(!output.stdout.contains("No DashQL is connected"));
     503            1 :         assert!(!output.stdout.contains("The open DashQL is older"));
     504            1 :         assert_eq!(current.requests.borrow()[0].1, "/v1/status");
     505              :         // A page without features has the baseline and is older than the guide
     506            1 :         let old = Fake::new(status(Value::Null)).run(&["guide", "--session", "s1"], None, "");
     507            1 :         assert!(old.stdout.contains("The open DashQL is older"));
     508            1 :         assert!(!old.stdout.contains("Attachments are Parquet files"));
     509              :         // Another session has no page
     510            1 :         let other = Fake::new(status(Value::Null)).run(&["guide"], Some("s2"), "");
     511            1 :         assert!(other.stdout.contains("No DashQL is connected"));
     512            1 :         assert_eq!(current.run(&["guide", "x"], None, "").exit_code, EXIT_USAGE);
     513            1 :     }
     514              : 
     515              :     #[test]
     516            1 :     fn prints_the_guide_without_the_server() {
     517            1 :         let output = Fake::new(None).run(&["guide"], None, "");
     518            1 :         assert_eq!(output, CliOutput::ok(cli_guide(Page::Unknown)));
     519            1 :         assert!(output.stdout.contains("No DashQL is connected"));
     520            1 :         assert!(output.stdout.contains("- Attachments are Parquet files of the session"));
     521            1 :     }
     522              : 
     523              :     #[test]
     524            1 :     fn prints_structured_output_with_json() {
     525            1 :         let fake =
     526            1 :             Fake::new(Some(json!({ "ok": true, "session": "s", "result": { "text": "x", "data": { "rows": 1 } } })));
     527            1 :         assert_eq!(fake.run(&["run", "q1", "--json", "--max-rows", "5"], None, "").stdout, "{\n  \"rows\": 1\n}\n");
     528            1 :         assert_eq!(fake.body(0)["args"], json!({ "script": "q1", "maxRows": 5 }));
     529            1 :         fake.run(&["run", "q1", "--force"], None, "");
     530            1 :         assert_eq!(fake.body(1)["args"], json!({ "script": "q1", "force": true }));
     531            1 :     }
     532              : 
     533              :     #[test]
     534            1 :     fn exits_with_2_when_not_connected_and_1_when_the_command_failed() {
     535            1 :         let not_connected =
     536            1 :             Fake::new(Some(json!({ "ok": false, "error": { "code": "not_connected", "message": "no" } })));
     537            1 :         assert_eq!(not_connected.run(&["scripts"], None, "").exit_code, EXIT_USAGE);
     538            1 :         let busy = Fake::new(Some(json!({ "ok": false, "error": { "code": "busy", "message": "busy" } })));
     539            1 :         assert_eq!(
     540            1 :             busy.run(&["scripts"], None, ""),
     541            1 :             CliOutput { stdout: String::new(), stderr: "busy\n".into(), exit_code: EXIT_FAILED }
     542              :         );
     543            1 :         let not_running = Fake::new(None);
     544            1 :         assert_eq!(not_running.run(&["scripts"], None, "").stderr, format!("{NOT_RUNNING_MESSAGE}\n"));
     545            1 :         assert_eq!(not_running.run(&["status"], None, "").exit_code, EXIT_USAGE);
     546            1 :     }
     547              : 
     548              :     #[test]
     549            1 :     fn rejects_malformed_calls() {
     550            1 :         let fake = Fake::new(Some(json!({})));
     551           11 :         for argv in [
     552            1 :             &[][..],
     553            1 :             &["read"],
     554            1 :             &["frobnicate"],
     555            1 :             &["run", "q1", "--max-rows", "-1"],
     556            1 :             &["run", "q1", "--bogus"],
     557            1 :             &["read", "--session"],
     558            1 :             &["vis"],
     559            1 :             &["vis", "show", "q1"],
     560            1 :             &["vis", "get", "q1"],
     561            1 :             &["vis", "read"],
     562            1 :             &["visualize", "q1"],
     563            1 :         ] {
     564           11 :             assert_eq!(fake.run(argv, None, "").exit_code, EXIT_USAGE, "{argv:?}");
     565              :         }
     566            1 :         assert!(fake.requests.borrow().is_empty());
     567            1 :     }
     568              : 
     569              :     #[test]
     570            1 :     fn formats_the_status() {
     571            1 :         let status = json!({
     572            1 :             "port": 47100,
     573            1 :             "clients": [{ "id": "c", "kind": "web", "origin": "https://dashql.app", "sessions": [{ "id": "s1", "name": "Revenue" }], "focused": "s1" }],
     574            1 :             "turns": [
     575            1 :                 { "turnId": "t", "session": "s1", "harness": "codex", "model": null, "effort": null, "state": "running", "status": "dashql agent catalog" },
     576            1 :                 { "turnId": "u", "session": "s1", "harness": "claude", "model": "opus", "effort": "high", "state": "queued", "status": "waiting for agent..." },
     577              :             ],
     578              :         });
     579            1 :         assert_eq!(
     580            1 :             format_status(&status),
     581              :             "Agent server on 127.0.0.1:47100\nClient web (https://dashql.app), 1 session\n  s1  \"Revenue\" (focused)\nTurn of codex in s1: running, dashql agent catalog\nTurn of claude (opus, high) in s1: queued, waiting for agent..."
     582              :         );
     583            1 :         assert_eq!(
     584            1 :             format_status(&json!({ "port": 1, "clients": [], "turns": [] })),
     585              :             "Agent server on 127.0.0.1:1\nNo DashQL client is connected: open https://dashql.app and turn on agent access"
     586              :         );
     587            1 :     }
     588              : }
        

Generated by: LCOV version 2.0-1