kcl_lib/engine/
mod.rs

1//! Functions for managing engine communications.
2
3#[cfg(not(target_arch = "wasm32"))]
4#[cfg(feature = "engine")]
5pub mod conn;
6pub mod conn_mock;
7#[cfg(target_arch = "wasm32")]
8#[cfg(feature = "engine")]
9pub mod conn_wasm;
10
11use std::{collections::HashMap, sync::Arc};
12
13use indexmap::IndexMap;
14use kcmc::{
15    each_cmd as mcmd,
16    id::ModelingCmdId,
17    length_unit::LengthUnit,
18    ok_response::OkModelingCmdResponse,
19    shared::Color,
20    websocket::{
21        BatchResponse, ModelingBatch, ModelingCmdReq, ModelingSessionData, OkWebSocketResponseData, WebSocketRequest,
22        WebSocketResponse,
23    },
24    ModelingCmd,
25};
26use kittycad_modeling_cmds as kcmc;
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use tokio::sync::RwLock;
30use uuid::Uuid;
31
32use crate::{
33    errors::{KclError, KclErrorDetails},
34    execution::{ArtifactCommand, DefaultPlanes, IdGenerator, Point3d},
35    SourceRange,
36};
37
38lazy_static::lazy_static! {
39    pub static ref GRID_OBJECT_ID: uuid::Uuid = uuid::Uuid::parse_str("cfa78409-653d-4c26-96f1-7c45fb784840").unwrap();
40
41    pub static ref GRID_SCALE_TEXT_OBJECT_ID: uuid::Uuid = uuid::Uuid::parse_str("10782f33-f588-4668-8bcd-040502d26590").unwrap();
42}
43
44/// The mode of execution.  When isolated, like during an import, attempting to
45/// send a command results in an error.
46#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, ts_rs::TS, JsonSchema)]
47#[ts(export)]
48#[serde(rename_all = "camelCase")]
49pub enum ExecutionKind {
50    #[default]
51    Normal,
52    Isolated,
53}
54
55impl ExecutionKind {
56    pub fn is_isolated(&self) -> bool {
57        matches!(self, ExecutionKind::Isolated)
58    }
59}
60
61#[async_trait::async_trait]
62pub trait EngineManager: std::fmt::Debug + Send + Sync + 'static {
63    /// Get the batch of commands to be sent to the engine.
64    fn batch(&self) -> Arc<RwLock<Vec<(WebSocketRequest, SourceRange)>>>;
65
66    /// Get the batch of end commands to be sent to the engine.
67    fn batch_end(&self) -> Arc<RwLock<IndexMap<uuid::Uuid, (WebSocketRequest, SourceRange)>>>;
68
69    /// Get the command responses from the engine.
70    fn responses(&self) -> Arc<RwLock<IndexMap<Uuid, WebSocketResponse>>>;
71
72    /// Get the artifact commands that have accumulated so far.
73    fn artifact_commands(&self) -> Arc<RwLock<Vec<ArtifactCommand>>>;
74
75    /// Clear all artifact commands that have accumulated so far.
76    async fn clear_artifact_commands(&self) {
77        self.artifact_commands().write().await.clear();
78    }
79
80    /// Take the artifact commands that have accumulated so far and clear them.
81    async fn take_artifact_commands(&self) -> Vec<ArtifactCommand> {
82        std::mem::take(&mut *self.artifact_commands().write().await)
83    }
84
85    /// Take the responses that have accumulated so far and clear them.
86    async fn take_responses(&self) -> IndexMap<Uuid, WebSocketResponse> {
87        std::mem::take(&mut *self.responses().write().await)
88    }
89
90    /// Get the current execution kind.
91    async fn execution_kind(&self) -> ExecutionKind;
92
93    /// Replace the current execution kind with a new value and return the
94    /// existing value.
95    async fn replace_execution_kind(&self, execution_kind: ExecutionKind) -> ExecutionKind;
96
97    /// Get the default planes.
98    async fn default_planes(
99        &self,
100        id_generator: &mut IdGenerator,
101        _source_range: SourceRange,
102    ) -> Result<DefaultPlanes, crate::errors::KclError>;
103
104    /// Helpers to be called after clearing a scene.
105    /// (These really only apply to wasm for now).
106    async fn clear_scene_post_hook(
107        &self,
108        id_generator: &mut IdGenerator,
109        source_range: SourceRange,
110    ) -> Result<(), crate::errors::KclError>;
111
112    /// Send a modeling command and wait for the response message.
113    async fn inner_send_modeling_cmd(
114        &self,
115        id: uuid::Uuid,
116        source_range: SourceRange,
117        cmd: WebSocketRequest,
118        id_to_source_range: HashMap<uuid::Uuid, SourceRange>,
119    ) -> Result<kcmc::websocket::WebSocketResponse, crate::errors::KclError>;
120
121    async fn clear_scene(
122        &self,
123        id_generator: &mut IdGenerator,
124        source_range: SourceRange,
125    ) -> Result<(), crate::errors::KclError> {
126        self.batch_modeling_cmd(
127            uuid::Uuid::new_v4(),
128            source_range,
129            &ModelingCmd::SceneClearAll(mcmd::SceneClearAll::default()),
130        )
131        .await?;
132
133        // Flush the batch queue, so clear is run right away.
134        // Otherwise the hooks below won't work.
135        self.flush_batch(false, source_range).await?;
136
137        // Ensure artifact commands are cleared so that we don't accumulate them
138        // across runs.
139        self.clear_artifact_commands().await;
140
141        // Do the after clear scene hook.
142        self.clear_scene_post_hook(id_generator, source_range).await?;
143
144        Ok(())
145    }
146
147    /// Set the visibility of edges.
148    async fn set_edge_visibility(
149        &self,
150        visible: bool,
151        source_range: SourceRange,
152    ) -> Result<(), crate::errors::KclError> {
153        self.batch_modeling_cmd(
154            uuid::Uuid::new_v4(),
155            source_range,
156            &ModelingCmd::from(mcmd::EdgeLinesVisible { hidden: !visible }),
157        )
158        .await?;
159
160        Ok(())
161    }
162
163    async fn handle_artifact_command(
164        &self,
165        cmd: &ModelingCmd,
166        cmd_id: ModelingCmdId,
167        id_to_source_range: &HashMap<Uuid, SourceRange>,
168    ) -> Result<(), KclError> {
169        let cmd_id = *cmd_id.as_ref();
170        let range = id_to_source_range
171            .get(&cmd_id)
172            .copied()
173            .ok_or_else(|| KclError::internal(format!("Failed to get source range for command ID: {:?}", cmd_id)))?;
174
175        // Add artifact command.
176        self.artifact_commands().write().await.push(ArtifactCommand {
177            cmd_id,
178            range,
179            command: cmd.clone(),
180        });
181        Ok(())
182    }
183
184    async fn set_units(
185        &self,
186        units: crate::UnitLength,
187        source_range: SourceRange,
188    ) -> Result<(), crate::errors::KclError> {
189        // Before we even start executing the program, set the units.
190        self.batch_modeling_cmd(
191            uuid::Uuid::new_v4(),
192            source_range,
193            &ModelingCmd::from(mcmd::SetSceneUnits { unit: units.into() }),
194        )
195        .await?;
196
197        Ok(())
198    }
199
200    /// Re-run the command to apply the settings.
201    async fn reapply_settings(
202        &self,
203        settings: &crate::ExecutorSettings,
204        source_range: SourceRange,
205    ) -> Result<(), crate::errors::KclError> {
206        // Set the edge visibility.
207        self.set_edge_visibility(settings.highlight_edges, source_range).await?;
208
209        // Change the units.
210        self.set_units(settings.units, source_range).await?;
211
212        // Send the command to show the grid.
213        self.modify_grid(!settings.show_grid, source_range).await?;
214
215        // We do not have commands for changing ssao on the fly.
216
217        // Flush the batch queue, so the settings are applied right away.
218        self.flush_batch(false, source_range).await?;
219
220        Ok(())
221    }
222
223    // Add a modeling command to the batch but don't fire it right away.
224    async fn batch_modeling_cmd(
225        &self,
226        id: uuid::Uuid,
227        source_range: SourceRange,
228        cmd: &ModelingCmd,
229    ) -> Result<(), crate::errors::KclError> {
230        // In isolated mode, we don't send the command to the engine.
231        if self.execution_kind().await.is_isolated() {
232            return Ok(());
233        }
234
235        let req = WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
236            cmd: cmd.clone(),
237            cmd_id: id.into(),
238        });
239
240        // Add cmd to the batch.
241        self.batch().write().await.push((req, source_range));
242
243        Ok(())
244    }
245
246    /// Add a command to the batch that needs to be executed at the very end.
247    /// This for stuff like fillets or chamfers where if we execute too soon the
248    /// engine will eat the ID and we can't reference it for other commands.
249    async fn batch_end_cmd(
250        &self,
251        id: uuid::Uuid,
252        source_range: SourceRange,
253        cmd: &ModelingCmd,
254    ) -> Result<(), crate::errors::KclError> {
255        // In isolated mode, we don't send the command to the engine.
256        if self.execution_kind().await.is_isolated() {
257            return Ok(());
258        }
259
260        let req = WebSocketRequest::ModelingCmdReq(ModelingCmdReq {
261            cmd: cmd.clone(),
262            cmd_id: id.into(),
263        });
264
265        // Add cmd to the batch end.
266        self.batch_end().write().await.insert(id, (req, source_range));
267        Ok(())
268    }
269
270    /// Send the modeling cmd and wait for the response.
271    async fn send_modeling_cmd(
272        &self,
273        id: uuid::Uuid,
274        source_range: SourceRange,
275        cmd: &ModelingCmd,
276    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
277        self.batch_modeling_cmd(id, source_range, cmd).await?;
278
279        // Flush the batch queue.
280        self.flush_batch(false, source_range).await
281    }
282
283    /// Force flush the batch queue.
284    async fn flush_batch(
285        &self,
286        // Whether or not to flush the end commands as well.
287        // We only do this at the very end of the file.
288        batch_end: bool,
289        source_range: SourceRange,
290    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
291        let all_requests = if batch_end {
292            let mut requests = self.batch().read().await.clone();
293            requests.extend(self.batch_end().read().await.values().cloned());
294            requests
295        } else {
296            self.batch().read().await.clone()
297        };
298
299        // Return early if we have no commands to send.
300        if all_requests.is_empty() {
301            return Ok(OkWebSocketResponseData::Modeling {
302                modeling_response: OkModelingCmdResponse::Empty {},
303            });
304        }
305
306        let requests: Vec<ModelingCmdReq> = all_requests
307            .iter()
308            .filter_map(|(val, _)| match val {
309                WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd, cmd_id }) => Some(ModelingCmdReq {
310                    cmd: cmd.clone(),
311                    cmd_id: *cmd_id,
312                }),
313                _ => None,
314            })
315            .collect();
316
317        let batched_requests = WebSocketRequest::ModelingCmdBatchReq(ModelingBatch {
318            requests,
319            batch_id: uuid::Uuid::new_v4().into(),
320            responses: true,
321        });
322
323        let final_req = if all_requests.len() == 1 {
324            // We can unwrap here because we know the batch has only one element.
325            all_requests.first().unwrap().0.clone()
326        } else {
327            batched_requests
328        };
329
330        // Create the map of original command IDs to source range.
331        // This is for the wasm side, kurt needs it for selections.
332        let mut id_to_source_range = HashMap::new();
333        for (req, range) in all_requests.iter() {
334            match req {
335                WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: _, cmd_id }) => {
336                    id_to_source_range.insert(Uuid::from(*cmd_id), *range);
337                }
338                _ => {
339                    return Err(KclError::Engine(KclErrorDetails {
340                        message: format!("The request is not a modeling command: {:?}", req),
341                        source_ranges: vec![*range],
342                    }));
343                }
344            }
345        }
346
347        // Do the artifact commands.
348        for (req, _) in all_requests.iter() {
349            match &req {
350                WebSocketRequest::ModelingCmdBatchReq(ModelingBatch { requests, .. }) => {
351                    for request in requests {
352                        self.handle_artifact_command(&request.cmd, request.cmd_id, &id_to_source_range)
353                            .await?;
354                    }
355                }
356                WebSocketRequest::ModelingCmdReq(request) => {
357                    self.handle_artifact_command(&request.cmd, request.cmd_id, &id_to_source_range)
358                        .await?;
359                }
360                _ => {}
361            }
362        }
363
364        // Throw away the old batch queue.
365        self.batch().write().await.clear();
366        if batch_end {
367            self.batch_end().write().await.clear();
368        }
369
370        // We pop off the responses to cleanup our mappings.
371        match final_req {
372            WebSocketRequest::ModelingCmdBatchReq(ModelingBatch {
373                ref requests,
374                batch_id,
375                responses: _,
376            }) => {
377                // Get the last command ID.
378                let last_id = requests.last().unwrap().cmd_id;
379                let ws_resp = self
380                    .inner_send_modeling_cmd(batch_id.into(), source_range, final_req, id_to_source_range.clone())
381                    .await?;
382                let response = self.parse_websocket_response(ws_resp, source_range)?;
383
384                // If we have a batch response, we want to return the specific id we care about.
385                if let OkWebSocketResponseData::ModelingBatch { responses } = response {
386                    let responses = responses.into_iter().map(|(k, v)| (Uuid::from(k), v)).collect();
387                    self.parse_batch_responses(last_id.into(), id_to_source_range, responses)
388                } else {
389                    // We should never get here.
390                    Err(KclError::Engine(KclErrorDetails {
391                        message: format!("Failed to get batch response: {:?}", response),
392                        source_ranges: vec![source_range],
393                    }))
394                }
395            }
396            WebSocketRequest::ModelingCmdReq(ModelingCmdReq { cmd: _, cmd_id }) => {
397                // You are probably wondering why we can't just return the source range we were
398                // passed with the function. Well this is actually really important.
399                // If this is the last command in the batch and there is only one and we've reached
400                // the end of the file, this will trigger a flush batch function, but it will just
401                // send default or the end of the file as it's source range not the origin of the
402                // request so we need the original request source range in case the engine returns
403                // an error.
404                let source_range = id_to_source_range.get(cmd_id.as_ref()).cloned().ok_or_else(|| {
405                    KclError::Engine(KclErrorDetails {
406                        message: format!("Failed to get source range for command ID: {:?}", cmd_id),
407                        source_ranges: vec![],
408                    })
409                })?;
410                let ws_resp = self
411                    .inner_send_modeling_cmd(cmd_id.into(), source_range, final_req, id_to_source_range)
412                    .await?;
413                self.parse_websocket_response(ws_resp, source_range)
414            }
415            _ => Err(KclError::Engine(KclErrorDetails {
416                message: format!("The final request is not a modeling command: {:?}", final_req),
417                source_ranges: vec![source_range],
418            })),
419        }
420    }
421
422    async fn make_default_plane(
423        &self,
424        plane_id: uuid::Uuid,
425        x_axis: Point3d,
426        y_axis: Point3d,
427        color: Option<Color>,
428        source_range: SourceRange,
429    ) -> Result<uuid::Uuid, KclError> {
430        // Create new default planes.
431        let default_size = 100.0;
432        let default_origin = Point3d { x: 0.0, y: 0.0, z: 0.0 }.into();
433
434        self.batch_modeling_cmd(
435            plane_id,
436            source_range,
437            &ModelingCmd::from(mcmd::MakePlane {
438                clobber: false,
439                origin: default_origin,
440                size: LengthUnit(default_size),
441                x_axis: x_axis.into(),
442                y_axis: y_axis.into(),
443                hide: Some(true),
444            }),
445        )
446        .await?;
447
448        if let Some(color) = color {
449            // Set the color.
450            self.batch_modeling_cmd(
451                uuid::Uuid::new_v4(),
452                source_range,
453                &ModelingCmd::from(mcmd::PlaneSetColor { color, plane_id }),
454            )
455            .await?;
456        }
457
458        Ok(plane_id)
459    }
460
461    async fn new_default_planes(
462        &self,
463        id_generator: &mut IdGenerator,
464        source_range: SourceRange,
465    ) -> Result<DefaultPlanes, KclError> {
466        let plane_settings: Vec<(PlaneName, Uuid, Point3d, Point3d, Option<Color>)> = vec![
467            (
468                PlaneName::Xy,
469                id_generator.next_uuid(),
470                Point3d { x: 1.0, y: 0.0, z: 0.0 },
471                Point3d { x: 0.0, y: 1.0, z: 0.0 },
472                Some(Color {
473                    r: 0.7,
474                    g: 0.28,
475                    b: 0.28,
476                    a: 0.4,
477                }),
478            ),
479            (
480                PlaneName::Yz,
481                id_generator.next_uuid(),
482                Point3d { x: 0.0, y: 1.0, z: 0.0 },
483                Point3d { x: 0.0, y: 0.0, z: 1.0 },
484                Some(Color {
485                    r: 0.28,
486                    g: 0.7,
487                    b: 0.28,
488                    a: 0.4,
489                }),
490            ),
491            (
492                PlaneName::Xz,
493                id_generator.next_uuid(),
494                Point3d { x: 1.0, y: 0.0, z: 0.0 },
495                Point3d { x: 0.0, y: 0.0, z: 1.0 },
496                Some(Color {
497                    r: 0.28,
498                    g: 0.28,
499                    b: 0.7,
500                    a: 0.4,
501                }),
502            ),
503            (
504                PlaneName::NegXy,
505                id_generator.next_uuid(),
506                Point3d {
507                    x: -1.0,
508                    y: 0.0,
509                    z: 0.0,
510                },
511                Point3d { x: 0.0, y: 1.0, z: 0.0 },
512                None,
513            ),
514            (
515                PlaneName::NegYz,
516                id_generator.next_uuid(),
517                Point3d {
518                    x: 0.0,
519                    y: -1.0,
520                    z: 0.0,
521                },
522                Point3d { x: 0.0, y: 0.0, z: 1.0 },
523                None,
524            ),
525            (
526                PlaneName::NegXz,
527                id_generator.next_uuid(),
528                Point3d {
529                    x: -1.0,
530                    y: 0.0,
531                    z: 0.0,
532                },
533                Point3d { x: 0.0, y: 0.0, z: 1.0 },
534                None,
535            ),
536        ];
537
538        let mut planes = HashMap::new();
539        for (name, plane_id, x_axis, y_axis, color) in plane_settings {
540            planes.insert(
541                name,
542                self.make_default_plane(plane_id, x_axis, y_axis, color, source_range)
543                    .await?,
544            );
545        }
546
547        // Flush the batch queue, so these planes are created right away.
548        self.flush_batch(false, source_range).await?;
549
550        Ok(DefaultPlanes {
551            xy: planes[&PlaneName::Xy],
552            neg_xy: planes[&PlaneName::NegXy],
553            xz: planes[&PlaneName::Xz],
554            neg_xz: planes[&PlaneName::NegXz],
555            yz: planes[&PlaneName::Yz],
556            neg_yz: planes[&PlaneName::NegYz],
557        })
558    }
559
560    fn parse_websocket_response(
561        &self,
562        response: WebSocketResponse,
563        source_range: SourceRange,
564    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
565        match response {
566            WebSocketResponse::Success(success) => Ok(success.resp),
567            WebSocketResponse::Failure(fail) => {
568                let _request_id = fail.request_id;
569                Err(KclError::Engine(KclErrorDetails {
570                    message: format!("Modeling command failed: {:?}", fail.errors),
571                    source_ranges: vec![source_range],
572                }))
573            }
574        }
575    }
576
577    fn parse_batch_responses(
578        &self,
579        // The last response we are looking for.
580        id: uuid::Uuid,
581        // The mapping of source ranges to command IDs.
582        id_to_source_range: HashMap<uuid::Uuid, SourceRange>,
583        // The response from the engine.
584        responses: HashMap<uuid::Uuid, BatchResponse>,
585    ) -> Result<OkWebSocketResponseData, crate::errors::KclError> {
586        // Iterate over the responses and check for errors.
587        #[expect(
588            clippy::iter_over_hash_type,
589            reason = "modeling command uses a HashMap and keys are random, so we don't really have a choice"
590        )]
591        for (cmd_id, resp) in responses.iter() {
592            match resp {
593                BatchResponse::Success { response } => {
594                    if cmd_id == &id {
595                        // This is the response we care about.
596                        return Ok(OkWebSocketResponseData::Modeling {
597                            modeling_response: response.clone(),
598                        });
599                    } else {
600                        // Continue the loop if this is not the response we care about.
601                        continue;
602                    }
603                }
604                BatchResponse::Failure { errors } => {
605                    // Get the source range for the command.
606                    let source_range = id_to_source_range.get(cmd_id).cloned().ok_or_else(|| {
607                        KclError::Engine(KclErrorDetails {
608                            message: format!("Failed to get source range for command ID: {:?}", cmd_id),
609                            source_ranges: vec![],
610                        })
611                    })?;
612                    return Err(KclError::Engine(KclErrorDetails {
613                        message: format!("Modeling command failed: {:?}", errors),
614                        source_ranges: vec![source_range],
615                    }));
616                }
617            }
618        }
619
620        // Return an error that we did not get an error or the response we wanted.
621        // This should never happen but who knows.
622        Err(KclError::Engine(KclErrorDetails {
623            message: format!("Failed to find response for command ID: {:?}", id),
624            source_ranges: vec![],
625        }))
626    }
627
628    async fn modify_grid(&self, hidden: bool, source_range: SourceRange) -> Result<(), KclError> {
629        // Hide/show the grid.
630        self.batch_modeling_cmd(
631            uuid::Uuid::new_v4(),
632            source_range,
633            &ModelingCmd::from(mcmd::ObjectVisible {
634                hidden,
635                object_id: *GRID_OBJECT_ID,
636            }),
637        )
638        .await?;
639
640        // Hide/show the grid scale text.
641        self.batch_modeling_cmd(
642            uuid::Uuid::new_v4(),
643            source_range,
644            &ModelingCmd::from(mcmd::ObjectVisible {
645                hidden,
646                object_id: *GRID_SCALE_TEXT_OBJECT_ID,
647            }),
648        )
649        .await?;
650
651        Ok(())
652    }
653
654    /// Get session data, if it has been received.
655    /// Returns None if the server never sent it.
656    async fn get_session_data(&self) -> Option<ModelingSessionData> {
657        None
658    }
659
660    /// Close the engine connection and wait for it to finish.
661    async fn close(&self);
662}
663
664#[derive(Debug, Hash, Eq, Clone, Deserialize, Serialize, PartialEq, ts_rs::TS, JsonSchema)]
665#[ts(export)]
666#[serde(rename_all = "camelCase")]
667pub enum PlaneName {
668    /// The XY plane.
669    Xy,
670    /// The opposite side of the XY plane.
671    NegXy,
672    /// The XZ plane.
673    Xz,
674    /// The opposite side of the XZ plane.
675    NegXz,
676    /// The YZ plane.
677    Yz,
678    /// The opposite side of the YZ plane.
679    NegYz,
680}
681
682/// Create a new zoo api client.
683#[cfg(not(target_arch = "wasm32"))]
684pub fn new_zoo_client(token: Option<String>, engine_addr: Option<String>) -> anyhow::Result<kittycad::Client> {
685    let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),);
686    let http_client = reqwest::Client::builder()
687        .user_agent(user_agent)
688        // For file conversions we need this to be long.
689        .timeout(std::time::Duration::from_secs(600))
690        .connect_timeout(std::time::Duration::from_secs(60));
691    let ws_client = reqwest::Client::builder()
692        .user_agent(user_agent)
693        // For file conversions we need this to be long.
694        .timeout(std::time::Duration::from_secs(600))
695        .connect_timeout(std::time::Duration::from_secs(60))
696        .connection_verbose(true)
697        .tcp_keepalive(std::time::Duration::from_secs(600))
698        .http1_only();
699
700    let zoo_token_env = std::env::var("ZOO_API_TOKEN");
701
702    let token = if let Some(token) = token {
703        token
704    } else if let Ok(token) = std::env::var("KITTYCAD_API_TOKEN") {
705        if let Ok(zoo_token) = zoo_token_env {
706            if zoo_token != token {
707                return Err(anyhow::anyhow!(
708                    "Both environment variables KITTYCAD_API_TOKEN=`{}` and ZOO_API_TOKEN=`{}` are set. Use only one.",
709                    token,
710                    zoo_token
711                ));
712            }
713        }
714        token
715    } else if let Ok(token) = zoo_token_env {
716        token
717    } else {
718        return Err(anyhow::anyhow!(
719            "No API token found in environment variables. Use KITTYCAD_API_TOKEN or ZOO_API_TOKEN"
720        ));
721    };
722
723    // Create the client.
724    let mut client = kittycad::Client::new_from_reqwest(token, http_client, ws_client);
725    // Set an engine address if it's set.
726    let kittycad_host_env = std::env::var("KITTYCAD_HOST");
727    if let Some(addr) = engine_addr {
728        client.set_base_url(addr);
729    } else if let Ok(addr) = std::env::var("ZOO_HOST") {
730        if let Ok(kittycad_host) = kittycad_host_env {
731            if kittycad_host != addr {
732                return Err(anyhow::anyhow!(
733                    "Both environment variables KITTYCAD_HOST=`{}` and ZOO_HOST=`{}` are set. Use only one.",
734                    kittycad_host,
735                    addr
736                ));
737            }
738        }
739        client.set_base_url(addr);
740    } else if let Ok(addr) = kittycad_host_env {
741        client.set_base_url(addr);
742    }
743
744    Ok(client)
745}