diff --git a/content/cli/master/get-started/get-started-with-control-plane-projects.md b/content/cli/master/get-started/get-started-with-control-plane-projects.md
index dc2125420..fda41058c 100644
--- a/content/cli/master/get-started/get-started-with-control-plane-projects.md
+++ b/content/cli/master/get-started/get-started-with-control-plane-projects.md
@@ -14,8 +14,8 @@ runs the project on a local development control plane so you can test it without
deploying to a shared cluster.
{{}}
-This guide shows how to write the composition function in Go, Python, KCL, and
-templated YAML. You can pick your preferred language.
+This guide shows how to write the composition function in Go, Python, Rust, KCL,
+and templated YAML. You can pick your preferred language.
{{}}
A `WebApp` custom resource looks like this:
@@ -796,6 +796,179 @@ bindings the CLI generated when you added the Kubernetes dependency, so the
compiler checks the resources you create.
{{< /tab >}}
+{{< tab "Rust" >}}
+Rust is a good choice if you want a statically typed, compiled function and
+access to the [crates.io](https://crates.io) ecosystem.
+
+Generate a Rust function named `compose-webapp` and add it to the composition's
+pipeline:
+
+```shell
+crossplane function generate compose-webapp apis/webapps/composition.yaml --language rust
+```
+
+The command scaffolds the function under `functions/compose-webapp/` and adds a
+pipeline step to `apis/webapps/composition.yaml`.
+
+Replace the contents of `functions/compose-webapp/src/function.rs` with the
+following function logic:
+
+```rust
+//! Composes a Deployment and a Service for a WebApp.
+
+use std::collections::BTreeMap;
+
+use crossplane_models::com::example::platform::v1alpha1::WebApp;
+use crossplane_models::io::k8s::api::apps::v1::{Deployment, DeploymentSpec};
+use crossplane_models::io::k8s::api::core::v1::{
+ Container, ContainerPort, PodSpec, PodTemplateSpec, Service, ServicePort, ServiceSpec,
+};
+use crossplane_models::io::k8s::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta};
+use crossplane_models::io::k8s::apimachinery::pkg::util::intstr::IntOrString;
+use function_sdk_rust::proto::v1::function_runner_service_server::FunctionRunnerService;
+use function_sdk_rust::proto::v1::{RunFunctionRequest, RunFunctionResponse};
+use function_sdk_rust::{resource, response};
+use tonic::{Request, Response, Status};
+
+/// The composition function.
+#[derive(Debug, Default)]
+pub struct Function;
+
+#[tonic::async_trait]
+impl FunctionRunnerService for Function {
+ async fn run_function(
+ &self,
+ request: Request,
+ ) -> Result, Status> {
+ let req = request.into_inner();
+ let tag = req.meta.as_ref().map(|m| m.tag.clone()).unwrap_or_default();
+ tracing::info!(tag, "running function");
+
+ let mut rsp = response::to(&req, response::DEFAULT_TTL);
+
+ let observed = req.observed.as_ref().and_then(|s| s.composite.as_ref());
+ let xr: WebApp = match resource::get(observed) {
+ Ok(xr) => xr,
+ Err(e) => {
+ response::fatal(&mut rsp, format!("cannot get xr: {e}"));
+ return Ok(Response::new(rsp));
+ }
+ };
+
+ let metadata = xr.metadata.unwrap_or_default();
+ let spec = xr.spec.unwrap_or_default();
+ let (Some(name), Some(image)) = (metadata.name, spec.image) else {
+ response::fatal(&mut rsp, "xr is missing metadata.name or spec.image");
+ return Ok(Response::new(rsp));
+ };
+
+ let ports = spec.ports.unwrap_or_default();
+ let labels = BTreeMap::from([("app.kubernetes.io/name".to_string(), name.clone())]);
+
+ // Build each resource from its generated model. Default fills in the
+ // apiVersion and kind, and leaves every field the function doesn't set
+ // out of the desired state.
+ let deployment = Deployment {
+ metadata: Some(ObjectMeta {
+ name: Some(name.clone()),
+ namespace: metadata.namespace.clone(),
+ labels: Some(labels.clone()),
+ ..Default::default()
+ }),
+ spec: Some(DeploymentSpec {
+ replicas: spec.replicas.map(|r| r as i32),
+ selector: Some(LabelSelector {
+ match_labels: Some(labels.clone()),
+ ..Default::default()
+ }),
+ template: Some(PodTemplateSpec {
+ metadata: Some(ObjectMeta {
+ labels: Some(labels.clone()),
+ ..Default::default()
+ }),
+ spec: Some(PodSpec {
+ containers: Some(vec![Container {
+ name: Some(name.clone()),
+ image: Some(image),
+ ports: Some(
+ ports
+ .iter()
+ .map(|p| ContainerPort {
+ container_port: Some(*p as i32),
+ ..Default::default()
+ })
+ .collect(),
+ ),
+ ..Default::default()
+ }]),
+ ..Default::default()
+ }),
+ }),
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+
+ let service = Service {
+ metadata: Some(ObjectMeta {
+ name: Some(name),
+ namespace: metadata.namespace,
+ ..Default::default()
+ }),
+ spec: Some(ServiceSpec {
+ selector: Some(labels),
+ ports: Some(
+ ports
+ .iter()
+ .map(|p| ServicePort {
+ protocol: Some("TCP".to_string()),
+ port: Some(*p as i32),
+ target_port: Some(IntOrString::Int(*p)),
+ ..Default::default()
+ })
+ .collect(),
+ ),
+ ..Default::default()
+ }),
+ ..Default::default()
+ };
+
+ let desired = rsp.desired.get_or_insert_default();
+ resource::update(
+ desired
+ .resources
+ .entry("deployment".to_string())
+ .or_default(),
+ &deployment,
+ )
+ .map_err(|e| Status::internal(e.to_string()))?;
+ resource::update(
+ desired.resources.entry("service".to_string()).or_default(),
+ &service,
+ )
+ .map_err(|e| Status::internal(e.to_string()))?;
+
+ Ok(Response::new(rsp))
+ }
+}
+```
+
+The function reads the observed `WebApp` XR, then builds a `Deployment` and a
+`Service` from its `spec`.
+
+The `crossplane_models` crate holds the bindings the CLI generated from your
+XRD and from the Kubernetes dependency, so the compiler checks the resources you
+create. The function's `Cargo.toml` depends on the crate by path. Each API group
+and version is a module named after the reversed group, which is why the
+`WebApp` of `platform.example.com/v1alpha1` is
+`crossplane_models::com::example::platform::v1alpha1::WebApp`.
+
+Every field of a generated model is an `Option`, and a model leaves unset fields
+out when it serializes. `..Default::default()` leaves the rest of a struct
+unset and fills in the `apiVersion` and `kind` of a resource, so the function's
+desired state contains only the fields it sets.
+{{< /tab >}}
+
{{< tab "KCL" >}}
[KCL](https://kcl-lang.io) is a good choice for functions with dynamic logic.
It's fast and sandboxed.
@@ -935,6 +1108,13 @@ functions automatically, so you only pass the example XR and the composition:
crossplane composition render examples/webapps/podinfo.yaml apis/webapps/composition.yaml
```
+{{}}
+`render` times out after a minute by default. The CLI compiles a Rust function
+and its dependencies from source for every render, which can take longer than
+that and fails with `context deadline exceeded`. Pass `--timeout 5m` when the
+project has a Rust function.
+{{}}
+
The command prints the rendered `Deployment` and `Service` as well as the
updates Crossplane would make to the `WebApp` XR:
diff --git a/utils/vale/styles/Crossplane/brands.txt b/utils/vale/styles/Crossplane/brands.txt
index 39fe16708..2880be14e 100644
--- a/utils/vale/styles/Crossplane/brands.txt
+++ b/utils/vale/styles/Crossplane/brands.txt
@@ -7,6 +7,7 @@ CloudSQL
CNCF
KEDA
Commonmark
+crates.io
DockerHub
DocSearch
FluxCD