use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceContext {
pub current_dir: String,
pub launch_dir: Option<String>,
pub project_name: Option<String>,
pub project_root: Option<String>,
pub project_type: Option<String>,
pub vcs_system: Option<String>,
pub vcs_repository: Option<String>,
pub vcs_branch: Option<String>,
pub vcs_commit: Option<String>,
pub vcs_worktree: Option<bool>,
}
impl WorkspaceContext {
pub fn harvest_from_dir(dir: &Path) -> Self {
let current_dir = dir
.canonicalize()
.unwrap_or_else(|_| dir.to_path_buf())
.to_string_lossy()
.to_string();
let mut ctx = Self {
current_dir,
launch_dir: std::env::var("INIT_CWD").ok().or_else(|| {
std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string())
}),
..Default::default()
};
if let Some((root, ptype, name)) = find_project_root(dir) {
ctx.project_root = Some(root.to_string_lossy().to_string());
ctx.project_type = Some(ptype);
ctx.project_name = name;
} else {
if let Some(file_name) = dir.file_name() {
ctx.project_name = Some(file_name.to_string_lossy().to_string());
}
}
if let Some(vcs) = probe_git_metadata(dir) {
ctx.vcs_system = Some("git".to_string());
ctx.vcs_repository = vcs.repository;
ctx.vcs_branch = vcs.branch;
ctx.vcs_commit = vcs.commit;
ctx.vcs_worktree = Some(vcs.is_worktree);
} else {
ctx.vcs_system = Some("none".to_string());
}
ctx
}
pub fn harvest_current() -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self::harvest_from_dir(&cwd)
}
}
const PROJECT_MARKERS: &[(&str, &str)] = &[
("Cargo.toml", "rust"),
("package.json", "node"),
("pyproject.toml", "python"),
("requirements.txt", "python"),
("setup.py", "python"),
("go.mod", "go"),
("pom.xml", "java"),
("build.gradle", "java"),
("CMakeLists.txt", "cpp"),
(".gemini", "antigravity_workspace"),
(".claude", "claude_workspace"),
(".codex", "codex_workspace"),
];
fn find_project_root(start_dir: &Path) -> Option<(PathBuf, String, Option<String>)> {
let mut curr = start_dir.to_path_buf();
for _ in 0..15 {
for &(marker, ptype) in PROJECT_MARKERS {
let marker_path = curr.join(marker);
if marker_path.exists() {
let name = extract_project_name(&curr, marker);
return Some((curr, ptype.to_string(), name));
}
}
if !curr.pop() {
break;
}
}
None
}
fn extract_project_name(root: &Path, marker: &str) -> Option<String> {
if marker == "Cargo.toml" {
if let Ok(content) = fs::read_to_string(root.join("Cargo.toml")) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("name =") || line.starts_with("name=") {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() == 2 {
let name = parts[1].trim().trim_matches('"').trim_matches('\'');
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
}
} else if marker == "package.json" {
if let Ok(content) = fs::read_to_string(root.join("package.json")) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("\"name\":") || line.starts_with("\"name\" :") {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() >= 2 {
let name = parts[1].trim().trim_matches(',').trim().trim_matches('"');
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
}
}
root.file_name().map(|n| n.to_string_lossy().to_string())
}
#[derive(Default)]
struct GitProbeResult {
repository: Option<String>,
branch: Option<String>,
commit: Option<String>,
is_worktree: bool,
}
fn probe_git_metadata(start_dir: &Path) -> Option<GitProbeResult> {
let mut curr = start_dir.to_path_buf();
for _ in 0..15 {
let git_marker = curr.join(".git");
if git_marker.is_dir() {
return Some(read_git_dir(&git_marker, false));
} else if git_marker.is_file() {
if let Ok(content) = fs::read_to_string(&git_marker) {
let line = content.trim();
if let Some(target) = line.strip_prefix("gitdir:") {
let target_path = target.trim();
let resolved = if Path::new(target_path).is_absolute() {
PathBuf::from(target_path)
} else {
curr.join(target_path)
};
if resolved.exists() {
return Some(read_git_dir(&resolved, true));
}
}
}
return Some(read_git_dir(&git_marker, true));
}
if !curr.pop() {
break;
}
}
None
}
fn read_git_dir(git_dir: &Path, is_worktree: bool) -> GitProbeResult {
let mut result = GitProbeResult {
is_worktree,
..Default::default()
};
let head_path = git_dir.join("HEAD");
if let Ok(head_content) = fs::read_to_string(head_path) {
let head_line = head_content.trim();
if let Some(branch_ref) = head_line.strip_prefix("ref: refs/heads/") {
let branch = branch_ref.trim().to_string();
result.branch = Some(branch.clone());
let ref_path = git_dir.join("refs").join("heads").join(&branch);
if let Ok(sha) = fs::read_to_string(ref_path) {
let sha = sha.trim();
if sha.len() >= 7 {
result.commit = Some(sha.to_string());
}
}
} else if head_line.len() >= 7 && head_line.chars().all(|c| c.is_ascii_hexdigit()) {
result.commit = Some(head_line.to_string());
result.branch = Some("detached".to_string());
}
}
let config_path = if is_worktree {
let commondir_path = git_dir.join("commondir");
if let Ok(commondir) = fs::read_to_string(commondir_path) {
let common_resolved = git_dir.join(commondir.trim());
common_resolved.join("config")
} else {
git_dir.join("config")
}
} else {
git_dir.join("config")
};
if let Ok(config_content) = fs::read_to_string(config_path) {
let mut in_origin = false;
for line in config_content.lines() {
let line = line.trim();
if line.starts_with("[remote \"origin\"]") {
in_origin = true;
continue;
}
if in_origin {
if line.starts_with('[') {
break;
}
if line.starts_with("url =") || line.starts_with("url=") {
let parts: Vec<&str> = line.split('=').collect();
if parts.len() >= 2 {
let raw_url = parts[1].trim();
let sanitized_url = sanitize_git_url(raw_url);
result.repository = extract_repo_name(&sanitized_url);
break;
}
}
}
}
}
result
}
fn sanitize_git_url(raw_url: &str) -> String {
if let Some(at_idx) = raw_url.find('@') {
if let Some(proto_idx) = raw_url.find("://") {
if proto_idx < at_idx {
let proto = &raw_url[..proto_idx + 3];
let host_and_path = &raw_url[at_idx + 1..];
return format!("{proto}{host_and_path}");
}
}
}
raw_url.to_string()
}
fn extract_repo_name(url: &str) -> Option<String> {
let clean = url.trim_end_matches(".git").trim_end_matches('/');
clean
.split(['/', ':', '\\'])
.next_back()
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_harvest_current() {
let ctx = WorkspaceContext::harvest_current();
assert!(!ctx.current_dir.is_empty());
assert_eq!(ctx.vcs_system.as_deref(), Some("git"));
assert!(ctx.project_root.is_some());
}
#[test]
fn test_sanitize_git_url() {
let secret_url = "https://oauth2:ghp_12345SECRET@github.com/smota/agent-otel-bridge.git";
let sanitized = sanitize_git_url(secret_url);
assert_eq!(sanitized, "https://github.com/smota/agent-otel-bridge.git");
assert_eq!(
extract_repo_name(&sanitized),
Some("agent-otel-bridge".to_string())
);
}
}