Skip to main content

dsp_cli/actions/vre/
sparql.rs

1//! Actions for `dsp vre sparql query` (plan 035).
2//!
3//! Deliberately does **not** abstract DSP-API (dsp-cli/ADR-0016): the response body
4//! is a store-authored document in a store-negotiated media type, and
5//! dsp-cli does not interpret it. This is the third no-`Renderer` command
6//! (after `dsp docs` and `dsp auth token`, D2) — its output goes to an
7//! injected writer via the `run`/`query` seam, mirroring `set_token.rs`'s
8//! `run`/`run_from_line`/`run_impl` shape (each layer taking already-read
9//! text, never owning IO itself below `run`).
10//!
11//! dsp-api's own failures (D8) are classified inside `src/client/http.rs`
12//! and arrive here as `Err`, already carrying the right `Diagnostic` variant
13//! and exit code — this module must never re-implement that table (D8's
14//! closing paragraph). The only split this module owns is D7's: a `2xx`
15//! relay writes the body to stdout byte-verbatim; anything else is a relayed
16//! store rejection, surfaced as `Diagnostic::ServerError` with the store's
17//! text sanitised and capped (never interpolated raw — the store's bytes are
18//! untrusted prose the moment they leave the byte-exact stdout contract).
19
20use std::io::{self, IsTerminal, Read, Write};
21use std::path::Path;
22
23use crate::cli::SparqlQueryArgs;
24use crate::client::DspClient;
25use crate::config::{AuthCache, Config, resolve_token};
26use crate::diagnostic::Diagnostic;
27
28/// D4's `--accept` resolution: an alias table, `/`-passthrough for a raw
29/// media type, and a usage error for anything else. `None` defaults to
30/// `json` — the store's own no-`Accept` default is XML, which is hostile to
31/// the agent audience this tool exists for (D4).
32///
33/// There is no `none` sentinel (D4, amended 2026-08-07): `reqwest`'s public
34/// API cannot express "send no `Accept` header at all", so the flag was
35/// dropped rather than faked as `*/*` (not equivalent — live-verified,
36/// `*/*` returns JSON here while a genuinely absent `Accept` returns XML).
37/// `--accept xml` is the documented way to ask for the store's own default.
38fn resolve_accept(arg: Option<&str>) -> Result<String, Diagnostic> {
39    let Some(s) = arg else {
40        return Ok("application/sparql-results+json".to_string());
41    };
42
43    if s.is_empty() {
44        return Err(Diagnostic::Usage("--accept must not be empty".into()));
45    }
46
47    // A raw media type is forwarded into a header — reject control characters
48    // and cap the length so an unvalidated argv value can't rely solely on
49    // `http::HeaderValue`'s internal validation (D4's closing warning: without
50    // this check a bad value surfaces as Network/Internal, not the Usage
51    // error D4 promises).
52    if s.contains('/') {
53        // `is_control()`, not `is_ascii_control()`: non-ASCII control scalars
54        // (e.g. U+0085 NEL) would otherwise pass, making D4's promise
55        // conditional on the byte range.
56        if s.chars().any(|c| c.is_control()) {
57            return Err(Diagnostic::Usage("--accept must not contain control characters".into()));
58        }
59        if s.chars().count() > 200 {
60            return Err(Diagnostic::Usage("--accept is too long (max 200 characters)".into()));
61        }
62        return Ok(s.to_string());
63    }
64
65    match s {
66        "json" => Ok("application/sparql-results+json".to_string()),
67        "xml" => Ok("application/sparql-results+xml".to_string()),
68        "csv" => Ok("text/csv".to_string()),
69        "tsv" => Ok("text/tab-separated-values".to_string()),
70        "turtle" => Ok("text/turtle".to_string()),
71        "ntriples" => Ok("application/n-triples".to_string()),
72        "jsonld" => Ok("application/ld+json".to_string()),
73        // Echoing argv: sanitise and cap it like every other server- or
74        // user-supplied string that becomes prose (the convention
75        // `read_query_file` follows for the path). This branch skips the
76        // control-char/length guards above, which only cover the `/` arm, so
77        // without this an `--accept $'\x1b]0;x\x07'` would reach a terminal
78        // verbatim and a multi-megabyte value would be echoed whole.
79        other => Err(Diagnostic::Usage(format!(
80            "unknown --accept alias '{}'; valid aliases: json, xml, csv, tsv, turtle, \
81             ntriples, jsonld — or pass a raw media type containing '/'",
82            crate::util::text::sanitise_and_cap(other)
83        ))),
84    }
85}
86
87/// D5's *precedence* errors only — pure, no IO, no client call. The real
88/// stdin read and the real `--query-file` read both happen in [`run`], which
89/// classifies the three file-read failure modes (D5) before ever calling
90/// this; `file_text` therefore arrives already read, and this function has
91/// no channel for a file-read error by design.
92fn resolve_query(
93    args: &SparqlQueryArgs,
94    stdin_is_tty: bool,
95    stdin_text: Option<String>,
96    file_text: Option<String>,
97) -> Result<String, Diagnostic> {
98    let text = if let Some(q) = &args.query {
99        q.clone()
100    } else if args.query_file.is_some() {
101        // A missing `file_text` here means the caller did not read the file
102        // the flag names — a broken internal invariant, not empty user input.
103        file_text.ok_or_else(|| Diagnostic::Internal("--query-file was given but its contents were not read".into()))?
104    } else if let Some(s) = stdin_text {
105        s
106    } else if stdin_is_tty {
107        return Err(Diagnostic::Usage(
108            "no query given: provide --query <text>, --query-file <path>, or pipe the query \
109             via stdin — dsp-cli will not wait on an interactive terminal"
110                .into(),
111        ));
112    } else {
113        // Should not happen in practice: `run` always attempts a stdin read
114        // when neither flag is given and stdin is not a TTY. Treated as the
115        // same usage error rather than panicking, since this function has no
116        // other way to signal "no source was actually provided".
117        return Err(Diagnostic::Usage(
118            "no query given: provide --query <text>, --query-file <path>, or pipe the query \
119             via stdin"
120                .into(),
121        ));
122    };
123
124    if text.trim().is_empty() {
125        return Err(Diagnostic::Usage("the SPARQL query text must not be empty".into()));
126    }
127
128    Ok(text)
129}
130
131/// Read `--query-file`'s target, classifying all three D5 failure modes as
132/// `Diagnostic::Usage` naming the (sanitised) path and never the file's
133/// bytes. ⚠️ Never a bare `?` on the `std::fs`/`io` calls here — that would
134/// hit the blanket `From<io::Error>` impl and mis-classify as
135/// `Diagnostic::Internal` (`src/diagnostic.rs:67-77`).
136fn read_query_file(path: &str) -> Result<String, Diagnostic> {
137    // D5's sanitise-and-cap requirement reuses the one shared helper (D7),
138    // rather than duplicating `resource.rs`'s inline 80-char pattern.
139    let safe_path = crate::util::text::sanitise_and_cap(path);
140
141    let p = Path::new(path);
142    if p.is_dir() {
143        return Err(Diagnostic::Usage(format!(
144            "--query-file '{safe_path}' is a directory, not a file"
145        )));
146    }
147
148    let bytes =
149        std::fs::read(p).map_err(|e| Diagnostic::Usage(format!("could not read --query-file '{safe_path}': {e}")))?;
150
151    String::from_utf8(bytes).map_err(|_| Diagnostic::Usage(format!("--query-file '{safe_path}' is not valid UTF-8")))
152}
153
154/// Run a raw SPARQL query, owning all real IO: stdin (D5), the
155/// `--query-file` read (D5), `DSP_TOKEN` (D11), and stdout (D3).
156///
157/// Reads stdin only when neither `--query` nor `--query-file` is given, and
158/// never when stdin is a terminal — a hang waiting on an interactive
159/// terminal is a usage error, not a silent block (D5).
160pub fn run(args: &SparqlQueryArgs, cfg: &Config, client: &dyn DspClient) -> Result<(), Diagnostic> {
161    let stdin_is_tty = io::stdin().is_terminal();
162
163    let file_text = match &args.query_file {
164        Some(path) => Some(read_query_file(path)?),
165        None => None,
166    };
167
168    let stdin_text = if args.query.is_none() && args.query_file.is_none() && !stdin_is_tty {
169        let mut buf = String::new();
170        io::stdin()
171            .read_to_string(&mut buf)
172            .map_err(|e| Diagnostic::Usage(format!("could not read query from stdin: {e}")))?;
173        Some(buf)
174    } else {
175        None
176    };
177
178    let env_token = std::env::var("DSP_TOKEN").ok();
179    // Wrapped in `BrokenPipeWriter` so `dsp vre sparql query ... | head` exits
180    // 0 silently instead of surfacing a broken pipe as `Diagnostic::Internal`.
181    let mut out = crate::util::BrokenPipeWriter::new(io::stdout());
182
183    query(
184        args,
185        cfg,
186        client,
187        env_token,
188        None,
189        stdin_is_tty,
190        stdin_text,
191        file_text,
192        &mut out,
193    )
194}
195
196/// The testable action: resolve token (fail fast, D11) → resolve query text
197/// (D5) → resolve `--accept` (D4) → issue the request → D7's status split.
198///
199/// dsp-api's own failures (D8) already returned `Err` with the right
200/// `Diagnostic` variant and exit code from inside `client.sparql_query` —
201/// this function's status split therefore only ever sees `Ok(SparqlResponse)`
202/// and must NOT re-implement any of D8's table (that knowledge stays in
203/// `src/client/`, per dsp-cli/ADR-0001).
204///
205/// - `env_token`/`cache_path`: injectable seams (mirrors `project::run_impl`) so unit tests never
206///   touch the real environment or `~/.config/dsp-cli/`.
207/// - `stdin_is_tty`/`stdin_text`/`file_text`: already-read input, per D5 — this function performs
208///   no IO of its own beyond `out`.
209#[allow(clippy::too_many_arguments)]
210fn query(
211    args: &SparqlQueryArgs,
212    cfg: &Config,
213    client: &dyn DspClient,
214    env_token: Option<String>,
215    cache_path: Option<&Path>,
216    stdin_is_tty: bool,
217    stdin_text: Option<String>,
218    file_text: Option<String>,
219    out: &mut dyn Write,
220) -> Result<(), Diagnostic> {
221    // ── 1. Resolve token, fail fast (D11) — BEFORE any HTTP call ────────────
222    let env_token_would_win = env_token.as_deref().map(str::trim).map(|s| !s.is_empty()).unwrap_or(false);
223
224    let cache_result = match cache_path {
225        Some(p) => AuthCache::load_from(p),
226        None => AuthCache::load(),
227    };
228    let cache = match cache_result {
229        Ok(c) => c,
230        Err(e) if env_token_would_win => {
231            crate::util::warn_auth_cache_load_failed(&e, "DSP_TOKEN is set, falling through to env token");
232            AuthCache::default()
233        }
234        Err(e) => return Err(e),
235    };
236
237    let resolved = resolve_token(env_token, &cache, &cfg.server).ok_or_else(|| {
238        Diagnostic::AuthRequired(
239            "dsp vre sparql query requires a system-administrator token; run `dsp auth login` \
240             or set DSP_TOKEN"
241                .into(),
242        )
243    })?;
244
245    // ── 2. Resolve query text and --accept — pure, no IO, no client call ───
246    let query_text = resolve_query(args, stdin_is_tty, stdin_text, file_text)?;
247    let accept = resolve_accept(args.accept.as_deref())?;
248
249    // ── 3. Issue the request ────────────────────────────────────────────────
250    let resp = client.sparql_query(&cfg.server, &resolved.token, &query_text, &accept, args.timeout)?;
251
252    // ── 4. D7's split — the only decision this action owns ─────────────────
253    if (200..300).contains(&resp.status) {
254        out.write_all(&resp.body)?;
255        out.flush()?;
256        Ok(())
257    } else {
258        // The bounded variant: same sanitise-and-cap, but it slices before
259        // decoding so a multi-MiB store error is not copied twice to print 200
260        // characters. All three body-to-prose sites use this one helper (D7).
261        let sanitised = crate::util::text::sanitise_bytes_for_prose(&resp.body);
262        Err(Diagnostic::ServerError(format!(
263            "the triplestore rejected the query (HTTP {}): {sanitised}",
264            resp.status
265        )))
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use std::cell::RefCell;
272    use std::collections::HashMap;
273
274    use super::*;
275    use crate::client::sparql::SparqlResponse;
276    use crate::model::ProjectRef;
277
278    // ── resolve_accept ───────────────────────────────────────────────────────
279
280    #[test]
281    fn resolve_accept_defaults_to_json() {
282        assert_eq!(resolve_accept(None).unwrap(), "application/sparql-results+json");
283    }
284
285    #[test]
286    fn resolve_accept_every_alias() {
287        assert_eq!(resolve_accept(Some("json")).unwrap(), "application/sparql-results+json");
288        assert_eq!(resolve_accept(Some("xml")).unwrap(), "application/sparql-results+xml");
289        assert_eq!(resolve_accept(Some("csv")).unwrap(), "text/csv");
290        assert_eq!(resolve_accept(Some("tsv")).unwrap(), "text/tab-separated-values");
291        assert_eq!(resolve_accept(Some("turtle")).unwrap(), "text/turtle");
292        assert_eq!(resolve_accept(Some("ntriples")).unwrap(), "application/n-triples");
293        assert_eq!(resolve_accept(Some("jsonld")).unwrap(), "application/ld+json");
294    }
295
296    #[test]
297    fn resolve_accept_raw_media_type_is_forwarded_verbatim() {
298        assert_eq!(
299            resolve_accept(Some("text/csv;q=1, */*;q=0.1")).unwrap(),
300            "text/csv;q=1, */*;q=0.1"
301        );
302    }
303
304    #[test]
305    fn resolve_accept_unknown_alias_is_usage_error() {
306        let err = resolve_accept(Some("jsonn")).unwrap_err();
307        assert!(matches!(err, Diagnostic::Usage(_)));
308    }
309
310    #[test]
311    fn resolve_accept_empty_is_usage_error() {
312        let err = resolve_accept(Some("")).unwrap_err();
313        assert!(matches!(err, Diagnostic::Usage(_)));
314    }
315
316    #[test]
317    fn resolve_accept_control_character_is_usage_error() {
318        let err = resolve_accept(Some("text/csv\r\nX-Evil: 1")).unwrap_err();
319        assert!(matches!(err, Diagnostic::Usage(_)));
320    }
321
322    #[test]
323    fn resolve_accept_overlong_raw_type_is_usage_error() {
324        let long = format!("text/{}", "x".repeat(300));
325        let err = resolve_accept(Some(&long)).unwrap_err();
326        assert!(matches!(err, Diagnostic::Usage(_)));
327    }
328
329    // ── resolve_query ────────────────────────────────────────────────────────
330
331    fn args_with(query: Option<&str>, query_file: Option<&str>) -> SparqlQueryArgs {
332        SparqlQueryArgs {
333            server: Some("https://example.org".to_string()),
334            query: query.map(str::to_string),
335            query_file: query_file.map(str::to_string),
336            accept: None,
337            timeout: 3600,
338        }
339    }
340
341    #[test]
342    fn resolve_query_from_flag() {
343        let args = args_with(Some("SELECT * WHERE { ?s ?p ?o }"), None);
344        let text = resolve_query(&args, false, None, None).unwrap();
345        assert_eq!(text, "SELECT * WHERE { ?s ?p ?o }");
346    }
347
348    #[test]
349    fn resolve_query_from_file_text() {
350        let args = args_with(None, Some("query.rq"));
351        let text = resolve_query(&args, false, None, Some("SELECT * WHERE { ?s ?p ?o }".into())).unwrap();
352        assert_eq!(text, "SELECT * WHERE { ?s ?p ?o }");
353    }
354
355    #[test]
356    fn resolve_query_from_stdin() {
357        let args = args_with(None, None);
358        let text = resolve_query(&args, false, Some("SELECT * WHERE { ?s ?p ?o }".into()), None).unwrap();
359        assert_eq!(text, "SELECT * WHERE { ?s ?p ?o }");
360    }
361
362    #[test]
363    fn resolve_query_tty_with_no_flag_is_usage_error() {
364        let args = args_with(None, None);
365        let err = resolve_query(&args, true, None, None).unwrap_err();
366        assert!(matches!(err, Diagnostic::Usage(_)));
367    }
368
369    #[test]
370    fn resolve_query_empty_is_usage_error() {
371        let args = args_with(Some("   "), None);
372        let err = resolve_query(&args, false, None, None).unwrap_err();
373        assert!(matches!(err, Diagnostic::Usage(_)));
374    }
375
376    // ── MockDspClient ────────────────────────────────────────────────────────
377
378    struct MockDspClient {
379        sparql_query_result: Option<Result<SparqlResponse, Diagnostic>>,
380        sparql_query_calls: RefCell<u32>,
381        sparql_query_accept: RefCell<Option<String>>,
382    }
383
384    impl MockDspClient {
385        fn new() -> Self {
386            Self {
387                sparql_query_result: None,
388                sparql_query_calls: RefCell::new(0),
389                sparql_query_accept: RefCell::new(None),
390            }
391        }
392
393        fn with_sparql_query(mut self, result: Result<SparqlResponse, Diagnostic>) -> Self {
394            self.sparql_query_result = Some(result);
395            self
396        }
397
398        fn calls(&self) -> u32 {
399            *self.sparql_query_calls.borrow()
400        }
401
402        fn accept(&self) -> Option<String> {
403            self.sparql_query_accept.borrow().clone()
404        }
405    }
406
407    impl DspClient for MockDspClient {
408        fn login(
409            &self,
410            _server: &str,
411            _user: &str,
412            _password: &str,
413        ) -> Result<crate::model::LoginResponse, Diagnostic> {
414            unimplemented!("login not used in sparql action tests")
415        }
416
417        fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
418            unimplemented!("resolve_project not used in sparql action tests")
419        }
420
421        fn create_project_dump(
422            &self,
423            _server: &str,
424            _project_iri: &str,
425            _skip_assets: bool,
426            _token: &str,
427        ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
428            unimplemented!("create_project_dump not used in sparql action tests")
429        }
430
431        fn get_project_dump_status(
432            &self,
433            _server: &str,
434            _project_iri: &str,
435            _dump_id: &str,
436            _token: &str,
437        ) -> Result<crate::model::DumpTask, Diagnostic> {
438            unimplemented!("get_project_dump_status not used in sparql action tests")
439        }
440
441        fn download_project_dump(
442            &self,
443            _server: &str,
444            _project_iri: &str,
445            _dump_id: &str,
446            _token: &str,
447            _dest: &mut dyn std::io::Write,
448        ) -> Result<u64, Diagnostic> {
449            unimplemented!("download_project_dump not used in sparql action tests")
450        }
451
452        fn delete_project_dump(
453            &self,
454            _server: &str,
455            _project_iri: &str,
456            _dump_id: &str,
457            _token: &str,
458        ) -> Result<(), Diagnostic> {
459            unimplemented!("delete_project_dump not used in sparql action tests")
460        }
461
462        fn list_projects(&self, _server: &str, _token: Option<&str>) -> Result<Vec<crate::model::Project>, Diagnostic> {
463            unimplemented!("list_projects not used in sparql action tests")
464        }
465
466        fn describe_project(
467            &self,
468            _server: &str,
469            _project: &str,
470            _token: Option<&str>,
471        ) -> Result<crate::model::ProjectDetail, Diagnostic> {
472            unimplemented!("describe_project not used in sparql action tests")
473        }
474
475        fn list_data_models(
476            &self,
477            _server: &str,
478            _project_iri: &str,
479            _token: Option<&str>,
480        ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
481            unimplemented!("list_data_models not used in sparql action tests")
482        }
483
484        fn describe_data_model(
485            &self,
486            _server: &str,
487            _data_model_iri: &str,
488            _token: Option<&str>,
489        ) -> Result<crate::model::DataModelDetail, Diagnostic> {
490            unimplemented!("describe_data_model not used in sparql action tests")
491        }
492
493        fn describe_resource_type(
494            &self,
495            _server: &str,
496            _data_model_iri: &str,
497            _resource_type: &str,
498            _token: Option<&str>,
499        ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
500            unimplemented!("describe_resource_type not used in sparql action tests")
501        }
502
503        fn resource_counts(
504            &self,
505            _server: &str,
506            _project_iri: &str,
507            _token: Option<&str>,
508        ) -> Result<HashMap<String, u64>, Diagnostic> {
509            unimplemented!("resource_counts not used in sparql action tests")
510        }
511
512        fn data_model_structure(
513            &self,
514            _server: &str,
515            _data_model_iri: &str,
516            _token: Option<&str>,
517        ) -> Result<crate::model::DataModelStructure, Diagnostic> {
518            unimplemented!("data_model_structure not used in sparql action tests")
519        }
520
521        fn list_resources(
522            &self,
523            _server: &str,
524            _project_iri: &str,
525            _resource_type_iri: &str,
526            _order_by: Option<&str>,
527            _page: u32,
528            _token: Option<&str>,
529        ) -> Result<crate::model::ResourcePage, Diagnostic> {
530            unimplemented!("list_resources not used in sparql action tests")
531        }
532
533        fn describe_resource(
534            &self,
535            _server: &str,
536            _resource_iri: &str,
537            _token: Option<&str>,
538            _with_values: bool,
539        ) -> Result<crate::model::ResourceDetail, Diagnostic> {
540            unimplemented!("describe_resource not used in sparql action tests")
541        }
542
543        fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
544            unimplemented!("verify_token not used in sparql action tests")
545        }
546
547        fn list_vocabularies(
548            &self,
549            _server: &str,
550            _project_iri: &str,
551            _token: Option<&str>,
552        ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
553            unimplemented!("list_vocabularies not used in sparql action tests")
554        }
555
556        fn describe_vocabulary(
557            &self,
558            _server: &str,
559            _iri: &str,
560            _token: Option<&str>,
561        ) -> Result<crate::model::VocabularyTree, Diagnostic> {
562            unimplemented!("describe_vocabulary not used in sparql action tests")
563        }
564
565        fn sparql_query(
566            &self,
567            _server: &str,
568            _token: &str,
569            _query: &str,
570            accept: &str,
571            _timeout_secs: u64,
572        ) -> Result<SparqlResponse, Diagnostic> {
573            *self.sparql_query_calls.borrow_mut() += 1;
574            *self.sparql_query_accept.borrow_mut() = Some(accept.to_string());
575            self.sparql_query_result
576                .clone()
577                .expect("sparql_query_result must be set when sparql_query is called")
578        }
579    }
580
581    fn cfg() -> Config {
582        Config { server: "https://example.org".to_string() }
583    }
584
585    fn empty_cache_dir() -> tempfile::TempDir {
586        tempfile::tempdir().expect("tempdir")
587    }
588
589    // ── action-level tests ───────────────────────────────────────────────────
590
591    #[test]
592    fn success_writes_exact_bytes_to_out() {
593        let client = MockDspClient::new().with_sparql_query(Ok(SparqlResponse {
594            status: 200,
595            content_type: Some("application/sparql-results+json".to_string()),
596            body: b"{\"results\":{\"bindings\":[]}}".to_vec(),
597        }));
598        let args = args_with(Some("SELECT * WHERE { ?s ?p ?o }"), None);
599        let dir = empty_cache_dir();
600        let cache_path = dir.path().join("auth.toml");
601        let mut out = Vec::new();
602
603        query(
604            &args,
605            &cfg(),
606            &client,
607            Some("a-token".to_string()),
608            Some(&cache_path),
609            false,
610            None,
611            None,
612            &mut out,
613        )
614        .expect("2xx relay must succeed");
615
616        assert_eq!(out, b"{\"results\":{\"bindings\":[]}}");
617        assert_eq!(client.calls(), 1);
618    }
619
620    #[test]
621    fn store_400_maps_to_server_error_and_writes_nothing() {
622        let client = MockDspClient::new().with_sparql_query(Ok(SparqlResponse {
623            status: 400,
624            content_type: Some("text/plain".to_string()),
625            // Includes an ANSI escape: the relay path must strip it before the
626            // text becomes prose on stderr (D7). Asserting only that "Parse
627            // error" survives would not catch an unsanitised relay.
628            body: b"Parse error: \x1b]0;pwned\x07line 1, column 1: nonsense".to_vec(),
629        }));
630        let args = args_with(Some("not a query"), None);
631        let dir = empty_cache_dir();
632        let cache_path = dir.path().join("auth.toml");
633        let mut out = Vec::new();
634
635        let err = query(
636            &args,
637            &cfg(),
638            &client,
639            Some("a-token".to_string()),
640            Some(&cache_path),
641            false,
642            None,
643            None,
644            &mut out,
645        )
646        .expect_err("a store 400 must be Err");
647
648        match err {
649            Diagnostic::ServerError(msg) => {
650                assert!(msg.contains("Parse error"), "message must contain the store's text: {msg}");
651                assert!(
652                    !msg.contains('\u{1b}') && !msg.contains('\u{7}'),
653                    "the relay path must strip control characters (D7): {msg:?}"
654                );
655            }
656            other => panic!("expected ServerError, got: {other:?}"),
657        }
658        assert!(out.is_empty(), "nothing must be written to stdout on a relayed rejection");
659    }
660
661    #[test]
662    fn no_token_is_auth_required_and_client_is_never_called() {
663        let client = MockDspClient::new();
664        let args = args_with(Some("SELECT * WHERE { ?s ?p ?o }"), None);
665        let dir = empty_cache_dir();
666        let cache_path = dir.path().join("auth.toml");
667        let mut out = Vec::new();
668
669        let err = query(&args, &cfg(), &client, None, Some(&cache_path), false, None, None, &mut out)
670            .expect_err("no token must be Err");
671
672        assert!(matches!(err, Diagnostic::AuthRequired(_)));
673        assert_eq!(client.calls(), 0, "the client must never be called without a token");
674        assert!(out.is_empty());
675    }
676
677    #[test]
678    fn accept_alias_reaches_the_client_as_its_media_type() {
679        let client = MockDspClient::new().with_sparql_query(Ok(SparqlResponse {
680            status: 200,
681            content_type: Some("text/csv".to_string()),
682            body: b"s,p,o\n".to_vec(),
683        }));
684        let mut args = args_with(Some("SELECT * WHERE { ?s ?p ?o }"), None);
685        args.accept = Some("csv".to_string());
686        let dir = empty_cache_dir();
687        let cache_path = dir.path().join("auth.toml");
688        let mut out = Vec::new();
689
690        query(
691            &args,
692            &cfg(),
693            &client,
694            Some("a-token".to_string()),
695            Some(&cache_path),
696            false,
697            None,
698            None,
699            &mut out,
700        )
701        .expect("2xx relay must succeed");
702
703        assert_eq!(client.accept(), Some("text/csv".to_string()));
704    }
705}