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 : }
|