use crate::texts::tr;
use escouade_plugin_protocol::engine::TurnUsage;
use serde_json::{json, Value};
pub const MODE_UPDATE: &str = "[MODE_UPDATE] ";
pub const PLAN_APPROVAL: &str = "Requesting plan approval for: ";
pub const PLAN: &str = "gemini/plan";
pub const RATE_RESUME_MS: i64 = 60_000;
pub const DAILY_RESUME_MS: i64 = 3_600_000;
const UNSHOWN: [&str; 2] = ["update_topic", "exit_plan_mode"];
pub fn escouade_mode(mode: &str) -> Option<&'static str> {
match mode {
"plan" => Some("plan"),
"autoEdit" => Some("acceptEdits"),
"yolo" => Some("bypassPermissions"),
"default" => Some("default"),
_ => None,
}
}
pub fn mode_update(text: &str) -> Option<&'static str> {
escouade_mode(text.strip_prefix(MODE_UPDATE)?.trim())
}
pub fn is_plan_exit(call: &str) -> bool {
call.starts_with("exit_plan_mode")
}
pub fn plan_file(title: &str) -> Option<&str> {
title
.strip_prefix(PLAN_APPROVAL)
.map(str::trim)
.filter(|p| !p.is_empty())
}
pub fn plan_of(params: &Value) -> String {
let file = params["toolCall"]["title"].as_str().and_then(plan_file);
let plan = file
.and_then(|f| std::fs::read_to_string(f).ok())
.unwrap_or_else(|| file.unwrap_or_default().to_string());
plan.trim().to_string()
}
pub fn models_of(models: &Value) -> Vec<String> {
models["availableModels"]
.as_array()
.into_iter()
.flatten()
.filter_map(|m| m["modelId"].as_str().map(str::to_string))
.collect()
}
pub fn spent(usage: &Value) -> TurnUsage {
let n = |k: &str| usage[k].as_u64().unwrap_or(0);
let cached = n("cachedReadTokens");
TurnUsage {
input: n("inputTokens").saturating_sub(cached),
output: n("outputTokens") + n("thoughtTokens"),
cache: cached,
cost: 0.0,
}
}
pub fn limit_of(code: Option<i64>, error: &str) -> Option<i64> {
let lower = error.to_lowercase();
let daily = lower.contains("daily quota") || lower.contains("perday");
let limited = daily
|| code == Some(429)
|| lower.contains("usage limit")
|| crate::limits::limit_of(error).is_some();
if !limited {
return None;
}
let told = lower.split_once("retry in ").and_then(|(_, rest)| {
let digits: String = rest
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let n: f64 = digits.parse().ok()?;
let unit = rest[digits.len()..].trim_start();
let ms = if unit.starts_with("ms") {
n
} else {
n * 1000.0
};
(ms > 0.0).then_some(ms.ceil() as i64)
});
Some(told.unwrap_or(if daily {
DAILY_RESUME_MS
} else {
RATE_RESUME_MS
}))
}
fn tool_name(id: &str) -> &str {
id.split_once("__").map_or("", |(name, _)| name)
}
fn mcp_title(title: &str) -> Option<(&str, &str)> {
let (tool, rest) = title.split_once(" (")?;
let server = rest.strip_suffix(" MCP Server)")?;
Some((tool.trim(), server.trim()))
}
fn subagent_title(title: &str) -> Option<&str> {
title
.strip_prefix("Delegating to agent ")
.map(|a| a.trim().trim_matches(['\'', '"']))
}
pub fn normalized(u: &Value, cwd: &str) -> Option<Value> {
let id = u["toolCallId"].as_str().unwrap_or_default();
let tool = tool_name(id);
if UNSHOWN.contains(&tool) {
return None;
}
let mut n = u.clone();
let title = u["title"].as_str().unwrap_or_default();
if n["rawInput"].is_null() {
if tool.starts_with("mcp_") {
if let Some((name, server)) = mcp_title(title) {
n["rawInput"] =
json!({ "providerIdentifier": server, "toolName": name, "args": {} });
}
} else if tool == "invoke_agent" || u["kind"] == "think" {
n["kind"] = json!("other");
if !title.is_empty() {
let agent = subagent_title(title).unwrap_or(title);
n["rawInput"] = json!({
"_toolName": "task", "description": agent, "prompt": title, "subagentType": agent,
});
}
} else if !tool.is_empty()
&& !matches!(
u["kind"].as_str(),
Some("execute" | "edit" | "read" | "search" | "fetch" | "delete")
)
{
if u.get("kind").is_some() || u["sessionUpdate"] == "tool_call" {
n["rawInput"] = json!({ "_toolName": tool, "reason": title });
}
}
} else if n["kind"] == "think" && tool == "invoke_agent" {
n["kind"] = json!("other");
}
if let Some(content) = n["content"].as_array_mut() {
for c in content.iter_mut().filter(|c| c["type"] == "diff") {
if let Some(p) = c["path"].as_str() {
c["path"] = json!(crate::tools::absolute(p, cwd));
}
}
}
if let Some(list) = n["locations"].as_array_mut() {
for l in list.iter_mut() {
if let Some(p) = l["path"].as_str() {
l["path"] = json!(crate::tools::absolute(p, cwd));
}
}
}
Some(n)
}
pub fn rate_limited(lang: &str, said: &str) -> String {
tr(
lang,
format!("Limite d’usage de Gemini atteinte : {said}"),
format!("Gemini’s usage limit reached: {said}"),
)
}
pub fn session_refused(lang: &str, e: &str) -> String {
tr(
lang,
format!("Gemini CLI n’a pas ouvert la session de l’agent : {e}. S’il n’est pas connecté, lance gemini dans un terminal pour te connecter à ton compte Google, ou donne-lui une clé d’API par GEMINI_API_KEY."),
format!("Gemini CLI didn’t open the agent’s session: {e}. If it isn’t signed in, run gemini in a terminal to sign in to your Google account, or give it an API key by GEMINI_API_KEY."),
)
}
pub fn protocol_refused(lang: &str, version: &str) -> String {
let ours = crate::PROTOCOL;
tr(
lang,
format!("Gemini CLI parle ACP {version}, Escouade ACP {ours} : mets Gemini CLI à jour (npm install -g @google/gemini-cli)."),
format!("Gemini CLI speaks ACP {version}, Escouade ACP {ours}: update Gemini CLI (npm install -g @google/gemini-cli)."),
)
}
#[cfg(test)]
#[path = "gemini_tests.rs"]
mod tests;