codex-mobile-bridge 0.2.10

Remote bridge and service manager for codex-mobile.
Documentation
use super::*;

pub(crate) fn build_turn_input_items(
    request: &SendTurnRequest,
    staging_root: &Path,
) -> Result<(Vec<Value>, Vec<PathBuf>)> {
    let mut input_items = Vec::new();
    let mut staged_paths = Vec::new();

    if let Some(items) = request.input_items.as_ref() {
        for item in items {
            match item {
                SendTurnInputItem::Text { text } => {
                    if text.trim().is_empty() {
                        continue;
                    }
                    input_items.push(text_input_item(text));
                }
                SendTurnInputItem::LocalImage { path } => {
                    let path = validate_staged_image_path(path, staging_root)?;
                    staged_paths.push(path.clone());
                    input_items.push(json!({
                        "type": "localImage",
                        "path": path.to_string_lossy().to_string(),
                    }));
                }
            }
        }
    }

    if input_items.is_empty() && !request.text.trim().is_empty() {
        input_items.push(text_input_item(&request.text));
    }

    if input_items.is_empty() {
        bail!("输入内容不能为空");
    }

    Ok((input_items, staged_paths))
}

fn text_input_item(text: &str) -> Value {
    json!({
        "type": "text",
        "text": text,
        "text_elements": [],
    })
}

fn validate_staged_image_path(path: &str, staging_root: &Path) -> Result<PathBuf> {
    let candidate = PathBuf::from(path);
    if !candidate.is_absolute() {
        bail!("图片路径必须为绝对路径");
    }
    if !candidate.starts_with(staging_root) {
        bail!("图片路径不属于 bridge staging 目录");
    }
    let metadata = fs::metadata(&candidate)
        .with_context(|| format!("图片暂存文件不存在: {}", candidate.display()))?;
    if !metadata.is_file() {
        bail!("图片暂存路径无效: {}", candidate.display());
    }
    Ok(candidate)
}

pub(super) fn infer_image_extension(
    file_name: Option<&str>,
    mime_type: Option<&str>,
) -> Option<&'static str> {
    file_name
        .and_then(|value| Path::new(value).extension().and_then(|ext| ext.to_str()))
        .and_then(normalize_extension)
        .or_else(|| {
            mime_type.and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
                "image/png" => Some("png"),
                "image/jpeg" | "image/jpg" => Some("jpg"),
                "image/webp" => Some("webp"),
                "image/gif" => Some("gif"),
                "image/bmp" => Some("bmp"),
                "image/heic" => Some("heic"),
                "image/heif" => Some("heif"),
                _ => None,
            })
        })
}

fn normalize_extension(extension: &str) -> Option<&'static str> {
    match extension
        .trim()
        .trim_start_matches('.')
        .to_ascii_lowercase()
        .as_str()
    {
        "png" => Some("png"),
        "jpg" | "jpeg" => Some("jpg"),
        "webp" => Some("webp"),
        "gif" => Some("gif"),
        "bmp" => Some("bmp"),
        "heic" => Some("heic"),
        "heif" => Some("heif"),
        _ => None,
    }
}

pub(super) fn sanitize_file_name(file_name: &str) -> Option<String> {
    let file_name = Path::new(file_name)
        .file_name()?
        .to_string_lossy()
        .trim()
        .to_string();
    (!file_name.is_empty()).then_some(file_name)
}

pub(super) fn normalize_optional_trimmed(value: Option<&str>) -> Option<String> {
    value
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToOwned::to_owned)
}