use anyhow::Result;
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use super::read_only::{allow_result_file_write, read_only_prompt};
use super::RunOpts;
pub(super) fn build_gemini_family_command(
binary: &str,
prompt: &str,
opts: &RunOpts,
trust_env: Option<(&str, &str)>,
) -> Result<Command> {
let mut cmd = Command::new(binary);
cmd.args(["-o", "stream-json"]);
if let Some((key, value)) = trust_env
&& std::env::var_os(key).is_none()
{
cmd.env(key, value);
}
let effective_prompt = if allow_result_file_write(opts) {
read_only_prompt(prompt, opts)
} else {
prompt.to_string()
};
if opts.read_only && !allow_result_file_write(opts) {
cmd.args(["--approval-mode", "plan"]);
} else {
cmd.arg("-y");
}
if let Some(ref model) = opts.model {
cmd.args(["-m", model]);
}
for dir in gemini_include_directories(opts.dir.as_deref(), &opts.context_files) {
cmd.args(["--include-directories", &dir]);
}
cmd.args(["-p", &effective_prompt]);
if let Some(ref dir) = opts.dir {
cmd.current_dir(dir);
}
Ok(cmd)
}
pub(super) fn gemini_include_directories(run_dir: Option<&str>, context_files: &[String]) -> Vec<String> {
let run_dir = resolve_run_dir(run_dir);
let mut directories = BTreeSet::new();
for file in context_files {
let parent = Path::new(file).parent().unwrap_or_else(|| Path::new("."));
if should_include_directory(parent, run_dir.as_deref()) {
directories.insert(parent.to_string_lossy().into_owned());
}
}
directories.into_iter().collect()
}
pub(super) fn is_skill_or_hook_event(event_type: &str) -> bool {
event_type.contains("skill") || event_type.contains("hook")
}
pub(super) fn milestone_detail(event_type: &str, value: &Value) -> String {
let label = if event_type.contains("skill") { "skill" } else { "hook" };
if let Some(message) = [
value.get("message"),
value.get("detail"),
value.get("summary"),
]
.into_iter()
.flatten()
.find_map(Value::as_str)
{
return format!("{label}: {}", truncate(message, super::truncate::EVENT_DETAIL_MAX));
}
let name = [
value.get("name"),
value.get("skill"),
value.get("hook"),
value.pointer("/metadata/name"),
]
.into_iter()
.flatten()
.find_map(Value::as_str);
let status = [
value.get("status"),
value.get("phase"),
value.get("state"),
]
.into_iter()
.flatten()
.find_map(Value::as_str);
match (name, status) {
(Some(name), Some(status)) => format!("{label} {name}: {status}"),
(Some(name), None) => format!("{label} {name}"),
(None, Some(status)) => format!("{label}: {status}"),
(None, None) => label.to_string(),
}
}
pub(super) fn is_gemini_rate_limit_error(message: &str) -> bool {
crate::rate_limit_signatures::match_quota_signature_for_agent(
message,
crate::types::AgentKind::Gemini,
)
.is_some()
}
pub(super) fn truncate(text: &str, max_len: usize) -> String {
if text.len() <= max_len {
text.to_string()
} else {
let safe = text.floor_char_boundary(max_len.saturating_sub(3));
format!("{}...", &text[..safe])
}
}
pub(super) fn extract_tool_name(value: &Value) -> Option<&str> {
value.get("tool_name")
.and_then(Value::as_str)
.or_else(|| value.get("name").and_then(Value::as_str))
.or_else(|| value.pointer("/functionCall/name").and_then(Value::as_str))
.or_else(|| value.get("function_call").and_then(Value::as_str))
.or_else(|| value.get("function_call").and_then(|v| v.get("name")).and_then(Value::as_str))
.or_else(|| value.get("toolName").and_then(Value::as_str))
.or_else(|| value.get("tool").and_then(Value::as_str))
.or_else(|| value.get("tool").and_then(|v| v.get("name")).and_then(Value::as_str))
}
pub(super) fn extract_tool_arguments(value: &Value) -> Option<String> {
[
value.get("arguments"),
value.pointer("/functionCall/args"),
value.get("parameters"),
value.get("input"),
]
.into_iter()
.flatten()
.find_map(stringify_value)
}
pub(super) fn classify_tool_result(name: &str, output: &str) -> (crate::types::EventKind, String) {
let lower_output = output.to_lowercase();
let lower_name = name.to_lowercase();
if lower_name.contains("test") || lower_output.contains("test") || lower_output.contains("passed") {
(crate::types::EventKind::Test, format!("{}: {}", name, truncate(output, super::truncate::EVENT_DETAIL_MAX)))
} else if lower_output.contains("error") || lower_output.contains("failed") || lower_output.contains("failure") {
(crate::types::EventKind::Error, format!("{}: {}", name, truncate(output, super::truncate::EVENT_DETAIL_MAX)))
} else if lower_name.contains("build") || lower_name.contains("compile") || lower_output.contains("compiled") || lower_output.contains("built") {
(crate::types::EventKind::Build, format!("{}: {}", name, truncate(output, super::truncate::EVENT_DETAIL_MAX)))
} else {
(crate::types::EventKind::ToolCall, format!("{}: {}", name, truncate(output, super::truncate::EVENT_DETAIL_MAX)))
}
}
pub(super) fn extract_completion_stats(value: &Value) -> (Option<i64>, Option<String>) {
if value.get("stats").is_none() {
return (None, None);
}
(extract_tokens(value), extract_model(value))
}
fn model_tokens(model: &Value) -> i64 {
model.get("total_tokens").or_else(|| model.pointer("/tokens/total"))
.and_then(Value::as_i64).unwrap_or(0)
}
fn dominant_model<'a>(models: &'a Value) -> Option<&'a str> {
let use_cost = match models {
Value::Object(obj) => obj.values().all(|v| v.get("costUSD").and_then(Value::as_f64).is_some()),
Value::Array(items) => items.iter().all(|v| v.get("costUSD").and_then(Value::as_f64).is_some()),
_ => return None,
};
let better = |a: &Value, b: &Value| {
let cost = if use_cost {
a["costUSD"].as_f64().zip(b["costUSD"].as_f64()).and_then(|(a, b)| a.partial_cmp(&b))
} else { None };
cost.filter(|order| !order.is_eq())
.unwrap_or_else(|| model_tokens(a).cmp(&model_tokens(b)))
};
if let Some(obj) = models.as_object() {
return obj.iter().rev().max_by(|(_, a), (_, b)| better(a, b)).map(|(name, _)| name.as_str());
}
models.as_array()?.iter().rev().filter_map(|model| Some((model.get("model")?.as_str()?, model)))
.max_by(|(_, a), (_, b)| better(a, b)).map(|(name, _)| name)
}
pub(super) fn extract_tokens(value: &Value) -> Option<i64> {
if let Some(total) = value.pointer("/stats/total_tokens").and_then(Value::as_i64) {
return Some(total);
}
if let Some(models) = value.pointer("/stats/models").and_then(Value::as_array) {
let total = models.iter().filter_map(|model| model.pointer("/tokens/total").and_then(Value::as_i64))
.fold(0_i64, i64::saturating_add);
if total > 0 {
return Some(total);
}
}
if let Some(models) = value.pointer("/stats/models").and_then(Value::as_object) {
let total = models.values().filter_map(|model| model.get("total_tokens").and_then(Value::as_i64))
.fold(0_i64, i64::saturating_add);
if total > 0 {
return Some(total);
}
}
value.pointer("/usageMetadata/totalTokenCount").and_then(Value::as_i64)
}
pub(super) fn extract_error_detail(value: &Value) -> Option<String> {
[
value.get("message"),
value.pointer("/error/message"),
value.pointer("/error/status"),
value.pointer("/error/details/0/reason"),
value.pointer("/error/details/0/message"),
]
.into_iter()
.flatten()
.find_map(stringify_value)
}
pub(super) fn extract_model(value: &Value) -> Option<String> {
if let Some(model) = value.pointer("/stats/models").and_then(dominant_model) {
return Some(model.to_string());
}
for path in ["/modelVersion", "/model"] {
if let Some(model) = value.pointer(path).and_then(Value::as_str) {
return Some(model.to_string());
}
}
None
}
pub(super) fn extract_text_payload(value: Option<&Value>) -> Option<String> {
match value? {
Value::Null => None,
Value::String(text) => Some(text.clone()),
Value::Array(items) => {
let parts = items
.iter()
.filter_map(|item| extract_text_payload(Some(item)))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>();
(!parts.is_empty()).then(|| parts.concat())
}
Value::Object(map) => {
for key in ["text", "content", "parts"] {
if let Some(text) = map.get(key).and_then(|item| extract_text_payload(Some(item)))
&& !text.is_empty()
{
return Some(text);
}
}
None
}
_ => None,
}
}
fn stringify_value(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(text) => Some(text.clone()),
other => Some(other.to_string()),
}
}
fn resolve_run_dir(run_dir: Option<&str>) -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
let dir = run_dir.map(PathBuf::from).unwrap_or(cwd);
Some(if dir.is_absolute() { dir } else { std::env::current_dir().ok()?.join(dir) })
}
fn should_include_directory(dir: &Path, run_dir: Option<&Path>) -> bool {
if dir == Path::new(".") || dir.as_os_str().is_empty() {
return false;
}
if dir.is_relative() {
return matches!(dir.components().next(), Some(Component::ParentDir));
}
match run_dir {
Some(base) => !dir.starts_with(base),
None => true,
}
}
#[cfg(test)]
#[path = "gemini_support_tests.rs"]
mod tests;