tail-fin-gemini 0.6.5

Gemini (Google) adapter for tail-fin: cookie-authenticated HTTP client — text + file attach + multi-turn; no browser
Documentation
use serde::{Deserialize, Serialize};

use tail_fin_common::TailFinError;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeminiResponse {
    pub response: String,
    /// `c_<id>` — committed conversation id. Present when Gemini
    /// persists the turn (i.e. most logged-in cases).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<String>,
    /// `r_<id>` — this turn's response id. Needed to continue the
    /// conversation in a follow-up request.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub response_id: Option<String>,
    /// `rc_<id>` — the specific choice id (Gemini sometimes offers
    /// multiple drafts; `rc_` pins which one we're continuing from).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub choice_id: Option<String>,
}

impl GeminiResponse {
    /// True iff all three ids are present and non-trivially shaped.
    pub fn can_continue(&self) -> bool {
        self.require_continuation().is_ok()
    }

    /// Destructure the triple, returning an error listing which fields
    /// are missing. `ask_continue` calls this so users get a specific
    /// message instead of the request failing opaquely on the server.
    ///
    /// **Trust model**: this runs on ids that were extracted from a
    /// real server response (by `extract_turn_ids`, which already
    /// enforces the `c_`/`r_`/`rc_` prefixes at extraction time).
    /// Re-validating prefixes here would mean rejecting ids *Gemini
    /// itself gave us* if they ever change shape — a regression risk
    /// for zero benefit. The strict prefix check belongs on the
    /// untrusted CLI/library-input path (see
    /// `GeminiClient::validate_continuation_ids`), not here.
    ///
    /// **Caveat for external callers**: `GeminiResponse` derives
    /// `Deserialize` and exposes public fields, so a caller that
    /// round-trips a stashed response through JSON/disk (or constructs
    /// one manually) bypasses `extract_turn_ids`'s prefix enforcement.
    /// Bogus ids take one wasted network round-trip — the stale-cid
    /// guard in `ask_inner` catches the server-side rejection.
    pub fn require_continuation(&self) -> Result<(&str, &str, &str), TailFinError> {
        let cid = non_trivial(self.conversation_id.as_deref());
        let rid = non_trivial(self.response_id.as_deref());
        let rcid = non_trivial(self.choice_id.as_deref());
        match (cid, rid, rcid) {
            (Some(c), Some(r), Some(rc)) => Ok((c, r, rc)),
            _ => {
                let mut bad = Vec::new();
                if cid.is_none() {
                    bad.push("conversation_id");
                }
                if rid.is_none() {
                    bad.push("response_id");
                }
                if rcid.is_none() {
                    bad.push("choice_id");
                }
                Err(TailFinError::Api(format!(
                    "cannot continue: response is missing {}",
                    bad.join(", ")
                )))
            }
        }
    }
}

/// Return the str iff it's present, non-empty after trim, and not the
/// literal `"null"` (common artefact of `jq -r` on an absent field).
/// Lenient by design — see `require_continuation` trust-model note.
fn non_trivial(s: Option<&str>) -> Option<&str> {
    let s = s?;
    let t = s.trim();
    if t.is_empty() || t == "null" {
        None
    } else {
        Some(s)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn serde_roundtrip_full() {
        let r = GeminiResponse {
            response: "hello".into(),
            conversation_id: Some("c_abc".into()),
            response_id: Some("r_def".into()),
            choice_id: Some("rc_ghi".into()),
        };
        let s = serde_json::to_string(&r).unwrap();
        let back: GeminiResponse = serde_json::from_str(&s).unwrap();
        assert_eq!(back.conversation_id.as_deref(), Some("c_abc"));
        assert_eq!(back.response_id.as_deref(), Some("r_def"));
        assert_eq!(back.choice_id.as_deref(), Some("rc_ghi"));
    }

    #[test]
    fn omits_missing_ids() {
        let r = GeminiResponse {
            response: "hi".into(),
            conversation_id: None,
            response_id: None,
            choice_id: None,
        };
        let s = serde_json::to_string(&r).unwrap();
        assert!(!s.contains("conversation_id"));
        assert!(!s.contains("response_id"));
        assert!(!s.contains("choice_id"));
    }

    #[test]
    fn can_continue_requires_all_three_ids() {
        let mut r = GeminiResponse {
            response: "".into(),
            conversation_id: Some("c_ok".into()),
            response_id: Some("r_ok".into()),
            choice_id: Some("rc_ok".into()),
        };
        assert!(r.can_continue());
        r.choice_id = None;
        assert!(!r.can_continue());
    }

    #[test]
    fn require_continuation_lists_missing_fields() {
        let r = GeminiResponse {
            response: "".into(),
            conversation_id: Some("c_valid".into()),
            response_id: None,
            choice_id: None,
        };
        let err = r.require_continuation().unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("response_id"), "got: {msg}");
        assert!(msg.contains("choice_id"), "got: {msg}");
        assert!(!msg.contains(" conversation_id"), "got: {msg}");
    }

    #[test]
    fn require_continuation_rejects_literal_null_strings() {
        // jq -r on an absent field prints "null" — treat as missing.
        let r = GeminiResponse {
            response: "".into(),
            conversation_id: Some("null".into()),
            response_id: Some("null".into()),
            choice_id: Some("null".into()),
        };
        assert!(!r.can_continue());
    }

    #[test]
    fn require_continuation_rejects_empty_strings() {
        let r = GeminiResponse {
            response: "".into(),
            conversation_id: Some("".into()),
            response_id: Some("r_ok".into()),
            choice_id: Some("rc_ok".into()),
        };
        assert!(!r.can_continue());
    }

    #[test]
    fn require_continuation_accepts_ids_without_expected_prefix() {
        // Forward-compat: if Gemini ever changes id shape, we don't
        // want to reject ids the server itself handed us. Prefix
        // enforcement lives on the untrusted-input path (see
        // `validate_continuation_ids` in client.rs), not here.
        let r = GeminiResponse {
            response: "".into(),
            conversation_id: Some("CID_uppercase".into()),
            response_id: Some("some_new_shape".into()),
            choice_id: Some("also_different".into()),
        };
        assert!(r.can_continue());
    }

    #[test]
    fn require_continuation_trims_whitespace() {
        let r = GeminiResponse {
            response: "".into(),
            conversation_id: Some("   ".into()),
            response_id: Some("r_ok".into()),
            choice_id: Some("rc_ok".into()),
        };
        assert!(!r.can_continue());
    }
}