use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::model::filesystem::{FileSystem, StdFileSystem};
use crate::services::remote::{
LocalLongRunningSpawner, LocalProcessSpawner, LongRunningSpawner, ProcessSpawner,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathTranslationSpec {
pub host_root: String,
pub remote_root: String,
}
#[derive(Debug, Clone)]
pub struct PathTranslation {
pub host_root: PathBuf,
pub remote_root: PathBuf,
}
impl PathTranslation {
pub fn host_to_remote(&self, host: &Path) -> Option<PathBuf> {
let rel = host.strip_prefix(&self.host_root).ok()?;
Some(self.remote_root.join(rel))
}
pub fn remote_to_host(&self, remote: &Path) -> Option<PathBuf> {
let rel = remote.strip_prefix(&self.remote_root).ok()?;
Some(self.host_root.join(rel))
}
}
#[derive(Debug, Clone)]
pub struct TerminalWrapper {
pub command: String,
pub args: Vec<String>,
pub manages_cwd: bool,
}
impl TerminalWrapper {
pub fn host_shell() -> Self {
Self {
command: crate::services::terminal::manager::detect_shell(),
args: Vec::new(),
manages_cwd: false,
}
}
pub fn with_user_shell_override(
mut self,
shell: Option<&crate::config::TerminalShellConfig>,
) -> Self {
if let Some(shell) = shell {
if !self.manages_cwd {
self.command = shell.command.clone();
self.args = shell.args.clone();
}
}
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorityPayload {
pub filesystem: FilesystemSpec,
pub spawner: SpawnerSpec,
pub terminal_wrapper: TerminalWrapperSpec,
#[serde(default)]
pub display_label: String,
#[serde(default)]
pub path_translation: Option<PathTranslationSpec>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum FilesystemSpec {
Local,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum SpawnerSpec {
Local,
DockerExec {
container_id: String,
#[serde(default)]
user: Option<String>,
#[serde(default)]
workspace: Option<String>,
#[serde(default)]
env: Vec<(String, String)>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum TerminalWrapperSpec {
HostShell,
Explicit {
command: String,
args: Vec<String>,
#[serde(default = "default_true")]
manages_cwd: bool,
},
}
fn default_true() -> bool {
true
}
#[derive(Clone)]
pub struct Authority {
pub filesystem: Arc<dyn FileSystem + Send + Sync>,
pub process_spawner: Arc<dyn ProcessSpawner>,
pub long_running_spawner: Arc<dyn LongRunningSpawner>,
pub terminal_wrapper: TerminalWrapper,
pub display_label: String,
pub path_translation: Option<PathTranslation>,
}
impl Authority {
pub fn local() -> Self {
Self {
filesystem: Arc::new(StdFileSystem),
process_spawner: Arc::new(LocalProcessSpawner),
long_running_spawner: Arc::new(LocalLongRunningSpawner),
terminal_wrapper: TerminalWrapper::host_shell(),
display_label: String::new(),
path_translation: None,
}
}
pub fn ssh(
filesystem: Arc<dyn FileSystem + Send + Sync>,
process_spawner: Arc<dyn ProcessSpawner>,
) -> Self {
Self {
filesystem,
process_spawner,
long_running_spawner: Arc::new(LocalLongRunningSpawner),
terminal_wrapper: TerminalWrapper::host_shell(),
display_label: String::new(),
path_translation: None,
}
}
pub fn from_plugin_payload(payload: AuthorityPayload) -> Result<Self, AuthorityPayloadError> {
let filesystem: Arc<dyn FileSystem + Send + Sync> = match payload.filesystem {
FilesystemSpec::Local => Arc::new(StdFileSystem),
};
let (process_spawner, long_running_spawner): (
Arc<dyn ProcessSpawner>,
Arc<dyn LongRunningSpawner>,
) = match payload.spawner {
SpawnerSpec::Local => (
Arc::new(LocalProcessSpawner),
Arc::new(LocalLongRunningSpawner),
),
SpawnerSpec::DockerExec {
container_id,
user,
workspace,
env,
} => (
Arc::new(
crate::services::authority::docker_spawner::DockerExecSpawner::with_env(
container_id.clone(),
user.clone(),
workspace.clone(),
env.clone(),
),
),
Arc::new(
crate::services::authority::docker_spawner::DockerLongRunningSpawner::with_env(
container_id,
user,
workspace,
env,
),
),
),
};
let terminal_wrapper = match payload.terminal_wrapper {
TerminalWrapperSpec::HostShell => TerminalWrapper::host_shell(),
TerminalWrapperSpec::Explicit {
command,
args,
manages_cwd,
} => TerminalWrapper {
command,
args,
manages_cwd,
},
};
let path_translation = payload.path_translation.map(|spec| PathTranslation {
host_root: PathBuf::from(spec.host_root),
remote_root: PathBuf::from(spec.remote_root),
});
Ok(Self {
filesystem,
process_spawner,
long_running_spawner,
terminal_wrapper,
display_label: payload.display_label,
path_translation,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum AuthorityPayloadError {
#[error("invalid authority payload: {0}")]
Invalid(String),
}
mod docker_spawner;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_authority_uses_host_shell_with_no_args() {
let auth = Authority::local();
assert!(!auth.terminal_wrapper.command.is_empty());
assert!(auth.terminal_wrapper.args.is_empty());
assert!(!auth.terminal_wrapper.manages_cwd);
assert_eq!(auth.display_label, "");
}
#[test]
fn from_plugin_payload_local_yields_host_shell() {
let payload = AuthorityPayload {
filesystem: FilesystemSpec::Local,
spawner: SpawnerSpec::Local,
terminal_wrapper: TerminalWrapperSpec::HostShell,
display_label: String::new(),
path_translation: None,
};
let auth = Authority::from_plugin_payload(payload).expect("local payload is valid");
assert!(!auth.terminal_wrapper.command.is_empty());
assert!(auth.terminal_wrapper.args.is_empty());
}
#[test]
fn payload_roundtrips_through_serde_json() {
let json = serde_json::json!({
"filesystem": { "kind": "local" },
"spawner": {
"kind": "docker-exec",
"container_id": "abc123",
"user": "vscode",
"workspace": "/workspaces/proj"
},
"terminal_wrapper": {
"kind": "explicit",
"command": "docker",
"args": ["exec", "-it", "abc123", "bash", "-l"],
"manages_cwd": true
},
"display_label": "Container:abc123"
});
let payload: AuthorityPayload =
serde_json::from_value(json).expect("json matches payload schema");
let auth = Authority::from_plugin_payload(payload).expect("docker payload is valid");
assert_eq!(auth.terminal_wrapper.command, "docker");
assert!(auth.terminal_wrapper.manages_cwd);
assert_eq!(auth.display_label, "Container:abc123");
}
#[test]
fn payload_accepts_docker_exec_env_pairs() {
let json = serde_json::json!({
"filesystem": { "kind": "local" },
"spawner": {
"kind": "docker-exec",
"container_id": "abc123",
"user": "vscode",
"workspace": "/workspaces/proj",
"env": [
["PATH", "/home/vscode/.local/bin:/usr/bin"],
["LANG", "C.UTF-8"]
]
},
"terminal_wrapper": { "kind": "host-shell" }
});
let payload: AuthorityPayload =
serde_json::from_value(json).expect("env field is accepted");
if let SpawnerSpec::DockerExec { env, .. } = &payload.spawner {
assert_eq!(env.len(), 2);
assert_eq!(
env[0],
("PATH".into(), "/home/vscode/.local/bin:/usr/bin".into())
);
assert_eq!(env[1], ("LANG".into(), "C.UTF-8".into()));
} else {
panic!("expected docker-exec spawner");
}
let json_no_env = serde_json::json!({
"filesystem": { "kind": "local" },
"spawner": {
"kind": "docker-exec",
"container_id": "abc123"
},
"terminal_wrapper": { "kind": "host-shell" }
});
let payload2: AuthorityPayload =
serde_json::from_value(json_no_env).expect("env is optional");
if let SpawnerSpec::DockerExec { env, .. } = payload2.spawner {
assert!(env.is_empty());
} else {
panic!("expected docker-exec spawner");
}
}
#[test]
fn payload_defaults_manages_cwd_to_true_for_explicit_wrapper() {
let json = serde_json::json!({
"filesystem": { "kind": "local" },
"spawner": { "kind": "local" },
"terminal_wrapper": {
"kind": "explicit",
"command": "bash",
"args": []
}
});
let payload: AuthorityPayload =
serde_json::from_value(json).expect("manages_cwd is optional");
let auth = Authority::from_plugin_payload(payload).expect("payload is valid");
assert!(auth.terminal_wrapper.manages_cwd);
assert_eq!(auth.display_label, "");
}
#[test]
fn user_shell_override_replaces_host_shell_wrapper() {
let override_shell = crate::config::TerminalShellConfig {
command: "/usr/local/bin/fish".into(),
args: vec!["-l".into(), "-i".into()],
};
let wrapper = TerminalWrapper::host_shell().with_user_shell_override(Some(&override_shell));
assert_eq!(wrapper.command, "/usr/local/bin/fish");
assert_eq!(wrapper.args, vec!["-l".to_string(), "-i".to_string()]);
assert!(!wrapper.manages_cwd);
}
#[test]
fn user_shell_override_is_noop_when_wrapper_manages_cwd() {
let docker = TerminalWrapper {
command: "docker".into(),
args: vec![
"exec".into(),
"-w".into(),
"/workspaces/proj".into(),
"abc123".into(),
"bash".into(),
],
manages_cwd: true,
};
let override_shell = crate::config::TerminalShellConfig {
command: "/usr/local/bin/fish".into(),
args: vec![],
};
let wrapper = docker
.clone()
.with_user_shell_override(Some(&override_shell));
assert_eq!(wrapper.command, docker.command);
assert_eq!(wrapper.args, docker.args);
assert!(wrapper.manages_cwd);
}
#[test]
fn user_shell_override_none_leaves_wrapper_unchanged() {
let original = TerminalWrapper::host_shell();
let wrapper = original.clone().with_user_shell_override(None);
assert_eq!(wrapper.command, original.command);
assert_eq!(wrapper.args, original.args);
assert_eq!(wrapper.manages_cwd, original.manages_cwd);
}
#[test]
fn from_plugin_payload_docker_exec_carries_label() {
let payload = AuthorityPayload {
filesystem: FilesystemSpec::Local,
spawner: SpawnerSpec::DockerExec {
container_id: "abc123".into(),
user: Some("vscode".into()),
workspace: Some("/workspaces/proj".into()),
env: Vec::new(),
},
terminal_wrapper: TerminalWrapperSpec::Explicit {
command: "docker".into(),
args: vec![
"exec".into(),
"-it".into(),
"-u".into(),
"vscode".into(),
"-w".into(),
"/workspaces/proj".into(),
"abc123".into(),
"bash".into(),
"-l".into(),
],
manages_cwd: true,
},
display_label: "Container:abc123".into(),
path_translation: None,
};
let auth = Authority::from_plugin_payload(payload).expect("docker payload is valid");
assert_eq!(auth.terminal_wrapper.command, "docker");
assert!(auth.terminal_wrapper.manages_cwd);
assert_eq!(auth.display_label, "Container:abc123");
}
#[test]
fn path_translation_round_trips_under_workspace() {
let pt = PathTranslation {
host_root: PathBuf::from("/tmp/.tmpA1B2"),
remote_root: PathBuf::from("/workspaces/proj"),
};
let host = Path::new("/tmp/.tmpA1B2/src/util.py");
let remote = pt.host_to_remote(host).expect("host under host_root");
assert_eq!(remote, PathBuf::from("/workspaces/proj/src/util.py"));
assert_eq!(
pt.remote_to_host(&remote)
.expect("remote under remote_root"),
host.to_path_buf(),
);
}
#[test]
fn path_translation_returns_none_outside_root() {
let pt = PathTranslation {
host_root: PathBuf::from("/host/proj"),
remote_root: PathBuf::from("/workspaces/proj"),
};
assert!(pt
.host_to_remote(Path::new("/usr/include/stdio.h"))
.is_none());
assert!(pt
.remote_to_host(Path::new("/usr/include/stdio.h"))
.is_none());
}
#[test]
fn from_plugin_payload_with_path_translation_round_trips() {
let json = serde_json::json!({
"filesystem": { "kind": "local" },
"spawner": {
"kind": "docker-exec",
"container_id": "abc123",
"workspace": "/workspaces/proj"
},
"terminal_wrapper": { "kind": "host-shell" },
"path_translation": {
"host_root": "/tmp/.tmpA1B2",
"remote_root": "/workspaces/proj"
}
});
let payload: AuthorityPayload =
serde_json::from_value(json).expect("path_translation is accepted");
let auth =
Authority::from_plugin_payload(payload).expect("payload with translation is valid");
let pt = auth
.path_translation
.expect("authority carries the translation");
assert_eq!(pt.host_root, PathBuf::from("/tmp/.tmpA1B2"));
assert_eq!(pt.remote_root, PathBuf::from("/workspaces/proj"));
}
}