use std::ffi::OsString;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use crate::KitError;
const MIN_IDLE_STREAM_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Clone, Debug)]
pub struct OpenCodeConfig {
mode: OpenCodeMode,
}
#[derive(Clone, Debug)]
pub(crate) enum OpenCodeMode {
Spawn {
executable: PathBuf,
arguments: Vec<OsString>,
address: SocketAddr,
startup_timeout: Duration,
request_timeout: Duration,
idle_stream_timeout: Duration,
},
External {
base_url: reqwest::Url,
connect_timeout: Duration,
request_timeout: Duration,
idle_stream_timeout: Duration,
},
}
impl OpenCodeConfig {
pub fn spawn(
executable: PathBuf,
arguments: Vec<OsString>,
address: SocketAddr,
startup_timeout: Duration,
request_timeout: Duration,
idle_stream_timeout: Duration,
) -> Result<Self, KitError> {
if executable.as_os_str().is_empty() {
return Err(KitError::invalid_config(
"OpenCode executable path must not be empty",
));
}
if !address.ip().is_loopback() || address.port() == 0 {
return Err(KitError::invalid_config(
"OpenCode spawn address must be loopback with a nonzero port",
));
}
for argument in &arguments {
if Self::replaces_owned_routing(argument) {
return Err(KitError::invalid_config(
"OpenCode extra arguments must not replace serve, hostname, or port",
));
}
}
Self::validate_timeout("OpenCode startup timeout", startup_timeout)?;
Self::validate_timeout("OpenCode request timeout", request_timeout)?;
Self::validate_idle_timeout(idle_stream_timeout)?;
Ok(Self {
mode: OpenCodeMode::Spawn {
executable,
arguments,
address,
startup_timeout,
request_timeout,
idle_stream_timeout,
},
})
}
pub fn external(
base_url: impl AsRef<str>,
connect_timeout: Duration,
request_timeout: Duration,
idle_stream_timeout: Duration,
) -> Result<Self, KitError> {
let mut base_url = reqwest::Url::parse(base_url.as_ref())
.map_err(|_| KitError::invalid_config("OpenCode base URL is invalid"))?;
if !matches!(base_url.scheme(), "http" | "https")
|| !base_url.username().is_empty()
|| base_url.password().is_some()
|| base_url.query().is_some()
|| base_url.fragment().is_some()
{
return Err(KitError::invalid_config(
"OpenCode base URL must be credential-free canonical HTTP or HTTPS",
));
}
base_url.set_query(None);
base_url.set_fragment(None);
if !base_url.path().ends_with('/') {
let path = format!("{}/", base_url.path());
base_url.set_path(&path);
}
Self::validate_timeout("OpenCode connect timeout", connect_timeout)?;
Self::validate_timeout("OpenCode request timeout", request_timeout)?;
Self::validate_idle_timeout(idle_stream_timeout)?;
Ok(Self {
mode: OpenCodeMode::External {
base_url,
connect_timeout,
request_timeout,
idle_stream_timeout,
},
})
}
pub(crate) fn mode(&self) -> &OpenCodeMode {
&self.mode
}
pub(crate) fn validate(&self) -> Result<(), KitError> {
match &self.mode {
OpenCodeMode::Spawn {
executable,
arguments,
address,
startup_timeout,
request_timeout,
idle_stream_timeout,
} => {
if executable.as_os_str().is_empty()
|| !address.ip().is_loopback()
|| address.port() == 0
|| arguments.iter().any(Self::replaces_owned_routing)
{
return Err(KitError::invalid_config(
"OpenCode spawn configuration is invalid",
));
}
Self::validate_timeout("OpenCode startup timeout", *startup_timeout)?;
Self::validate_timeout("OpenCode request timeout", *request_timeout)?;
Self::validate_idle_timeout(*idle_stream_timeout)
}
OpenCodeMode::External {
base_url,
connect_timeout,
request_timeout,
idle_stream_timeout,
} => {
if !matches!(base_url.scheme(), "http" | "https")
|| !base_url.username().is_empty()
|| base_url.password().is_some()
{
return Err(KitError::invalid_config(
"OpenCode external configuration is invalid",
));
}
Self::validate_timeout("OpenCode connect timeout", *connect_timeout)?;
Self::validate_timeout("OpenCode request timeout", *request_timeout)?;
Self::validate_idle_timeout(*idle_stream_timeout)
}
}
}
fn validate_timeout(label: &str, timeout: Duration) -> Result<(), KitError> {
if timeout.is_zero() {
return Err(KitError::invalid_config(format!(
"{label} must be greater than zero"
)));
}
Ok(())
}
fn validate_idle_timeout(timeout: Duration) -> Result<(), KitError> {
if timeout < MIN_IDLE_STREAM_TIMEOUT {
return Err(KitError::invalid_config(
"OpenCode idle stream timeout must be at least thirty seconds",
));
}
Ok(())
}
fn replaces_owned_routing(argument: &OsString) -> bool {
let argument = argument.to_string_lossy();
matches!(argument.as_ref(), "serve" | "--" | "--hostname" | "--port")
|| argument.starts_with("--hostname=")
|| argument.starts_with("--port=")
}
}
impl OpenCodeMode {
pub(crate) fn base_url(&self) -> reqwest::Url {
match self {
Self::Spawn { address, .. } => reqwest::Url::parse(&format!("http://{address}/"))
.expect("validated SocketAddr always forms an HTTP URL"),
Self::External { base_url, .. } => base_url.clone(),
}
}
pub(crate) fn request_timeout(&self) -> Duration {
match self {
Self::Spawn {
request_timeout, ..
}
| Self::External {
request_timeout, ..
} => *request_timeout,
}
}
pub(crate) fn idle_stream_timeout(&self) -> Duration {
match self {
Self::Spawn {
idle_stream_timeout,
..
}
| Self::External {
idle_stream_timeout,
..
} => *idle_stream_timeout,
}
}
pub(crate) fn connect_timeout(&self) -> Option<Duration> {
match self {
Self::External {
connect_timeout, ..
} => Some(*connect_timeout),
Self::Spawn { .. } => None,
}
}
}