codex-mobile-bridge 0.1.0

Remote bridge and service manager for codex-mobile.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use uuid::Uuid;

use crate::bridge_protocol::WorkspaceRecord;

pub fn canonicalize_directory(path: &Path) -> Result<PathBuf> {
    let canonical = fs::canonicalize(path)
        .with_context(|| format!("工作区目录不存在或不可访问: {}", path.display()))?;

    if !canonical.is_dir() {
        bail!("工作区必须是目录: {}", canonical.display());
    }

    Ok(canonical)
}

pub fn resolve_workspace_path(root: &Path, relative_path: Option<&str>) -> Result<PathBuf> {
    let raw_relative = relative_path.unwrap_or(".").trim();
    let normalized = if raw_relative.is_empty() {
        "."
    } else {
        raw_relative
    };

    let candidate = root.join(normalized);
    let canonical = fs::canonicalize(&candidate)
        .with_context(|| format!("目录不存在: {}", candidate.display()))?;

    if !canonical.starts_with(root) {
        bail!("路径越界,目标不在工作区内: {}", canonical.display());
    }

    if !canonical.is_dir() {
        bail!("目标路径不是目录: {}", canonical.display());
    }

    Ok(canonical)
}

pub fn build_workspace_id(root: &Path) -> String {
    let display = default_display_name(root);
    let slug = slugify(&display);
    let uuid = Uuid::new_v5(&Uuid::NAMESPACE_URL, root.to_string_lossy().as_bytes());
    let short = uuid.simple().to_string();
    format!("{}-{}", slug, &short[..8])
}

pub fn default_display_name(root: &Path) -> String {
    root.file_name()
        .and_then(|name| name.to_str())
        .map(ToOwned::to_owned)
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| "workspace".to_string())
}

pub fn workspace_matches(workspace: &WorkspaceRecord, cwd: &str) -> bool {
    let root = Path::new(&workspace.root_path);
    let cwd_path = Path::new(cwd);
    cwd_path.starts_with(root)
}

fn slugify(input: &str) -> String {
    let mut slug = String::new();
    let mut last_dash = false;

    for ch in input.chars() {
        let mapped = match ch {
            'a'..='z' | '0'..='9' => Some(ch),
            'A'..='Z' => Some(ch.to_ascii_lowercase()),
            _ => None,
        };

        if let Some(value) = mapped {
            slug.push(value);
            last_dash = false;
            continue;
        }

        if !last_dash {
            slug.push('-');
            last_dash = true;
        }
    }

    let trimmed = slug.trim_matches('-').chars().take(32).collect::<String>();

    trimmed.if_empty_then("workspace")
}

trait IfEmpty {
    fn if_empty_then(self, fallback: &str) -> String;
}

impl IfEmpty for String {
    fn if_empty_then(self, fallback: &str) -> String {
        if self.is_empty() {
            fallback.to_string()
        } else {
            self
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{canonicalize_directory, resolve_workspace_path};
    use std::fs;

    #[test]
    fn resolve_workspace_path_rejects_escape() {
        let temp = tempfile_dir("workspace-escape");
        let root = temp.join("root");
        let child = root.join("child");
        fs::create_dir_all(&child).unwrap();

        let result = resolve_workspace_path(&root, Some("../"));
        assert!(result.is_err());
    }

    #[test]
    fn canonicalize_directory_accepts_existing_dir() {
        let temp = tempfile_dir("workspace-ok");
        fs::create_dir_all(&temp).unwrap();

        let result = canonicalize_directory(&temp).unwrap();
        assert!(result.is_dir());
    }

    fn tempfile_dir(name: &str) -> std::path::PathBuf {
        let base = std::env::temp_dir().join(format!("{}-{}", name, uuid::Uuid::new_v4().simple()));
        base
    }
}