1use std::ffi::OsString;
2use std::net::SocketAddr;
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::KitError;
7
8const MIN_IDLE_STREAM_TIMEOUT: Duration = Duration::from_secs(30);
9
10#[derive(Clone, Debug)]
35pub struct OpenCodeConfig {
36 mode: OpenCodeMode,
37}
38
39#[derive(Clone, Debug)]
40pub(crate) enum OpenCodeMode {
41 Spawn {
42 executable: PathBuf,
43 arguments: Vec<OsString>,
44 address: SocketAddr,
45 startup_timeout: Duration,
46 request_timeout: Duration,
47 idle_stream_timeout: Duration,
48 },
49 External {
50 base_url: reqwest::Url,
51 connect_timeout: Duration,
52 request_timeout: Duration,
53 idle_stream_timeout: Duration,
54 },
55}
56
57impl OpenCodeConfig {
58 pub fn spawn(
79 executable: PathBuf,
80 arguments: Vec<OsString>,
81 address: SocketAddr,
82 startup_timeout: Duration,
83 request_timeout: Duration,
84 idle_stream_timeout: Duration,
85 ) -> Result<Self, KitError> {
86 if executable.as_os_str().is_empty() {
87 return Err(KitError::invalid_config(
88 "OpenCode executable path must not be empty",
89 ));
90 }
91 if !address.ip().is_loopback() || address.port() == 0 {
92 return Err(KitError::invalid_config(
93 "OpenCode spawn address must be loopback with a nonzero port",
94 ));
95 }
96 for argument in &arguments {
97 if Self::replaces_owned_routing(argument) {
98 return Err(KitError::invalid_config(
99 "OpenCode extra arguments must not replace serve, hostname, or port",
100 ));
101 }
102 }
103 Self::validate_timeout("OpenCode startup timeout", startup_timeout)?;
104 Self::validate_timeout("OpenCode request timeout", request_timeout)?;
105 Self::validate_idle_timeout(idle_stream_timeout)?;
106 Ok(Self {
107 mode: OpenCodeMode::Spawn {
108 executable,
109 arguments,
110 address,
111 startup_timeout,
112 request_timeout,
113 idle_stream_timeout,
114 },
115 })
116 }
117
118 pub fn external(
139 base_url: impl AsRef<str>,
140 connect_timeout: Duration,
141 request_timeout: Duration,
142 idle_stream_timeout: Duration,
143 ) -> Result<Self, KitError> {
144 let mut base_url = reqwest::Url::parse(base_url.as_ref())
145 .map_err(|_| KitError::invalid_config("OpenCode base URL is invalid"))?;
146 if !matches!(base_url.scheme(), "http" | "https")
147 || !base_url.username().is_empty()
148 || base_url.password().is_some()
149 || base_url.query().is_some()
150 || base_url.fragment().is_some()
151 {
152 return Err(KitError::invalid_config(
153 "OpenCode base URL must be credential-free canonical HTTP or HTTPS",
154 ));
155 }
156 base_url.set_query(None);
157 base_url.set_fragment(None);
158 if !base_url.path().ends_with('/') {
159 let path = format!("{}/", base_url.path());
160 base_url.set_path(&path);
161 }
162 Self::validate_timeout("OpenCode connect timeout", connect_timeout)?;
163 Self::validate_timeout("OpenCode request timeout", request_timeout)?;
164 Self::validate_idle_timeout(idle_stream_timeout)?;
165 Ok(Self {
166 mode: OpenCodeMode::External {
167 base_url,
168 connect_timeout,
169 request_timeout,
170 idle_stream_timeout,
171 },
172 })
173 }
174
175 pub(crate) fn mode(&self) -> &OpenCodeMode {
176 &self.mode
177 }
178
179 pub(crate) fn validate(&self) -> Result<(), KitError> {
180 match &self.mode {
181 OpenCodeMode::Spawn {
182 executable,
183 arguments,
184 address,
185 startup_timeout,
186 request_timeout,
187 idle_stream_timeout,
188 } => {
189 if executable.as_os_str().is_empty()
190 || !address.ip().is_loopback()
191 || address.port() == 0
192 || arguments.iter().any(Self::replaces_owned_routing)
193 {
194 return Err(KitError::invalid_config(
195 "OpenCode spawn configuration is invalid",
196 ));
197 }
198 Self::validate_timeout("OpenCode startup timeout", *startup_timeout)?;
199 Self::validate_timeout("OpenCode request timeout", *request_timeout)?;
200 Self::validate_idle_timeout(*idle_stream_timeout)
201 }
202 OpenCodeMode::External {
203 base_url,
204 connect_timeout,
205 request_timeout,
206 idle_stream_timeout,
207 } => {
208 if !matches!(base_url.scheme(), "http" | "https")
209 || !base_url.username().is_empty()
210 || base_url.password().is_some()
211 {
212 return Err(KitError::invalid_config(
213 "OpenCode external configuration is invalid",
214 ));
215 }
216 Self::validate_timeout("OpenCode connect timeout", *connect_timeout)?;
217 Self::validate_timeout("OpenCode request timeout", *request_timeout)?;
218 Self::validate_idle_timeout(*idle_stream_timeout)
219 }
220 }
221 }
222
223 fn validate_timeout(label: &str, timeout: Duration) -> Result<(), KitError> {
224 if timeout.is_zero() {
225 return Err(KitError::invalid_config(format!(
226 "{label} must be greater than zero"
227 )));
228 }
229 Ok(())
230 }
231
232 fn validate_idle_timeout(timeout: Duration) -> Result<(), KitError> {
233 if timeout < MIN_IDLE_STREAM_TIMEOUT {
234 return Err(KitError::invalid_config(
235 "OpenCode idle stream timeout must be at least thirty seconds",
236 ));
237 }
238 Ok(())
239 }
240
241 fn replaces_owned_routing(argument: &OsString) -> bool {
242 let argument = argument.to_string_lossy();
243 matches!(argument.as_ref(), "serve" | "--" | "--hostname" | "--port")
244 || argument.starts_with("--hostname=")
245 || argument.starts_with("--port=")
246 }
247}
248
249impl OpenCodeMode {
250 pub(crate) fn base_url(&self) -> reqwest::Url {
251 match self {
252 Self::Spawn { address, .. } => reqwest::Url::parse(&format!("http://{address}/"))
253 .expect("validated SocketAddr always forms an HTTP URL"),
254 Self::External { base_url, .. } => base_url.clone(),
255 }
256 }
257
258 pub(crate) fn request_timeout(&self) -> Duration {
259 match self {
260 Self::Spawn {
261 request_timeout, ..
262 }
263 | Self::External {
264 request_timeout, ..
265 } => *request_timeout,
266 }
267 }
268
269 pub(crate) fn idle_stream_timeout(&self) -> Duration {
270 match self {
271 Self::Spawn {
272 idle_stream_timeout,
273 ..
274 }
275 | Self::External {
276 idle_stream_timeout,
277 ..
278 } => *idle_stream_timeout,
279 }
280 }
281
282 pub(crate) fn connect_timeout(&self) -> Option<Duration> {
283 match self {
284 Self::External {
285 connect_timeout, ..
286 } => Some(*connect_timeout),
287 Self::Spawn { .. } => None,
288 }
289 }
290}