use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
const MAX_WORKTREE_FANOUT: usize = 50;
pub const BRIDGE_POINTER_TTL_MS: u64 = 4 * 60 * 60 * 1000;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum BridgePointerSource {
Standalone,
Repl,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BridgePointer {
#[serde(rename = "sessionId")]
pub session_id: String,
#[serde(rename = "environmentId")]
pub environment_id: String,
pub source: BridgePointerSource,
}
#[derive(Debug, Clone)]
pub struct BridgePointerWithAge {
pub session_id: String,
pub environment_id: String,
pub source: BridgePointerSource,
pub age_ms: u64,
}
pub fn get_bridge_pointer_path(dir: &str) -> PathBuf {
let projects_dir = get_projects_dir();
let sanitized = sanitize_path(dir);
projects_dir.join(sanitized).join("bridge-pointer.json")
}
fn get_projects_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ai-code")
.join("projects")
}
fn sanitize_path(path: &str) -> String {
path.chars()
.map(|c| {
if c == '/' || c == '\\' || c == ':' {
'-'
} else {
c
}
})
.collect()
}
pub async fn write_bridge_pointer(dir: &str, pointer: &BridgePointer) -> Result<(), String> {
let path = get_bridge_pointer_path(dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {}", e))?;
}
let content =
serde_json::to_string_pretty(pointer).map_err(|e| format!("Failed to serialize: {}", e))?;
fs::write(&path, content).map_err(|e| format!("Failed to write pointer: {}", e))?;
log_for_debugging(&format!("[bridge:pointer] wrote {}", path.display()));
Ok(())
}
pub async fn read_bridge_pointer(dir: &str) -> Option<BridgePointerWithAge> {
let path = get_bridge_pointer_path(dir);
let metadata = match fs::metadata(&path) {
Ok(m) => m,
Err(_) => return None,
};
let mtime_ms = metadata
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_millis() as u64;
let raw = match fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return None,
};
let parsed: BridgePointer = match serde_json::from_str(&raw) {
Ok(p) => p,
Err(_) => {
log_for_debugging(&format!(
"[bridge:pointer] invalid schema, clearing: {}",
path.display()
));
let _ = clear_bridge_pointer(dir).await;
return None;
}
};
let age_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64
- mtime_ms;
if age_ms > BRIDGE_POINTER_TTL_MS {
log_for_debugging(&format!(
"[bridge:pointer] stale (>4h mtime), clearing: {}",
path.display()
));
let _ = clear_bridge_pointer(dir).await;
return None;
}
Some(BridgePointerWithAge {
session_id: parsed.session_id,
environment_id: parsed.environment_id,
source: parsed.source,
age_ms,
})
}
pub async fn read_bridge_pointer_across_worktrees(
dir: &str,
) -> Option<(BridgePointerWithAge, String)> {
if let Some(pointer) = read_bridge_pointer(dir).await {
return Some((pointer, dir.to_string()));
}
let worktrees = get_worktree_paths(dir).await?;
if worktrees.len() <= 1 {
return None;
}
if worktrees.len() > MAX_WORKTREE_FANOUT {
log_for_debugging(&format!(
"[bridge:pointer] {} worktrees exceeds fanout cap {}, skipping",
worktrees.len(),
MAX_WORKTREE_FANOUT
));
return None;
}
let dir_key = sanitize_path(dir);
let candidates: Vec<&String> = worktrees
.iter()
.filter(|wt| sanitize_path(wt) != dir_key)
.collect();
let mut results: Vec<Option<(BridgePointerWithAge, String)>> = Vec::new();
for wt in candidates {
if let Some(p) = read_bridge_pointer(wt).await {
results.push(Some((p, wt.clone())));
}
}
let mut freshest: Option<(BridgePointerWithAge, String)> = None;
for r in results.into_iter().flatten() {
match &freshest {
Some(f) if r.0.age_ms >= f.0.age_ms => {}
_ => freshest = Some(r),
}
}
if let Some(ref f) = freshest {
log_for_debugging(&format!(
"[bridge:pointer] fanout found pointer in worktree {} (ageMs={})",
f.1, f.0.age_ms
));
}
freshest
}
async fn get_worktree_paths(dir: &str) -> Option<Vec<String>> {
use std::process::Command;
let output = Command::new("git")
.args(&["worktree", "list", "--porcelain"])
.current_dir(dir)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let output_str = String::from_utf8_lossy(&output.stdout);
let paths: Vec<String> = output_str
.lines()
.filter_map(|line| {
if line.starts_with("worktree ") {
Some(line.trim_start_matches("worktree ").to_string())
} else {
None
}
})
.collect();
if paths.is_empty() {
Some(vec![dir.to_string()])
} else {
Some(paths)
}
}
pub async fn clear_bridge_pointer(dir: &str) -> Result<(), String> {
let path = get_bridge_pointer_path(dir);
match fs::remove_file(&path) {
Ok(_) => {
log_for_debugging(&format!("[bridge:pointer] cleared {}", path.display()));
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => {
log_for_debugging(&format!("[bridge:pointer] clear failed: {}", e));
Err(format!("Failed to clear pointer: {}", e))
}
}
}
#[allow(unused_variables)]
fn log_for_debugging(msg: &str) {
eprintln!("{}", msg);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bridge_pointer_serialization() {
let pointer = BridgePointer {
session_id: "test-session".to_string(),
environment_id: "test-env".to_string(),
source: BridgePointerSource::Standalone,
};
let json = serde_json::to_string(&pointer).unwrap();
let parsed: BridgePointer = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.session_id, "test-session");
assert_eq!(parsed.environment_id, "test-env");
}
#[test]
fn test_sanitize_path() {
assert_eq!(sanitize_path("foo/bar"), "foo-bar");
assert_eq!(sanitize_path("foo\\bar"), "foo-bar");
}
}