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
}
}