Skip to main content

grr_cli/commands/
ask.rs

1//! `grr ask` — the natural-language entry point, powered by System One
2//! models (TypeSafe's Jev by default; any provider speaking the same
3//! contract works by config — endpoint, model and key are all config).
4//!
5//! The pattern is hierarchical route-and-fill: code selects the candidate
6//! sets from the embedded Discovery index, a judgment picks the intended
7//! one, then typed parameters are filled — and the request is assembled
8//! through the same shared call path as `grr api`:
9//!
10//! ```text
11//! grr ask "show my unread messages"                  # plan (the default output)
12//! grr ask "show my unread messages" --run            # execute
13//! grr ask "..." --service gmail                      # skip the service judgment
14//! grr ask "..." --method gmail.users.messages.list   # skip both, straight to params
15//! ```
16//!
17//! Three sequential judgments, each one small HTTP request:
18//!
19//! 1. **Service** — a Choice over the 14 indexed services (+ `none`),
20//!    criteria = what each service covers, derived from the index.
21//! 2. **Method** — a Choice over the chosen service's methods (+ `none`),
22//!    criteria = each method's description. Depends on 1: the answer
23//!    narrows the candidate set, so the two cannot share one request.
24//! 3. **Parameters** — one Choice per required parameter, all in a single
25//!    request: the questions map IS the object with one field per required
26//!    param, and the aggregated answers become the values. Options are the
27//!    param's enum values when it declares them, else verbatim spans found
28//!    in the request (plus the universal `me` alias for user-id params) —
29//!    the model can only select among code-supplied candidates, never
30//!    invent a value.
31//!
32//! Planning is the default and the plan IS the output: `--run` executes
33//! through the shared call path. A low-confidence method choice
34//! (configurable, default 0.6) is flagged, not blocked: the model supplies
35//! the probability, code owns the behavior.
36
37use crate::commands::api::{CallOptions, RequestPlan, call_method, plan_request};
38use crate::commands::safety::SafetyProfile;
39use crate::core::auth::GoogleAuth;
40use crate::core::config::GrrConfig;
41use crate::discovery::{self, Method, Parameter, Service};
42use crate::output::{OutputFormat, print_output};
43use anyhow::{Context, Result, bail};
44use clap::Args;
45use serde_json::{Value, json};
46
47/// The escape hatch on every selection: "none of the candidates fits".
48const NONE: &str = "none";
49
50/// A Choice accepts a maximum of 255 options — the documented API limit
51/// the option guards keep a future index honest against.
52const MAX_CHOICE_OPTIONS: usize = 255;
53
54#[derive(Args, Debug)]
55pub struct AskArgs {
56    /// The request in plain words, e.g. "show my unread messages"
57    pub request: String,
58
59    /// Execute the resolved request instead of printing the plan. Planning
60    /// is the default: the plan IS the output.
61    #[arg(long)]
62    pub run: bool,
63
64    /// Skip the service judgment: the service is already known (gmail,
65    /// calendar, drive, people, chat, forms, tasks, docs, sheets, slides,
66    /// script, analyticsadmin, analyticsdata, searchconsole)
67    #[arg(long, value_name = "SERVICE")]
68    pub service: Option<String>,
69
70    /// Skip both routing judgments: resolve this method id directly
71    /// (e.g. gmail.users.messages.list) and go straight to parameter fill
72    #[arg(long, value_name = "ID")]
73    pub method: Option<String>,
74
75    /// Print the plan without sending anything (the default); kept as an
76    /// explicit flag for readability, mutually exclusive with --run
77    #[arg(long, conflicts_with = "run")]
78    pub dry_run: bool,
79
80    /// Output format
81    #[arg(short, long, value_enum, default_value = "json")]
82    pub format: OutputFormat,
83}
84
85/// The resolved System One provider: endpoint, model and key from config
86/// (config file + env, defaults filled). Its own plain HTTP client, not
87/// the shared grr one: that client speaks HTTP/3 prior-knowledge, which
88/// only works against Google frontends — a System One provider may be
89/// HTTP/1.1-only, or local (a mock in tests).
90struct SystemOne {
91    client: reqwest::Client,
92    endpoint: String,
93    model: String,
94    api_key: String,
95}
96
97fn resolve_systemone(config: &GrrConfig) -> Result<SystemOne> {
98    let s1 = &config.systemone;
99    let api_key = s1
100        .bearer_key()
101        .ok_or_else(|| anyhow::anyhow!(crate::core::config::NO_API_KEY_HELP))?;
102    Ok(SystemOne {
103        client: reqwest::Client::builder()
104            .timeout(std::time::Duration::from_secs(30))
105            .build()
106            .map_err(|e| anyhow::anyhow!("failed to build the System One HTTP client: {e}"))?,
107        endpoint: s1.endpoint_or_default().to_owned(),
108        model: s1.model_or_default().to_owned(),
109        api_key: api_key.to_owned(),
110    })
111}
112
113impl SystemOne {
114    /// The endpoint with any query string stripped: a custom provider
115    /// might carry a key in the URL, and error messages must never echo it.
116    fn masked_endpoint(&self) -> String {
117        match self.endpoint.split_once('?') {
118            Some((before, _)) => format!("{before}?…"),
119            None => self.endpoint.clone(),
120        }
121    }
122}
123
124/// One System One request: POST `state` + typed `questions`, back the
125/// `answers` map. The API key rides in the Authorization header only —
126/// never in a body, never in an error message, never in the plan output.
127async fn judge(s1: &SystemOne, state: Value, questions: Value) -> Result<Value> {
128    let body = json!({
129        "state": state,
130        "model": s1.model,
131        "questions": questions,
132    });
133    let response = s1
134        .client
135        .post(&s1.endpoint)
136        .bearer_auth(&s1.api_key)
137        .json(&body)
138        .send()
139        .await
140        .map_err(|e| {
141            anyhow::anyhow!("System One request to {} failed: {e}", s1.masked_endpoint())
142        })?;
143
144    let status = response.status();
145    let text = response.text().await.unwrap_or_default();
146    if !status.is_success() {
147        if status.as_u16() == 429 {
148            bail!(
149                "System One endpoint {} is rate limited (HTTP 429); retry shortly",
150                s1.masked_endpoint()
151            );
152        }
153        bail!(
154            "System One endpoint {} returned HTTP {}.\n{}",
155            s1.masked_endpoint(),
156            status.as_u16(),
157            text
158        );
159    }
160
161    let parsed: Value = serde_json::from_str(&text).with_context(|| {
162        format!(
163            "System One response from {} is not JSON",
164            s1.masked_endpoint()
165        )
166    })?;
167    parsed
168        .get("answers")
169        .cloned()
170        .ok_or_else(|| anyhow::anyhow!("System One response has no `answers` object:\n{text}"))
171}
172
173/// Extract the Choice answer for one question id: the selected option and
174/// its confidence. A provider answering a Choice question with a non-choice
175/// type does not speak the contract — say so rather than misreading it.
176/// A missing confidence is treated as maximally uncertain (0.0), which
177/// flags the answer — the safe default for a contract violation.
178fn choice_answer<'a>(answers: &'a Value, id: &str) -> Result<(&'a str, f64)> {
179    let answer = answers
180        .get(id)
181        .ok_or_else(|| anyhow::anyhow!("System One returned no answer for `{id}`"))?;
182    if let Some(kind) = answer.get("type").and_then(Value::as_str)
183        && kind != "choice"
184    {
185        bail!(
186            "System One answered question `{id}` with type `{kind}`, not `choice` — \
187             the provider must speak the System One contract"
188        );
189    }
190    let choice = answer
191        .get("choice")
192        .and_then(Value::as_str)
193        .ok_or_else(|| {
194            anyhow::anyhow!("System One's answer for `{id}` has no `choice` value:\n{answer}")
195        })?;
196    let confidence = answer
197        .get("confidence")
198        .and_then(Value::as_f64)
199        .unwrap_or(0.0);
200    Ok((choice, confidence))
201}
202
203/// Unique resource-path segments across a service's method ids
204/// (`users.messages.list` -> `users`, `messages`), in first-seen order,
205/// capped to keep the service-Choice state small.
206fn resource_segments(service: &Service) -> Vec<String> {
207    let mut out: Vec<String> = Vec::new();
208    for method in &service.methods {
209        let Some(cut) = method.id.rfind('.') else {
210            continue;
211        };
212        for segment in method.id[..cut].split('.') {
213            if !out.iter().any(|s| s == segment) {
214                out.push(segment.to_owned());
215            }
216        }
217    }
218    out.truncate(14);
219    out
220}
221
222/// One service's "what it covers" rubric for the service Choice, derived
223/// from the index: title, version, method count and the resource paths
224/// its methods walk. Deterministic and compact — the criteria carries the
225/// semantics, the state's catalog carries the same data as fields.
226fn service_rubric(service: &Service) -> String {
227    let title = if service.title.is_empty() {
228        &service.name
229    } else {
230        &service.title
231    };
232    format!(
233        "{} ({}): {} methods over {}",
234        title,
235        service.version,
236        service.methods.len(),
237        resource_segments(service).join(", ")
238    )
239}
240
241fn service_by_name(name: &str) -> Result<&'static Service> {
242    discovery::services().get(name).ok_or_else(|| {
243        let known: Vec<&str> = discovery::services().keys().copied().collect();
244        anyhow::anyhow!("unknown service `{name}`; known: {}", known.join(", "))
245    })
246}
247
248/// Judgment 1: a Choice over the 14 indexed services (+ `none`).
249async fn choose_service(s1: &SystemOne, request: &str) -> Result<(&'static Service, f64)> {
250    // A Choice accepts at most 255 options; 14 services + none leaves
251    // plenty of headroom, but the guard keeps a future index honest.
252    if discovery::services().len() >= MAX_CHOICE_OPTIONS {
253        bail!(
254            "{} services exceed the {MAX_CHOICE_OPTIONS}-option Choice limit",
255            discovery::services().len()
256        );
257    }
258
259    let mut criteria = serde_json::Map::new();
260    for (name, service) in discovery::services() {
261        criteria.insert((*name).to_owned(), json!(service_rubric(service)));
262    }
263    criteria.insert(
264        NONE.to_owned(),
265        json!("None of these services covers what the request needs."),
266    );
267
268    let catalog: Vec<Value> = discovery::services()
269        .values()
270        .map(|service| {
271            json!({
272                "name": service.name,
273                "methods": service.methods.len(),
274                "resources": resource_segments(service),
275            })
276        })
277        .collect();
278
279    let state = json!({
280        "request": request,
281        "catalog": catalog,
282    });
283    let questions = json!({
284        "service": {
285            "type": "choice",
286            "instructions": {
287                "question": "Which service should handle this request?",
288                "focus": "Pick the one service whose methods cover what the request needs. Choose `none` when no service does.",
289            },
290            "criteria": criteria,
291        },
292    });
293
294    let answers = judge(s1, state, questions).await?;
295    let (choice, confidence) = choice_answer(&answers, "service")?;
296    if choice == NONE {
297        bail!(
298            "none of the indexed services covers `{request}`.\n\
299             Run `grr api list` to see what is available, or rephrase the request in terms of one of them."
300        );
301    }
302    let service = discovery::services().get(choice).ok_or_else(|| {
303        anyhow::anyhow!("System One chose `{choice}`, which is not an indexed service")
304    })?;
305    Ok((service, confidence))
306}
307
308/// Judgment 2: a Choice over the chosen service's methods (+ `none`).
309/// Sequential after judgment 1 by necessity — the answer narrows the
310/// candidate set — never merged into one request.
311async fn choose_method<'a>(
312    s1: &SystemOne,
313    request: &str,
314    service: &'a Service,
315) -> Result<(&'a Method, f64)> {
316    // A Choice accepts at most 255 options: the biggest service in the
317    // current index is well under that, but the guard keeps a future
318    // index honest instead of silently truncating candidates.
319    if service.methods.len() >= MAX_CHOICE_OPTIONS {
320        bail!(
321            "{} has {} methods, exceeding the {MAX_CHOICE_OPTIONS}-option Choice limit",
322            service.name,
323            service.methods.len()
324        );
325    }
326
327    let mut criteria = serde_json::Map::new();
328    for method in &service.methods {
329        criteria.insert(
330            method.id.clone(),
331            json!(method_summary(&method.description)),
332        );
333    }
334    criteria.insert(
335        NONE.to_owned(),
336        json!("None of these methods does what the request needs."),
337    );
338
339    let state = json!({
340        "request": request,
341        "service": {
342            "name": service.name,
343            "title": service.title,
344            "version": service.version,
345            "methods": service.methods.len(),
346        },
347        "step": "The service was already chosen. Now pick the one method that does what the request needs. Choose `none` when none does.",
348    });
349    let questions = json!({
350        "method": {
351            "type": "choice",
352            "instructions": {
353                "question": format!(
354                    "Which method of the {} service should handle this request?",
355                    service.name
356                ),
357                "focus": "Match the request's intent to one method's description.",
358            },
359            "criteria": criteria,
360        },
361    });
362
363    let answers = judge(s1, state, questions).await?;
364    let (choice, confidence) = choice_answer(&answers, "method")?;
365    if choice == NONE {
366        bail!(
367            "none of {}'s {} methods covers `{request}`.\n\
368             Run `grr api list {}` to see what is available, or rephrase the request.",
369            service.name,
370            service.methods.len(),
371            service.name
372        );
373    }
374    let method = service.method(choice).ok_or_else(|| {
375        anyhow::anyhow!(
376            "System One chose `{choice}`, which is not a method of `{}`",
377            service.name
378        )
379    })?;
380    Ok((method, confidence))
381}
382
383fn method_summary(description: &str) -> String {
384    description.chars().take(200).collect()
385}
386
387/// Is this a user-id parameter that the universal `me` alias fills?
388/// Gmail and Calendar write it `userId`, People `userKey`.
389fn is_user_param(name: &str) -> bool {
390    matches!(
391        name.to_ascii_lowercase().as_str(),
392        "userid" | "user" | "userkey"
393    )
394}
395
396/// Candidate parameter values found in the request text: quoted spans
397/// first, then whitespace tokens carrying a digit or an `@` (ids, emails,
398/// amounts). Deduped, capped — the model can only select among these, so
399/// recall matters more than precision, but every option is a value the
400/// code supplied.
401fn value_candidates(request: &str) -> Vec<String> {
402    let mut out: Vec<String> = Vec::new();
403    let mut rest = request;
404    while let Some(start) = rest.find(['\'', '"']) {
405        let quote = &rest[start..start + 1];
406        let Some(end) = rest[start + 1..].find(quote) else {
407            break;
408        };
409        push_candidate(&mut out, &rest[start + 1..start + 1 + end]);
410        rest = &rest[start + 1 + end + 1..];
411    }
412    for token in request.split_whitespace() {
413        let cleaned = token.trim_matches(|c: char| ".,;:!?()[]{}\"'".contains(c));
414        if cleaned.chars().any(|c| c.is_ascii_digit() || c == '@') {
415            push_candidate(&mut out, cleaned);
416        }
417    }
418    out.truncate(8);
419    out
420}
421
422fn push_candidate(out: &mut Vec<String>, value: &str) {
423    let value = value.trim();
424    if value.is_empty()
425        || value.len() > 120
426        || value == NONE
427        || value == "me"
428        || out.iter().any(|c| c == value)
429    {
430        return;
431    }
432    out.push(value.to_owned());
433}
434
435/// One per-param question: structured instructions (the method context +
436/// the parameter's name/kind/description) and a criteria object mapping
437/// each option to a rubric. Options are the parameter's enum values when
438/// it declares them — a closed set, so the answer is always a value the
439/// method accepts — else the candidate spans found in the request, plus
440/// the authenticated-user alias for user-id parameters, plus `none`.
441fn param_question(param: &Parameter, method_description: &str, candidates: &[String]) -> Value {
442    let mut criteria = serde_json::Map::new();
443    match &param.enum_values {
444        Some(values) => {
445            for value in values {
446                criteria.insert(value.clone(), Value::Null);
447            }
448        }
449        None => {
450            for candidate in candidates {
451                criteria.insert(candidate.clone(), Value::Null);
452            }
453            if is_user_param(&param.name) {
454                criteria.insert(
455                    "me".to_owned(),
456                    json!("The authenticated user — the universal alias for this parameter"),
457                );
458            }
459        }
460    }
461    criteria.insert(
462        NONE.to_owned(),
463        json!(format!(
464            "No value in the request fits the `{}` parameter.",
465            param.name
466        )),
467    );
468
469    json!({
470        "type": "choice",
471        "instructions": {
472            "question": format!("What value should the `{}` parameter take?", param.name),
473            "method": method_description,
474            "parameter": {
475                "name": param.name,
476                "kind": param.kind,
477                "description": param.description,
478                "required": true,
479            },
480        },
481        "criteria": criteria,
482    })
483}
484
485/// The judgment-3 questions map: one Choice question per required
486/// parameter, all in one request. Built once per flow, testable in
487/// isolation.
488fn param_fill_questions(
489    method_description: &str,
490    method: &Method,
491    candidates: &[String],
492) -> serde_json::Map<String, Value> {
493    let mut questions = serde_json::Map::new();
494    for required in &method.required {
495        let Some(param) = method.parameter(required) else {
496            continue;
497        };
498        questions.insert(
499            param.name.clone(),
500            param_question(param, method_description, candidates),
501        );
502    }
503    questions
504}
505
506/// Keep the parameter's real JSON type: a required integer/number
507/// parameter whose choice parses as a number is inserted as a number, not
508/// the string "10" — the same coercion philosophy as `parse_params`.
509fn coerce_value(choice: &str, param: &Parameter) -> Value {
510    match param.kind.as_str() {
511        "integer" => choice
512            .parse::<i64>()
513            .map(|n| json!(n))
514            .unwrap_or(json!(choice)),
515        "number" => choice
516            .parse::<f64>()
517            .map(|n| json!(n))
518            .unwrap_or(json!(choice)),
519        "boolean" => choice
520            .parse::<bool>()
521            .map(|b| json!(b))
522            .unwrap_or(json!(choice)),
523        _ => json!(choice),
524    }
525}
526
527/// Judgment 3: fill the chosen method's required parameters. One request,
528/// one Choice question per required parameter — the questions map IS the
529/// object with one field per required param, and the aggregated answers
530/// become the parameter values.
531///
532/// The documented System One contract returns choice/noul/score answers —
533/// there is no object-returning question type — so the object answer is
534/// realized as parallel per-param questions aggregated in code. Every
535/// value is type-safe by construction: the model can only select among
536/// code-supplied candidates, never invent.
537async fn fill_params(
538    s1: &SystemOne,
539    request: &str,
540    service: &Service,
541    method: &Method,
542) -> Result<serde_json::Map<String, Value>> {
543    let candidates = value_candidates(request);
544    let questions = param_fill_questions(&method.description, method, &candidates);
545    if questions.is_empty() {
546        return Ok(serde_json::Map::new());
547    }
548
549    let state = json!({
550        "request": request,
551        "method": {
552            "id": format!("{}.{}", service.name, method.id),
553            "description": method.description,
554            "http": method.http_method,
555        },
556        "step": "The method was already chosen. Now fill each required parameter from the request.",
557    });
558
559    let answers = judge(s1, state, Value::Object(questions)).await?;
560
561    let mut params = serde_json::Map::new();
562    for required in &method.required {
563        let Some(param) = method.parameter(required) else {
564            continue;
565        };
566        let (choice, _) = choice_answer(&answers, &param.name)?;
567        if choice == NONE {
568            let full_id = format!("{}.{}", service.name, method.id);
569            bail!(
570                "could not fill the required parameter `{}` for {full_id} from the request.\n\
571                 Run `grr api describe {full_id}` to see its parameters, or supply them directly:\n\
572                 `grr api call {full_id} --params '{{\"{}\": \"...\"}}'`",
573                param.name,
574                param.name
575            );
576        }
577        params.insert(param.name.clone(), coerce_value(choice, param));
578    }
579    Ok(params)
580}
581
582/// Everything `grr ask` resolved: the routed method, the filled parameters
583/// and the built request plan. `confidence` is the method Choice's (None
584/// when --method skipped the choices); `service_confidence` the service
585/// Choice's (None when --service ran).
586#[derive(Debug)]
587struct AskResolution {
588    service: &'static Service,
589    method: &'static Method,
590    params: serde_json::Map<String, Value>,
591    confidence: Option<f64>,
592    service_confidence: Option<f64>,
593    model: String,
594    provider: String,
595    plan: RequestPlan,
596    low_confidence: bool,
597}
598
599/// The full flow: resolve the provider, run the judgments the flags don't
600/// skip, gate on the safety profile, fill the parameters and plan the
601/// request. Pure with respect to Google — no credential needed, no call
602/// happens until `--run`.
603async fn resolve_ask(
604    config: &GrrConfig,
605    args: &AskArgs,
606    profile: &SafetyProfile,
607) -> Result<AskResolution> {
608    let s1 = resolve_systemone(config)?;
609    let request = args.request.trim();
610
611    let (service, method, method_confidence, service_confidence) =
612        if let Some(id) = args.method.as_deref() {
613            if let Some(wanted) = args.service.as_deref() {
614                let actual = id.split('.').next().unwrap_or(id);
615                if !actual.eq_ignore_ascii_case(wanted) {
616                    bail!(
617                        "--method {id} belongs to service `{actual}`, but --service {wanted} \
618                         was given. Drop --service or use a matching method id."
619                    );
620                }
621            }
622            let (service, method) = discovery::resolve(id).map_err(|message| {
623                anyhow::anyhow!(
624                    "{message}\n\nRun `grr api list {}` to see what is available.",
625                    id.split('.').next().unwrap_or("gmail")
626                )
627            })?;
628            (service, method, None, None)
629        } else if let Some(name) = args.service.as_deref() {
630            let service = service_by_name(name)?;
631            let (method, confidence) = choose_method(&s1, request, service).await?;
632            (service, method, Some(confidence), None)
633        } else {
634            let (service, s_confidence) = choose_service(&s1, request).await?;
635            let (method, m_confidence) = choose_method(&s1, request, service).await?;
636            (service, method, Some(m_confidence), Some(s_confidence))
637        };
638
639    // The safety gate runs as soon as the method is known — before the
640    // parameter-fill judgment and before anything is built or sent. The
641    // same gate `grr api call` honors (src/commands/safety.rs).
642    if let Err(message) = profile.check(&service.name, method) {
643        bail!("{message}");
644    }
645
646    let params = if method.required.is_empty() {
647        serde_json::Map::new()
648    } else {
649        fill_params(&s1, request, service, method).await?
650    };
651
652    let plan = plan_request(service, method, &params, None, None, &[])?;
653
654    let threshold = config.systemone.threshold();
655    let low_confidence = method_confidence.is_some_and(|c| c < threshold)
656        || service_confidence.is_some_and(|c| c < threshold);
657
658    Ok(AskResolution {
659        service,
660        method,
661        params,
662        confidence: method_confidence,
663        service_confidence,
664        model: s1.model,
665        provider: s1.endpoint,
666        plan,
667        low_confidence,
668    })
669}
670
671/// The plan JSON — the default output of `grr ask`. The API key never
672/// appears: it rides in the Authorization header only.
673fn ask_plan_payload(request: &str, resolution: &AskResolution) -> Value {
674    json!({
675        "request": request,
676        "method": format!("{}.{}", resolution.service.name, resolution.method.id),
677        "service": resolution.service.name,
678        "httpMethod": resolution.plan.verb,
679        "params": resolution.params,
680        "url": resolution.plan.url,
681        "body": resolution.plan.body,
682        "scopes": resolution.method.scopes,
683        "leastPrivilegeScope": resolution.method.least_privilege_scope(),
684        "confidence": resolution.confidence,
685        "serviceConfidence": resolution.service_confidence,
686        "model": resolution.model,
687        "provider": resolution.provider,
688        "dryRun": true,
689        "lowConfidence": resolution.low_confidence,
690        "nextStep": "rerun with --run to execute this request",
691    })
692}
693
694/// THE `grr ask` handler — the wire-up target for cli.rs.
695///
696/// Planning is the default: the resolved plan (method, params, URL,
697/// scopes) is the output, nothing is sent, and no Google credential is
698/// needed. `--run` executes the plan through the shared call path
699/// (`call_method`) and prints the response body. Low confidence is
700/// flagged on stderr, never silently swallowed and never blocking.
701pub async fn handle_ask(
702    config: &GrrConfig,
703    auth: Option<&GoogleAuth>,
704    args: AskArgs,
705    profile: &SafetyProfile,
706) -> Result<()> {
707    let resolution = resolve_ask(config, &args, profile).await?;
708    let format = args.format;
709    let full_id = format!("{}.{}", resolution.service.name, resolution.method.id);
710
711    if resolution.low_confidence {
712        let confidence = resolution
713            .confidence
714            .or(resolution.service_confidence)
715            .unwrap_or(0.0);
716        eprintln!(
717            "warning: low confidence ({confidence:.2} < {:.2}); verify the resolved method before trusting it",
718            config.systemone.threshold()
719        );
720    }
721
722    if args.dry_run || !args.run {
723        eprintln!(
724            "note: resolved {full_id} ({} {}); plan below — rerun with --run to execute",
725            resolution.plan.verb, resolution.plan.url
726        );
727        print_output(&ask_plan_payload(&args.request, &resolution), format)?;
728        return Ok(());
729    }
730
731    let Some(auth) = auth else {
732        bail!(
733            "--run needs a Google credential, but none was built. Re-run without --run to print the plan only."
734        );
735    };
736    eprintln!(
737        "note: resolved {full_id} ({} {}); running",
738        resolution.plan.verb, resolution.plan.url
739    );
740    let payload = call_method(
741        Some(auth),
742        resolution.service,
743        resolution.method,
744        resolution.params,
745        CallOptions {
746            dry_run: false,
747            ..Default::default()
748        },
749    )
750    .await?;
751    print_output(&payload, format)?;
752    Ok(())
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use crate::core::auth::TokenStorage;
759    use wiremock::Match;
760    use wiremock::matchers::method;
761    use wiremock::{Mock, MockServer, Request, ResponseTemplate};
762
763    /// Distinguish the three judgments by the questions map they carry:
764    /// the service Choice, the method Choice, the per-param fills.
765    struct QuestionKey(&'static str);
766
767    impl Match for QuestionKey {
768        fn matches(&self, request: &Request) -> bool {
769            serde_json::from_slice::<Value>(&request.body)
770                .ok()
771                .and_then(|body| {
772                    body.get("questions")
773                        .and_then(Value::as_object)
774                        .map(|questions| questions.contains_key(self.0))
775                })
776                .unwrap_or(false)
777        }
778    }
779
780    fn choice_response(id: &str, choice: &str, confidence: f64) -> ResponseTemplate {
781        ResponseTemplate::new(200).set_body_json(json!({
782            "model": "jev-1.13.0",
783            "answers": {
784                id: {
785                    "type": "choice",
786                    "choice": choice,
787                    "probabilities": { choice: confidence },
788                    "confidence": confidence,
789                }
790            },
791            "usage": {},
792        }))
793    }
794
795    async fn test_auth() -> GoogleAuth {
796        let config = crate::core::config::OAuthConfig {
797            client_id: "test-id.apps.googleusercontent.com".into(),
798            client_secret: None,
799        };
800        let storage = TokenStorage {
801            access_token: "test-token".into(),
802            refresh_token: None,
803            expires_at: u64::MAX,
804            token_type: "Bearer".into(),
805            scope: String::new(),
806        };
807        GoogleAuth::with_token(config, storage).await.unwrap()
808    }
809
810    fn test_config(endpoint: &str, model: &str) -> GrrConfig {
811        GrrConfig {
812            oauth: crate::core::config::OAuthConfig {
813                client_id: "test-id.apps.googleusercontent.com".into(),
814                client_secret: None,
815            },
816            systemone: crate::core::config::SystemOneConfig {
817                endpoint: endpoint.into(),
818                model: model.into(),
819                api_key: Some("test-systemone-key".into()),
820                confidence_threshold: None,
821            },
822        }
823    }
824
825    fn plan_args(request: &str) -> AskArgs {
826        AskArgs {
827            request: request.into(),
828            run: false,
829            service: None,
830            method: None,
831            dry_run: false,
832            format: OutputFormat::Json,
833        }
834    }
835
836    #[tokio::test]
837    async fn the_hierarchical_flow_resolves_the_right_method_and_params() {
838        let server = MockServer::start().await;
839        Mock::given(method("POST"))
840            .and(QuestionKey("service"))
841            .respond_with(choice_response("service", "gmail", 0.9))
842            .mount(&server)
843            .await;
844        Mock::given(method("POST"))
845            .and(QuestionKey("method"))
846            .respond_with(choice_response("method", "users.threads.get", 0.81))
847            .mount(&server)
848            .await;
849        // Judgment 3 carries BOTH required params in one request; the
850        // answers come back keyed by the same ids.
851        Mock::given(method("POST"))
852            .and(QuestionKey("userId"))
853            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
854                "model": "jev-1.13.0",
855                "answers": {
856                    "userId": {
857                        "type": "choice",
858                        "choice": "me",
859                        "probabilities": { "me": 0.95 },
860                        "confidence": 0.95,
861                    },
862                    "id": {
863                        "type": "choice",
864                        "choice": "abc123",
865                        "probabilities": { "abc123": 0.88 },
866                        "confidence": 0.88,
867                    },
868                },
869                "usage": {},
870            })))
871            .mount(&server)
872            .await;
873
874        let args = plan_args("show the message thread abc123");
875        let resolution = resolve_ask(
876            &test_config(&server.uri(), ""),
877            &args,
878            &SafetyProfile::PERMISSIVE,
879        )
880        .await
881        .unwrap();
882
883        assert_eq!(resolution.service.name, "gmail");
884        assert_eq!(resolution.method.id, "users.threads.get");
885        assert_eq!(resolution.params.get("userId").unwrap(), "me");
886        assert_eq!(resolution.params.get("id").unwrap(), "abc123");
887        assert_eq!(resolution.confidence, Some(0.81));
888        assert_eq!(resolution.service_confidence, Some(0.9));
889        assert_eq!(resolution.plan.verb, "GET");
890        assert_eq!(
891            resolution.plan.url,
892            "https://gmail.googleapis.com/gmail/v1/users/me/threads/abc123"
893        );
894
895        // Three judgments = three small HTTP requests, sequential.
896        assert_eq!(server.received_requests().await.unwrap().len(), 3);
897    }
898
899    #[tokio::test]
900    async fn the_no_match_outcome_is_actionable() {
901        let server = MockServer::start().await;
902        Mock::given(method("POST"))
903            .and(QuestionKey("service"))
904            .respond_with(choice_response("service", "none", 0.7))
905            .mount(&server)
906            .await;
907
908        let args = plan_args("book a flight to the moon");
909        let err = resolve_ask(
910            &test_config(&server.uri(), ""),
911            &args,
912            &SafetyProfile::PERMISSIVE,
913        )
914        .await
915        .unwrap_err()
916        .to_string();
917        assert!(err.contains("none of the indexed services"), "{err}");
918        assert!(err.contains("grr api list"), "{err}");
919    }
920
921    #[tokio::test]
922    async fn a_method_no_match_is_actionable_too() {
923        let server = MockServer::start().await;
924        Mock::given(method("POST"))
925            .and(QuestionKey("method"))
926            .respond_with(choice_response("method", "none", 0.7))
927            .mount(&server)
928            .await;
929
930        let mut args = plan_args("book a flight to the moon");
931        args.service = Some("gmail".into());
932        let err = resolve_ask(
933            &test_config(&server.uri(), ""),
934            &args,
935            &SafetyProfile::PERMISSIVE,
936        )
937        .await
938        .unwrap_err()
939        .to_string();
940        assert!(err.contains("none of gmail's"), "{err}");
941        assert!(err.contains("grr api list gmail"), "{err}");
942    }
943
944    #[tokio::test]
945    async fn low_confidence_is_flagged_in_the_resolution() {
946        let server = MockServer::start().await;
947        Mock::given(method("POST"))
948            .and(QuestionKey("method"))
949            .respond_with(choice_response("method", "users.threads.get", 0.42))
950            .mount(&server)
951            .await;
952        Mock::given(method("POST"))
953            .and(QuestionKey("userId"))
954            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
955                "model": "jev-1.13.0",
956                "answers": {
957                    "userId": {
958                        "type": "choice",
959                        "choice": "me",
960                        "probabilities": { "me": 0.95 },
961                        "confidence": 0.95,
962                    },
963                    "id": {
964                        "type": "choice",
965                        "choice": "abc123",
966                        "probabilities": { "abc123": 0.9 },
967                        "confidence": 0.9,
968                    },
969                },
970                "usage": {},
971            })))
972            .mount(&server)
973            .await;
974
975        let mut args = plan_args("show the message thread abc123");
976        args.service = Some("gmail".into());
977        let resolution = resolve_ask(
978            &test_config(&server.uri(), ""),
979            &args,
980            &SafetyProfile::PERMISSIVE,
981        )
982        .await
983        .unwrap();
984        assert!(resolution.low_confidence, "0.42 < 0.6 must flag");
985        assert_eq!(resolution.confidence, Some(0.42));
986
987        let payload = ask_plan_payload(&args.request, &resolution);
988        assert_eq!(payload["lowConfidence"], true);
989        assert_eq!(payload["confidence"], 0.42);
990    }
991
992    #[tokio::test]
993    async fn a_custom_endpoint_and_model_from_config_are_used() {
994        let server = MockServer::start().await;
995        Mock::given(method("POST"))
996            .and(QuestionKey("service"))
997            .respond_with(choice_response("service", "gmail", 0.9))
998            .mount(&server)
999            .await;
1000        Mock::given(method("POST"))
1001            .and(QuestionKey("method"))
1002            .respond_with(choice_response("method", "users.getProfile", 0.9))
1003            .mount(&server)
1004            .await;
1005        Mock::given(method("POST"))
1006            .and(QuestionKey("userId"))
1007            .respond_with(choice_response("userId", "me", 0.95))
1008            .mount(&server)
1009            .await;
1010
1011        let args = plan_args("show my profile");
1012        let resolution = resolve_ask(
1013            &test_config(&server.uri(), "my-jev-fork"),
1014            &args,
1015            &SafetyProfile::PERMISSIVE,
1016        )
1017        .await
1018        .unwrap();
1019
1020        // The wiremock server WAS hit (the mocks matched), which proves the
1021        // configured endpoint was used instead of the TypeSafe default; the
1022        // request bodies carry the configured model.
1023        let requests = server.received_requests().await.unwrap();
1024        assert_eq!(requests.len(), 3);
1025        for request in &requests {
1026            let body: Value = serde_json::from_slice(&request.body).unwrap();
1027            assert_eq!(body["model"], "my-jev-fork");
1028        }
1029        assert_eq!(resolution.model, "my-jev-fork");
1030    }
1031
1032    #[tokio::test]
1033    async fn the_api_key_never_enters_a_body_or_the_plan_output() {
1034        let server = MockServer::start().await;
1035        Mock::given(method("POST"))
1036            .and(QuestionKey("method"))
1037            .respond_with(choice_response("method", "users.getProfile", 0.9))
1038            .mount(&server)
1039            .await;
1040        Mock::given(method("POST"))
1041            .and(QuestionKey("userId"))
1042            .respond_with(choice_response("userId", "me", 0.95))
1043            .mount(&server)
1044            .await;
1045
1046        let mut args = plan_args("show my profile");
1047        args.service = Some("gmail".into());
1048        let resolution = resolve_ask(
1049            &test_config(&server.uri(), ""),
1050            &args,
1051            &SafetyProfile::PERMISSIVE,
1052        )
1053        .await
1054        .unwrap();
1055        let payload = ask_plan_payload(&args.request, &resolution);
1056        let plan_json = serde_json::to_string(&payload).unwrap();
1057        assert!(
1058            !plan_json.contains("test-systemone-key"),
1059            "the key leaked into the plan: {plan_json}"
1060        );
1061
1062        for request in server.received_requests().await.unwrap() {
1063            let body = String::from_utf8(request.body.to_vec()).unwrap();
1064            assert!(
1065                !body.contains("test-systemone-key"),
1066                "the key leaked into a request body"
1067            );
1068            let bearer = request
1069                .headers
1070                .get("authorization")
1071                .and_then(|v| v.to_str().ok())
1072                .unwrap_or("");
1073            assert_eq!(
1074                bearer, "Bearer test-systemone-key",
1075                "the key must ride in the Authorization header"
1076            );
1077        }
1078    }
1079
1080    #[tokio::test]
1081    async fn handle_ask_reports_a_missing_api_key_actionably() {
1082        let mut config = test_config("http://127.0.0.1:1/v1/systemone", "");
1083        config.systemone.api_key = None;
1084        let args = plan_args("show my profile");
1085        let err = handle_ask(&config, None, args, &SafetyProfile::PERMISSIVE)
1086            .await
1087            .unwrap_err()
1088            .to_string();
1089        assert!(err.contains("TYPESAFE_API_KEY"), "{err}");
1090        assert!(err.contains("[systemone]"), "{err}");
1091    }
1092
1093    #[tokio::test]
1094    async fn handle_ask_honors_the_safety_gate_before_running() {
1095        // The gate fires as soon as the method is resolved — no System One
1096        // call, no Google call; the endpoint is a dead port.
1097        let config = test_config("http://127.0.0.1:1/v1/systemone", "");
1098        let mut args = plan_args("send a message");
1099        args.run = true;
1100        args.method = Some("gmail.users.messages.send".into());
1101        let auth = test_auth().await;
1102        let err = handle_ask(&config, Some(&auth), args, &SafetyProfile::readonly())
1103            .await
1104            .unwrap_err()
1105            .to_string();
1106        assert!(err.contains("POST"), "{err}");
1107    }
1108
1109    #[tokio::test]
1110    async fn handle_ask_run_without_a_credential_is_actionable() {
1111        // --method skips the judgments and drive.files.list needs no
1112        // required params, so no System One call happens at all.
1113        let config = test_config("http://127.0.0.1:1/v1/systemone", "");
1114        let mut args = plan_args("list my drive files");
1115        args.run = true;
1116        args.method = Some("drive.files.list".into());
1117        let err = handle_ask(&config, None, args, &SafetyProfile::PERMISSIVE)
1118            .await
1119            .unwrap_err()
1120            .to_string();
1121        assert!(err.contains("--run"), "{err}");
1122        assert!(err.contains("credential"), "{err}");
1123    }
1124
1125    #[test]
1126    fn every_service_fits_the_choice_option_limit() {
1127        // A Choice accepts at most 255 options: the service Choice needs
1128        // 14 + none, the method Choice at most one service's methods + none.
1129        let all = discovery::services();
1130        assert!(all.len() < MAX_CHOICE_OPTIONS);
1131        for (name, service) in all {
1132            assert!(
1133                service.methods.len() < MAX_CHOICE_OPTIONS,
1134                "{name} has {} methods",
1135                service.methods.len()
1136            );
1137        }
1138    }
1139
1140    #[test]
1141    fn param_fill_builds_one_question_per_required_parameter() {
1142        let (_, method) = discovery::resolve("gmail.users.threads.get").unwrap();
1143        let questions = param_fill_questions(&method.description, method, &["abc123".to_owned()]);
1144        assert_eq!(questions.len(), method.required.len());
1145        assert!(questions.contains_key("userId"));
1146        assert!(questions.contains_key("id"));
1147        // The userId question carries the universal alias; the id question
1148        // carries the candidate found in the request.
1149        let user_question = questions.get("userId").unwrap();
1150        assert!(user_question["criteria"].get("me").is_some());
1151        let id_question = questions.get("id").unwrap();
1152        assert!(id_question["criteria"].get("abc123").is_some());
1153    }
1154
1155    #[test]
1156    fn enum_parameters_use_the_enum_values_as_options() {
1157        let param = Parameter {
1158            name: "orderBy".into(),
1159            kind: "string".into(),
1160            required: true,
1161            repeated: false,
1162            location: Some("query".into()),
1163            description: "Sort order".into(),
1164            enum_values: Some(vec!["startTime".into(), "updated".into()]),
1165        };
1166        let question = param_question(&param, "List events.", &["startTime".into()]);
1167        let options = question["criteria"].as_object().unwrap();
1168        assert!(options.contains_key("startTime"));
1169        assert!(options.contains_key("updated"));
1170        assert!(
1171            !options.contains_key("me"),
1172            "enum options are the closed set"
1173        );
1174        assert!(options.contains_key("none"));
1175    }
1176
1177    #[test]
1178    fn candidates_come_from_the_request_text_verbatim() {
1179        let out = value_candidates("show the file named \"a b.pdf\" with id 42");
1180        assert!(out.contains(&"a b.pdf".to_owned()), "{out:?}");
1181        assert!(out.contains(&"42".to_owned()), "{out:?}");
1182        assert!(out.iter().all(|c| c != "none" && c != "me"));
1183    }
1184
1185    #[test]
1186    fn numeric_parameters_keep_their_json_type() {
1187        let param = Parameter {
1188            name: "pageSize".into(),
1189            kind: "integer".into(),
1190            required: true,
1191            repeated: false,
1192            location: Some("query".into()),
1193            description: "Page size".into(),
1194            enum_values: None,
1195        };
1196        assert_eq!(coerce_value("10", &param), json!(10));
1197        assert_eq!(coerce_value("not-a-number", &param), json!("not-a-number"));
1198    }
1199
1200    #[test]
1201    fn the_service_rubric_covers_every_indexed_service() {
1202        for (name, service) in discovery::services() {
1203            let rubric = service_rubric(service);
1204            // The rubric names the service by its title (falling back to
1205            // the name when the title is empty) and always carries the
1206            // version and a method count.
1207            let title: &str = if service.title.is_empty() {
1208                name
1209            } else {
1210                service.title.as_str()
1211            };
1212            assert!(rubric.contains(title), "{name}: {rubric}");
1213            assert!(rubric.contains(&service.version), "{name}: {rubric}");
1214        }
1215    }
1216}