use crate::error::{DockerError, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
pub const ARCBOX_CONTEXT_NAME: &str = "arcbox";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ContextMeta {
pub name: String,
pub metadata: ContextMetadata,
pub endpoints: ContextEndpoints,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ContextMetadata {
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextEndpoints {
pub docker: DockerEndpoint,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct DockerEndpoint {
pub host: String,
#[serde(default, rename = "SkipTLSVerify")]
pub skip_tls_verify: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DockerConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub current_context: Option<String>,
#[serde(flatten)]
pub other: HashMap<String, serde_json::Value>,
}
pub struct DockerContextManager {
socket_path: PathBuf,
docker_config_dir: PathBuf,
}
impl DockerContextManager {
pub fn new(socket_path: PathBuf) -> Result<Self> {
let docker_config_dir = dirs::home_dir()
.ok_or_else(|| DockerError::Context("cannot find home directory".to_string()))?
.join(".docker");
Ok(Self {
socket_path,
docker_config_dir,
})
}
#[must_use]
pub const fn with_config_dir(socket_path: PathBuf, docker_config_dir: PathBuf) -> Self {
Self {
socket_path,
docker_config_dir,
}
}
#[must_use]
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
#[must_use]
pub fn docker_config_dir(&self) -> &Path {
&self.docker_config_dir
}
#[must_use]
pub fn context_exists(&self) -> bool {
self.context_meta_path().exists()
}
pub fn is_default(&self) -> Result<bool> {
let config = self.read_docker_config()?;
Ok(config.current_context.as_deref() == Some(ARCBOX_CONTEXT_NAME))
}
pub fn current_context(&self) -> Result<Option<String>> {
let config = self.read_docker_config()?;
Ok(config.current_context)
}
pub fn create_context(&self) -> Result<()> {
let meta_dir = self.context_dir();
fs::create_dir_all(&meta_dir).map_err(|e| {
DockerError::Context(format!("failed to create context directory: {e}"))
})?;
let meta = ContextMeta {
name: ARCBOX_CONTEXT_NAME.to_string(),
metadata: ContextMetadata {
description: "ArcBox Container Runtime".to_string(),
},
endpoints: ContextEndpoints {
docker: DockerEndpoint {
host: format!("unix://{}", self.socket_path.display()),
skip_tls_verify: false,
},
},
};
let meta_path = self.context_meta_path();
let meta_json = serde_json::to_string_pretty(&meta).map_err(|e| {
DockerError::Context(format!("failed to serialize context metadata: {e}"))
})?;
fs::write(&meta_path, meta_json)
.map_err(|e| DockerError::Context(format!("failed to write context metadata: {e}")))?;
info!("Created Docker context '{ARCBOX_CONTEXT_NAME}'");
debug!(path = %meta_path.display(), "Context metadata written");
Ok(())
}
pub fn remove_context(&self) -> Result<()> {
if self.is_default()? {
self.restore_default()?;
}
let context_dir = self.context_dir();
if context_dir.exists() {
fs::remove_dir_all(&context_dir).map_err(|e| {
DockerError::Context(format!("failed to remove context directory: {e}"))
})?;
info!("Removed Docker context '{ARCBOX_CONTEXT_NAME}'");
} else {
debug!("Context directory does not exist, nothing to remove");
}
Ok(())
}
pub fn set_default(&self) -> Result<()> {
if !self.context_exists() {
return Err(DockerError::Context(
"ArcBox context does not exist, run create_context first".to_string(),
));
}
let mut config = self.read_docker_config()?;
if let Some(ref current) = config.current_context {
if current != ARCBOX_CONTEXT_NAME {
self.save_previous_context(current)?;
}
}
config.current_context = Some(ARCBOX_CONTEXT_NAME.to_string());
self.write_docker_config(&config)?;
info!("Set '{ARCBOX_CONTEXT_NAME}' as default Docker context");
Ok(())
}
pub fn restore_default(&self) -> Result<()> {
let previous = self.read_previous_context()?;
let mut config = self.read_docker_config()?;
config.current_context.clone_from(&previous);
self.write_docker_config(&config)?;
let _ = fs::remove_file(self.previous_context_path());
if let Some(name) = previous {
info!("Restored default Docker context to '{name}'");
} else {
info!("Cleared default Docker context");
}
Ok(())
}
pub fn enable(&self) -> Result<()> {
self.create_context()?;
self.set_default()?;
Ok(())
}
pub fn disable(&self) -> Result<()> {
if self.is_default()? {
self.restore_default()?;
}
Ok(())
}
#[must_use]
pub fn status(&self) -> ContextStatus {
ContextStatus {
context_exists: self.context_exists(),
is_default: self.is_default().unwrap_or(false),
socket_path: self.socket_path.clone(),
socket_exists: self.socket_path.exists(),
}
}
fn context_hash(name: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(name.as_bytes());
hex::encode(hasher.finalize())
}
fn context_dir(&self) -> PathBuf {
let hash = Self::context_hash(ARCBOX_CONTEXT_NAME);
self.docker_config_dir
.join("contexts")
.join("meta")
.join(hash)
}
fn context_meta_path(&self) -> PathBuf {
self.context_dir().join("meta.json")
}
fn config_path(&self) -> PathBuf {
self.docker_config_dir.join("config.json")
}
fn previous_context_path(&self) -> PathBuf {
self.docker_config_dir.join(".arcbox-previous-context")
}
fn read_docker_config(&self) -> Result<DockerConfig> {
let config_path = self.config_path();
if !config_path.exists() {
return Ok(DockerConfig::default());
}
let data = fs::read_to_string(&config_path)
.map_err(|e| DockerError::Context(format!("failed to read config.json: {e}")))?;
serde_json::from_str(&data)
.map_err(|e| DockerError::Context(format!("failed to parse config.json: {e}")))
}
fn write_docker_config(&self, config: &DockerConfig) -> Result<()> {
fs::create_dir_all(&self.docker_config_dir).map_err(|e| {
DockerError::Context(format!("failed to create .docker directory: {e}"))
})?;
let config_path = self.config_path();
let json = serde_json::to_string_pretty(config)
.map_err(|e| DockerError::Context(format!("failed to serialize config.json: {e}")))?;
fs::write(&config_path, json)
.map_err(|e| DockerError::Context(format!("failed to write config.json: {e}")))?;
debug!(path = %config_path.display(), "Docker config written");
Ok(())
}
fn save_previous_context(&self, name: &str) -> Result<()> {
let path = self.previous_context_path();
fs::write(&path, name)
.map_err(|e| DockerError::Context(format!("failed to save previous context: {e}")))?;
debug!(previous = %name, "Saved previous context");
Ok(())
}
fn read_previous_context(&self) -> Result<Option<String>> {
let path = self.previous_context_path();
if !path.exists() {
return Ok(None);
}
let name = fs::read_to_string(&path)
.map_err(|e| DockerError::Context(format!("failed to read previous context: {e}")))?
.trim()
.to_string();
if name.is_empty() {
Ok(None)
} else {
Ok(Some(name))
}
}
}
#[derive(Debug, Clone)]
pub struct ContextStatus {
pub context_exists: bool,
pub is_default: bool,
pub socket_path: PathBuf,
pub socket_exists: bool,
}
impl std::fmt::Display for ContextStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "ArcBox Docker Integration Status:")?;
writeln!(
f,
" Context exists: {}",
if self.context_exists { "yes" } else { "no" }
)?;
writeln!(
f,
" Is default: {}",
if self.is_default { "yes" } else { "no" }
)?;
writeln!(f, " Socket path: {}", self.socket_path.display())?;
write!(
f,
" Socket exists: {}",
if self.socket_exists { "yes" } else { "no" }
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_context_hash() {
let hash = DockerContextManager::context_hash("arcbox");
assert_eq!(hash.len(), 64); assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_create_and_remove_context() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
assert!(!manager.context_exists());
manager.create_context().unwrap();
assert!(manager.context_exists());
let meta_path = manager.context_meta_path();
let meta_content = fs::read_to_string(&meta_path).unwrap();
let meta: ContextMeta = serde_json::from_str(&meta_content).unwrap();
assert_eq!(meta.name, "arcbox");
assert!(meta.endpoints.docker.host.starts_with("unix://"));
manager.remove_context().unwrap();
assert!(!manager.context_exists());
}
#[test]
fn test_set_and_restore_default() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.create_context().unwrap();
let config = DockerConfig {
current_context: Some("desktop-linux".to_string()),
..DockerConfig::default()
};
manager.write_docker_config(&config).unwrap();
manager.set_default().unwrap();
assert!(manager.is_default().unwrap());
assert_eq!(
manager.current_context().unwrap(),
Some("arcbox".to_string())
);
manager.restore_default().unwrap();
assert!(!manager.is_default().unwrap());
assert_eq!(
manager.current_context().unwrap(),
Some("desktop-linux".to_string())
);
}
#[test]
fn test_enable_disable() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.enable().unwrap();
assert!(manager.context_exists());
assert!(manager.is_default().unwrap());
manager.disable().unwrap();
assert!(manager.context_exists()); assert!(!manager.is_default().unwrap()); }
#[test]
fn test_status() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path.clone(), docker_config_dir);
let status = manager.status();
assert!(!status.context_exists);
assert!(!status.is_default);
assert_eq!(status.socket_path, socket_path);
assert!(!status.socket_exists);
fs::write(&socket_path, "").unwrap();
manager.enable().unwrap();
let status = manager.status();
assert!(status.context_exists);
assert!(status.is_default);
assert!(status.socket_exists);
}
#[test]
fn test_preserves_other_config_fields() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
fs::create_dir_all(&docker_config_dir).unwrap();
let config_path = docker_config_dir.join("config.json");
let initial_config = r#"{
"currentContext": "desktop-linux",
"credsStore": "osxkeychain",
"auths": {
"https://index.docker.io/v1/": {}
}
}"#;
fs::write(&config_path, initial_config).unwrap();
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.create_context().unwrap();
manager.set_default().unwrap();
let updated_config = fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&updated_config).unwrap();
assert_eq!(config["currentContext"], "arcbox");
assert_eq!(config["credsStore"], "osxkeychain");
assert!(config["auths"].is_object());
}
#[test]
fn test_create_context_is_idempotent() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.create_context().unwrap();
let first_meta = fs::read_to_string(manager.context_meta_path()).unwrap();
manager.create_context().unwrap();
let second_meta = fs::read_to_string(manager.context_meta_path()).unwrap();
assert_eq!(first_meta, second_meta);
}
#[test]
fn test_remove_default_context_restores_previous() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
let config = DockerConfig {
current_context: Some("orbstack".to_string()),
..DockerConfig::default()
};
manager.create_context().unwrap();
manager.write_docker_config(&config).unwrap();
manager.set_default().unwrap();
assert!(manager.is_default().unwrap());
manager.remove_context().unwrap();
assert!(!manager.context_exists());
assert_eq!(
manager.current_context().unwrap(),
Some("orbstack".to_string())
);
}
#[test]
fn test_set_default_without_previous_context() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.create_context().unwrap();
manager.set_default().unwrap();
assert!(manager.is_default().unwrap());
manager.restore_default().unwrap();
assert!(manager.current_context().unwrap().is_none());
}
#[test]
fn test_multiple_enable_disable_cycles() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
let config = DockerConfig {
current_context: Some("default".to_string()),
..DockerConfig::default()
};
fs::create_dir_all(manager.docker_config_dir()).unwrap();
manager.write_docker_config(&config).unwrap();
manager.enable().unwrap();
assert!(manager.is_default().unwrap());
manager.disable().unwrap();
assert_eq!(
manager.current_context().unwrap(),
Some("default".to_string())
);
manager.enable().unwrap();
assert!(manager.is_default().unwrap());
manager.disable().unwrap();
assert_eq!(
manager.current_context().unwrap(),
Some("default".to_string())
);
manager.enable().unwrap();
assert!(manager.is_default().unwrap());
manager.disable().unwrap();
assert_eq!(
manager.current_context().unwrap(),
Some("default".to_string())
);
}
#[test]
fn test_set_default_fails_without_context() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
let result = manager.set_default();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("does not exist"));
}
#[test]
fn test_disable_when_not_default_is_noop() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.create_context().unwrap();
assert!(!manager.is_default().unwrap());
manager.disable().unwrap();
assert!(!manager.is_default().unwrap());
}
#[test]
fn test_remove_nonexistent_context_is_noop() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.remove_context().unwrap();
assert!(!manager.context_exists());
}
#[test]
fn test_handles_empty_config_json() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
fs::create_dir_all(&docker_config_dir).unwrap();
let config_path = docker_config_dir.join("config.json");
fs::write(&config_path, "{}").unwrap();
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
assert!(manager.current_context().unwrap().is_none());
assert!(!manager.is_default().unwrap());
manager.enable().unwrap();
assert!(manager.is_default().unwrap());
}
#[test]
fn test_context_meta_json_format() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("test.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.create_context().unwrap();
let meta_content = fs::read_to_string(manager.context_meta_path()).unwrap();
let meta: serde_json::Value = serde_json::from_str(&meta_content).unwrap();
assert!(meta.get("Name").is_some());
assert!(meta.get("Metadata").is_some());
assert!(meta.get("Endpoints").is_some());
let endpoints = meta.get("Endpoints").unwrap();
let docker_endpoint = endpoints.get("docker").unwrap();
assert!(docker_endpoint.get("Host").is_some());
assert!(docker_endpoint.get("SkipTLSVerify").is_some());
let host = docker_endpoint.get("Host").unwrap().as_str().unwrap();
assert!(host.starts_with("unix://"));
assert!(host.contains("test.sock"));
}
#[test]
fn test_context_hash_is_deterministic() {
let hash1 = DockerContextManager::context_hash("arcbox");
let hash2 = DockerContextManager::context_hash("arcbox");
let hash3 = DockerContextManager::context_hash("arcbox");
assert_eq!(hash1, hash2);
assert_eq!(hash2, hash3);
let other_hash = DockerContextManager::context_hash("other-context");
assert_ne!(hash1, other_hash);
}
#[test]
fn test_context_directory_structure() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir.clone());
manager.create_context().unwrap();
let contexts_base = docker_config_dir.join("contexts");
let meta_dir = contexts_base.join("meta");
assert!(contexts_base.exists());
assert!(meta_dir.exists());
let hash = DockerContextManager::context_hash(ARCBOX_CONTEXT_NAME);
let hashed_dir = meta_dir.join(&hash);
assert!(hashed_dir.exists());
let meta_path = hashed_dir.join("meta.json");
assert!(meta_path.exists());
}
#[test]
fn test_full_lifecycle() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path.clone(), docker_config_dir);
assert!(!manager.context_exists());
let status = manager.status();
assert!(!status.context_exists);
assert!(!status.is_default);
manager.enable().unwrap();
assert!(manager.context_exists());
assert!(manager.is_default().unwrap());
fs::write(&socket_path, "").unwrap();
let status = manager.status();
assert!(status.socket_exists);
manager.disable().unwrap();
assert!(manager.context_exists()); assert!(!manager.is_default().unwrap());
manager.enable().unwrap();
assert!(manager.is_default().unwrap());
manager.remove_context().unwrap();
assert!(!manager.context_exists());
assert!(!manager.is_default().unwrap());
}
#[test]
fn test_switching_from_orbstack() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
fs::create_dir_all(&docker_config_dir).unwrap();
let config_path = docker_config_dir.join("config.json");
let orbstack_config = r#"{
"currentContext": "orbstack",
"credsStore": "osxkeychain",
"auths": {},
"plugins": {
"debug": {"hooks": "exec"}
}
}"#;
fs::write(&config_path, orbstack_config).unwrap();
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
manager.enable().unwrap();
assert!(manager.is_default().unwrap());
let updated_config = fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&updated_config).unwrap();
assert_eq!(config["currentContext"], "arcbox");
assert_eq!(config["credsStore"], "osxkeychain");
assert!(config["plugins"].is_object());
manager.disable().unwrap();
let restored_config = fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&restored_config).unwrap();
assert_eq!(config["currentContext"], "orbstack");
}
#[test]
fn test_socket_path_with_spaces() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("path with spaces/docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path.clone(), docker_config_dir);
manager.create_context().unwrap();
let meta_content = fs::read_to_string(manager.context_meta_path()).unwrap();
let meta: ContextMeta = serde_json::from_str(&meta_content).unwrap();
assert!(meta.endpoints.docker.host.contains("path with spaces"));
assert_eq!(
meta.endpoints.docker.host,
format!("unix://{}", socket_path.display())
);
}
#[test]
fn test_context_status_display() {
let temp_dir = tempdir().unwrap();
let socket_path = temp_dir.path().join("docker.sock");
let docker_config_dir = temp_dir.path().join(".docker");
let manager = DockerContextManager::with_config_dir(socket_path, docker_config_dir);
let status = manager.status();
let display = format!("{status}");
assert!(display.contains("Context exists:"));
assert!(display.contains("Is default:"));
assert!(display.contains("Socket path:"));
assert!(display.contains("Socket exists:"));
}
}