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)]
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,
}
#[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>,
},
}
#[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,
}
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(),
}
}
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(),
}
}
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,
} => (
Arc::new(
crate::services::authority::docker_spawner::DockerExecSpawner::new(
container_id.clone(),
user.clone(),
workspace.clone(),
),
),
Arc::new(
crate::services::authority::docker_spawner::DockerLongRunningSpawner::new(
container_id,
user,
workspace,
),
),
),
};
let terminal_wrapper = match payload.terminal_wrapper {
TerminalWrapperSpec::HostShell => TerminalWrapper::host_shell(),
TerminalWrapperSpec::Explicit {
command,
args,
manages_cwd,
} => TerminalWrapper {
command,
args,
manages_cwd,
},
};
Ok(Self {
filesystem,
process_spawner,
long_running_spawner,
terminal_wrapper,
display_label: payload.display_label,
})
}
}
#[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(),
};
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_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()),
},
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(),
};
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");
}
}