//! Generate the workspace's `compose.yaml`.
//!
//! The workspace compose file is now **dev-only**: it brings up
//! the moon-ide shell container and nothing else. Project services
//! (a folder's own `docker-compose.yml`) run as **separate compose
//! projects** managed per folder — see [`crate::project_compose`]
//! and the
//! [ADR 0007 amendment](../../../specs/decisions/0007-compose-and-moon-base.md#amendment-2026-04-29--workspace-shell-vs-project-services)
//! for why we split the two.
//!
//! Output shape:
//!
//! ```yaml
//! # Generated by moon-ide. Mounts come from bound-folders.json;
//! # project services live in each folder's own docker-compose.yml
//! # and are managed per-folder, not from this file.
//!
//! name: moon-ws-default
//!
//! services:
//!   dev:
//!     image: moon-base:dev
//!     init: true
//!     working_dir: /workspace
//!     volumes:
//!       - /home/me/code/moon-landing:/workspace/moon-landing
//!       - /home/me/code/moon-ide:/workspace/moon-ide
//!     command: ["sleep", "infinity"]
//!
//! x-moon:
//!   shell-service: dev
//! ```
//!
//! Path strategy: the compose file lives at
//! `~/.local/share/moon-ide/workspaces/<id>/compose.yaml` —
//! outside any specific repo, so every bind-mount path is the
//! absolute host path verbatim.
//!
//! Why a string template, not `serde_yaml`
//! ----------------------------------------
//!
//! The `serde_yaml` crate has been unmaintained since late 2023
//! and the structure we emit is small + fixed. Hand-rendered we
//! get exact control over comments, key order, and whitespace —
//! the file is meant to be human-readable, not parsed back by us.
//! If a future need to read it back appears we'll reach for a
//! maintained YAML crate at that point, not preemptively.

use std::fmt::Write as _;

use camino::Utf8PathBuf;

use crate::project::ProjectName;

/// One folder mounted into the dev container.
///
/// `mount_name` is the segment placed under `/workspace/`; for a
/// folder at `/home/me/code/moon-landing` it'll be
/// `moon-landing`. Callers are responsible for mount-name
/// uniqueness within a workspace (the registry layer is the
/// natural enforcement point — see
/// [`specs/roadmaps/phase-02-containers.md`](../../../specs/roadmaps/phase-02-containers.md#pending-redesign-workspace--folder)).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoundMount {
	/// Absolute host-side path. Written verbatim into `volumes:`.
	pub host_path: Utf8PathBuf,
	/// Container-side directory name (the basename portion of
	/// `/workspace/<name>`). Validated by the caller — this layer
	/// trusts the input.
	pub mount_name: String,
}

/// SSH agent forwarding into the dev container.
///
/// Bind-mounts the host's SSH agent socket inside the container
/// at a fixed path and points `SSH_AUTH_SOCK` at it, so
/// `git fetch`, `gh auth ...`, and any other SSH-using tool the
/// user runs in a container terminal can reach the host's keys
/// without copying private material into the container.
///
/// Source path is resolved at the lifecycle layer:
///
/// - **macOS (Docker Desktop)** uses the magic
///   `/run/host-services/ssh-auth.sock` — Docker Desktop
///   special-cases that path and forwards it to the host's
///   running agent.
/// - **Linux** uses `$SSH_AUTH_SOCK` if it's set and the socket
///   exists, otherwise we skip the forward entirely (a
///   `tracing::warn!` flags the silent skip; the rest of the
///   container still works, just without the agent).
///
/// In-container path is the same regardless of platform — we
/// re-use Docker Desktop's convention so any tooling that
/// already understands it Just Works inside the workspace shell.
pub const SSH_AGENT_CONTAINER_PATH: &str = "/run/host-services/ssh-auth.sock";

/// In-container directory the agent-proxy dir mounts at (ADR 0060).
/// `SSH_AGENT_CONTAINER_PATH` lives inside it, so the in-container
/// experience is identical on both mount shapes.
pub const SSH_AGENT_CONTAINER_DIR: &str = "/run/host-services";

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshAgentForward {
	/// File bind-mount of a host-managed stable socket. macOS's
	/// Docker Desktop magic path, and the Linux fallback when the
	/// IDE's agent proxy isn't running (raw `$SSH_AUTH_SOCK` —
	/// goes stale if the host agent restarts; ADR 0060).
	Socket {
		/// Absolute host-side path to the SSH agent's unix socket.
		host_socket: Utf8PathBuf,
	},
	/// Directory bind-mount of the IDE's ssh-agent proxy dir
	/// (ADR 0060) — contains exactly one socket,
	/// `ssh-auth.sock`, which the host IDE rebinds freely without
	/// the container's mount going stale (ADR 0026 pattern).
	ProxyDir {
		/// Absolute host-side path of the proxy directory.
		host_dir: Utf8PathBuf,
	},
}

/// One `~/.ssh/*.pub` public key bind-mounted read-only into the
/// container's `~/.ssh/` (ADR 0060). Lets `IdentityFile
/// ~/.ssh/<key>.pub` + `IdentitiesOnly yes` select a specific agent
/// key inside the container — the canonical fix for "the server
/// rejects auth after the agent offers too many keys" — without any
/// private material leaving the host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshPublicKeyMount {
	/// Absolute host-side path of the `.pub` file.
	pub host_path: Utf8PathBuf,
	/// File name (`id_ed25519.pub`, …) — the mount lands at
	/// `/home/dev/.ssh/<file_name>`.
	pub file_name: String,
}

/// In-container path the workspace's socket *directory* is
/// bind-mounted at. We mount the directory rather than the socket
/// file (ADR 0026) so the socket can be unlinked and rebound
/// across IDE restarts without the container's mount going stale,
/// and so a missing source can never make Docker auto-create the
/// socket path as a root-owned file/dir. Read-write — Unix-socket
/// `connect()` requires write permission on the inode, and a
/// read-only directory mount makes the socket inside it
/// unreachable.
pub const MOON_EDIT_SOCKET_DIR_CONTAINER_PATH: &str = "/run/moon";

/// In-container path the workspace's `instance.sock` resolves to,
/// inside [`MOON_EDIT_SOCKET_DIR_CONTAINER_PATH`]. This is what
/// `moon-edit` connects to (`$MOON_EDIT_SOCK`); the terminal
/// supervisor sets the env var from its own copy of this string.
///
/// `moon-edit` (`images/moon-base/moon-edit`) is the in-container
/// client and ships in `moon-base` at `/usr/local/bin/moon-edit`.
/// See [`specs/containers.md`](../../../specs/containers.md)
/// § "Editor forwarding", [ADR 0021](../../../specs/decisions/0021-git-editor-forward.md),
/// and [ADR 0026](../../../specs/decisions/0026-socket-dir-mount.md).
pub const MOON_EDIT_SOCKET_CONTAINER_PATH: &str = "/run/moon/instance.sock";

/// Bind-mount of the workspace's socket directory into the dev
/// container so the in-container `moon-edit` shim can forward
/// `$GIT_EDITOR` invocations to the host IDE.
///
/// The host path is the per-workspace `run/` directory
/// (`<workspaces_dir>/<slug>/run/`) that holds the focus socket
/// and nothing else. moon-ide creates it before any compose call
/// runs (`src-tauri/src/focus_socket.rs::try_bind`, plus the
/// lifecycle layer's `write_state`), so it always exists,
/// user-owned, at `compose up` time. Mounting the *directory*
/// rather than the socket file (ADR 0026) means the socket coming
/// and going across IDE restarts is visible live to the
/// container, and a missing source can never trigger Docker's
/// "auto-create as root" behaviour.
///
/// Unlike `SshAgentForward`, this mount has no host-side
/// detection step — the directory always exists for a bound
/// workspace, so the lifecycle layer can populate the option
/// unconditionally.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MoonEditSocketMount {
	/// Absolute host-side path to the workspace's socket
	/// directory (`<workspaces_dir>/<slug>/run/`).
	pub host_dir: Utf8PathBuf,
}

/// In-container path the host's `~/.ssh/config` is bind-mounted
/// at. We pin it to the `dev` user's `~/.ssh/config` so the
/// in-container `ssh` reads it without `SSH_CONFIG=/whatever` on
/// every invocation. `dev`'s home is `/home/dev` per the
/// moon-base Dockerfile.
pub const SSH_CONFIG_CONTAINER_PATH: &str = "/home/dev/.ssh/config";

/// Bind-mount of the host's `~/.ssh/config` into the dev
/// container, read-only. Surfaces the user's Host aliases,
/// `ProxyJump` chains, and per-host options inside the workspace
/// shell — without it, `ssh europe` (or any other custom Host
/// alias) inside a container terminal tries to DNS-resolve the
/// literal alias and hangs, even though the same command works
/// on the host.
///
/// Read-only on purpose: the host owns the config; the container
/// reads it. A container-side rewrite would surprisingly mutate
/// the host's config via the shared bind mount.
///
/// Caveats (documented in `specs/containers.md` § SSH config
/// forwarding):
///
/// - **`IdentityFile`** entries that reference host-side key
///   paths (`~/.ssh/id_*`) won't resolve inside the container
///   because keys are deliberately not mounted. SSH falls through
///   to the agent (`SSH_AUTH_SOCK`), which is the intended path
///   anyway.
/// - **`ProxyCommand`** entries that exec host-only binaries
///   (`cloudflared`, `aws ssm`, …) won't work — that's a known
///   limitation of cross-boundary tooling, not specific to this
///   mount.
/// - **`Include`** directives pointing at non-mounted paths are
///   silently skipped by OpenSSH; main config still applies.
///
/// If `~/.ssh/config` doesn't exist on the host, the lifecycle
/// layer skips the mount entirely — Docker would otherwise
/// auto-create the source as an empty directory and shadow any
/// later host config until the container is recreated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshConfigMount {
	/// Absolute host-side path to the user's ssh config file
	/// (typically `~/.ssh/config`).
	pub host_path: Utf8PathBuf,
}

/// In-container path the host's `~/.ssh/known_hosts` is
/// bind-mounted at, alongside the config. Pinned to the dev
/// user's `~/.ssh/known_hosts` so the in-container `ssh` trusts
/// the same hosts the user already trusted on the host.
pub const SSH_KNOWN_HOSTS_CONTAINER_PATH: &str = "/home/dev/.ssh/known_hosts";

/// Bind-mount of the host's `~/.ssh/known_hosts` into the dev
/// container, read-only. The image pre-seeds
/// `/etc/ssh/ssh_known_hosts` with only `github.com` /
/// `gitlab.com`, so without this any other host the user connects
/// to (`ssh europe`, a `ProxyJump` target, a private git host)
/// dies on the host-key prompt under non-interactive `docker
/// exec` — exactly the failure the agent hits. Forwarding the
/// host's known_hosts extends that trust to every host the user
/// already accepted on the host.
///
/// Read-only like the config: the host owns the file. A
/// container-side `ssh-keyscan` / `accept-new` write would
/// otherwise surprisingly mutate the host's trust store. A host
/// first seen *inside* the container still falls back to the
/// prompt-based accept flow (writable `~/.ssh/known_hosts`
/// permitting) — this mount only needs to cover hosts the user
/// already trusts.
///
/// Skipped by the lifecycle layer when the host file doesn't
/// exist, matching `detect_host_ssh_config`'s "don't mount a
/// non-existent source" rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshKnownHostsMount {
	/// Absolute host-side path to the user's known_hosts file
	/// (typically `~/.ssh/known_hosts`).
	pub host_path: Utf8PathBuf,
}

/// In-container path the host's `gh` config is mounted at. We
/// pin it to the `dev` user's `~/.config/gh` so the in-container
/// `gh` finds the host's auth without needing
/// `GH_CONFIG_DIR=/whatever` on every invocation. `dev`'s home is
/// `/home/dev` per the moon-base Dockerfile.
pub const GH_CONFIG_CONTAINER_PATH: &str = "/home/dev/.config/gh";

/// Bind-mount of the host's `gh` config directory (typically
/// `~/.config/gh`) into the dev container, read-only. Surfaces
/// the host's per-host preferences (default editor, default
/// protocol, etc.) — and on hosts that store the OAuth token in
/// `hosts.yml` directly, the auth state too.
///
/// Read-only on purpose:
///
/// 1. **One source of truth.** The host owns the auth; the
///    container reads it. A container `gh auth login` would
///    silently update the host's config (because the bind mount
///    is shared), which is surprising and hard to undo.
/// 2. **No accidental mutation.** Tools that lazily write the
///    config (e.g. `gh config set`) fail loudly inside the
///    container instead of mutating host state.
/// 3. **Mirrors SSH agent forwarding.** The agent socket is
///    similarly host-owned; gh config follows the same pattern.
///
/// On its own this mount is **not enough** for `gh` to
/// authenticate when the host uses the system keyring (the
/// modern default — macOS Keychain, GNOME Keyring, Wincred).
/// In that case `hosts.yml` carries no `oauth_token:` field and
/// the container has no keyring to fall back on, so an
/// in-container `gh auth status` reports "token is invalid".
/// [`HostGhToken`] forwards the active token as `GH_TOKEN` so
/// `gh` works in both storage shapes.
///
/// If the host config doesn't exist yet (`~/.config/gh` is
/// absent because the user has never run `gh auth login`), the
/// lifecycle layer skips the mount entirely — Docker would
/// otherwise auto-create the source as a directory and shadow
/// any later host login until the container is recreated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GhConfigMount {
	/// Absolute host-side path to the gh config directory.
	pub host_path: Utf8PathBuf,
}

/// The host's active `gh` OAuth token, projected into the dev
/// container as `GH_TOKEN`. Resolved via `gh auth token` at
/// compose-render time, which works regardless of whether the
/// host stored its credentials in the system keyring or in
/// `~/.config/gh/hosts.yml` — `gh` reads from whichever path is
/// configured and prints the token to stdout.
///
/// Why an env var and not just the config mount alone:
///
/// 1. **Keyring storage.** `gh` defaults to the OS keyring for
///    new logins (macOS Keychain, GNOME Keyring, Wincred). The
///    container has no access to the host's keyring, and
///    `hosts.yml` in that mode carries no `oauth_token:` field —
///    so the bind mount alone leaves the in-container `gh`
///    unauthenticated.
/// 2. **Precedence.** `GH_TOKEN` takes precedence over every
///    other resolution path, so we get the same answer whichever
///    way the host happens to be configured.
/// 3. **No mutation risk.** The container's `gh` can't
///    accidentally rotate or invalidate the host's token by
///    writing to a forwarded `hosts.yml`.
///
/// Resolved fresh per `render_compose` call — the same cadence
/// as [`HostGitIdentity`] — so a host `gh auth refresh` is
/// picked up by the next "Rebuild container".
///
/// The token ends up in plaintext inside the generated
/// `compose.yaml` (under `<state-dir>/`, owned by the user). The
/// trade-off is deliberate: we'd rather match the host's auth
/// state than ask the user to re-login per-workspace. The state
/// dir lives in the user's own data directory and isn't checked
/// into any repo.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostGhToken {
	pub token: String,
}

/// The host's `git config --global user.{name,email}`, projected
/// into the dev container as `GIT_AUTHOR_*` / `GIT_COMMITTER_*`
/// environment variables.
///
/// Why env vars and not a mounted `~/.gitconfig`:
///
/// 1. **Surgical surface.** We only export the bits we know we
///    want (the identity). A mounted host gitconfig would also
///    drag in credential helpers, signing settings, and per-tool
///    aliases — most of which point at host-only binaries that
///    don't exist in the container and surface as confusing
///    `git config --get` errors.
/// 2. **Layered safely.** Env vars take precedence over
///    `~/.gitconfig` inside the container, so the dev's own
///    in-container gitconfig (if they ever set one) keeps
///    working. The environment is the override, not a
///    replacement.
/// 3. **Easy to reason about.** A glance at the generated
///    `compose.yaml` shows exactly which identity the container
///    will commit as.
///
/// Resolved at the lifecycle layer — see
/// [`crate::lifecycle::detect_host_git_identity`]. `None` (no
/// global identity configured) skips the four env vars entirely;
/// the container's git is then in the same "Please tell me who
/// you are" state it'd have on a fresh checkout, which is the
/// correct fallback (we'd rather refuse to commit than commit as
/// the wrong person).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostGitIdentity {
	pub name: String,
	pub email: String,
}

/// How the generator should populate the `dev` service.
#[derive(Debug, Clone)]
pub struct ComposeRenderOptions<'a> {
	/// `name:` value — the compose project identifier; see
	/// [`crate::project`].
	pub project: &'a ProjectName,
	/// Image reference for the `dev` service. While moon-base is
	/// in tree but unpublished we default callers to
	/// `moon-base:dev` (a local build). Once a published tag
	/// exists this'll move to `huggingface/moon-base:<tag>`.
	pub dev_image: &'a str,
	/// Bind-mount entries for the `dev` service. Empty is fine
	/// — we still emit `volumes: []` (compose tolerates it) so
	/// the generator's output is consistent. In practice the IDE
	/// only writes this file once at least one folder is bound.
	pub bound_mounts: &'a [BoundMount],
	/// Optional SSH agent forwarding. `None` skips the volume
	/// and environment block entirely (no `SSH_AUTH_SOCK` set
	/// inside the container, no agent socket mounted).
	pub ssh_agent: Option<&'a SshAgentForward>,
	/// Host `~/.ssh/*.pub` public keys to bind read-only into the
	/// container's `~/.ssh/` (ADR 0060).
	pub ssh_public_keys: &'a [SshPublicKeyMount],
	/// Optional `~/.ssh/config` bind mount. `None` skips the
	/// volume entirely — Host aliases and per-host options are
	/// then unknown inside the container.
	pub ssh_config: Option<&'a SshConfigMount>,
	/// Optional `~/.ssh/known_hosts` bind mount. `None` skips the
	/// volume entirely — the container then trusts only the
	/// image's pre-seeded `github.com` / `gitlab.com` keys.
	pub ssh_known_hosts: Option<&'a SshKnownHostsMount>,
	/// Optional git identity carried over from the host. `None`
	/// leaves the container's git unconfigured, so the first
	/// commit prompts "Please tell me who you are" — a deliberate
	/// "refuse rather than commit as the wrong person" fallback.
	pub git_identity: Option<&'a HostGitIdentity>,
	/// Optional host `gh` config bind mount. `None` skips the
	/// volume entirely (no host preferences pass-through).
	pub gh_config: Option<&'a GhConfigMount>,
	/// Optional host `gh` OAuth token, emitted as `GH_TOKEN` on
	/// the dev service. `None` leaves the container's `gh`
	/// dependent on whatever the bind-mounted config contains —
	/// which is empty when the host uses the system keyring.
	pub gh_token: Option<&'a HostGhToken>,
	/// Optional bind-mount of the workspace's socket directory
	/// for editor forwarding. The lifecycle layer typically
	/// populates this unconditionally — the `run/` directory is
	/// guaranteed to exist by the time compose renders.
	pub moon_edit_socket: Option<&'a MoonEditSocketMount>,
}

/// Wrap `value` in double quotes, escaping `\` and `"` so an
/// arbitrary user string (a name with quotes, an email with a
/// `@`, anything containing `:` / `#` / leading whitespace) is
/// always YAML-safe. Cheaper than pulling in a YAML escaper for
/// the few env values we ever emit; matches the YAML 1.2 quoted
/// scalar rules we actually need.
fn quote_yaml_double(value: &str) -> String {
	let mut out = String::with_capacity(value.len() + 2);
	out.push('"');
	for ch in value.chars() {
		match ch {
			'\\' => out.push_str("\\\\"),
			'"' => out.push_str("\\\""),
			'\n' => out.push_str("\\n"),
			'\t' => out.push_str("\\t"),
			'\r' => out.push_str("\\r"),
			c => out.push(c),
		}
	}
	out.push('"');
	out
}

/// Result of [`generate_compose`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComposeRender {
	/// The full file contents, ready to be written to
	/// `<state_dir>/compose.yaml`.
	pub yaml: String,
}

/// Render the workspace's `compose.yaml`.
pub fn generate_compose(options: ComposeRenderOptions<'_>) -> ComposeRender {
	let mut yaml = String::new();
	let _ = writeln!(yaml, "# Generated by moon-ide. Mounts come from bound-folders.json;");
	let _ = writeln!(yaml, "# project services live in each folder's own docker-compose.yml");
	let _ = writeln!(yaml, "# and are managed per-folder, not from this file.");
	let _ = writeln!(yaml, "#");
	let _ = writeln!(yaml, "# Architecture: see specs/containers.md in the moon-ide");
	let _ = writeln!(yaml, "# repository.");
	let _ = writeln!(yaml);

	let _ = writeln!(yaml, "name: {}", options.project);
	let _ = writeln!(yaml);

	let _ = writeln!(yaml, "services:");
	let _ = writeln!(yaml, "  dev:");
	let _ = writeln!(yaml, "    image: {}", options.dev_image);
	// `init: true` reaps zombie processes for tools like watch
	// loops and `bun install`'s child workers — the same reason
	// `tini` ships in most "polyglot dev shell" base images.
	let _ = writeln!(yaml, "    init: true");
	let _ = writeln!(yaml, "    working_dir: /workspace");
	let _ = writeln!(yaml, "    volumes:");
	let has_extra_mount = options.ssh_agent.is_some()
		|| !options.ssh_public_keys.is_empty()
		|| options.ssh_config.is_some()
		|| options.ssh_known_hosts.is_some()
		|| options.gh_config.is_some()
		|| options.moon_edit_socket.is_some();
	if options.bound_mounts.is_empty() && !has_extra_mount {
		// Empty list is valid YAML and compose accepts it; this
		// keeps the structure consistent for the no-folder /
		// no-ssh-agent / no-ssh-config / no-gh-config edge case.
		let _ = writeln!(yaml, "      []");
	} else {
		for mount in options.bound_mounts {
			let _ = writeln!(
				yaml,
				"      - {host}:/workspace/{name}",
				host = mount.host_path.as_str(),
				name = mount.mount_name,
			);
		}
		match options.ssh_agent {
			// Bind the host's SSH agent so `git fetch`, `gh`, etc.
			// inside the container reach the host's keys without
			// copying any private material.
			Some(SshAgentForward::Socket { host_socket }) => {
				let _ = writeln!(
					yaml,
					"      - {host}:{container}",
					host = host_socket.as_str(),
					container = SSH_AGENT_CONTAINER_PATH,
				);
			}
			// Directory mount (ADR 0060): the IDE's proxy rebinds
			// the socket inside without the mount going stale.
			Some(SshAgentForward::ProxyDir { host_dir }) => {
				let _ = writeln!(
					yaml,
					"      - {host}:{container}",
					host = host_dir.as_str(),
					container = SSH_AGENT_CONTAINER_DIR,
				);
			}
			None => {}
		}
		for key in options.ssh_public_keys {
			// Read-only by design; public halves only (ADR 0060) —
			// they let `IdentityFile`+`IdentitiesOnly` select agent
			// keys, never authenticate by themselves.
			let _ = writeln!(
				yaml,
				"      - {host}:/home/dev/.ssh/{name}:ro",
				host = key.host_path.as_str(),
				name = key.file_name,
			);
		}
		if let Some(cfg) = options.ssh_config {
			// Read-only by design — the host owns the config,
			// the container reads Host aliases & per-host
			// options from it. Private key material stays out
			// (only the file itself, not the whole `.ssh` dir,
			// is mounted).
			let _ = writeln!(
				yaml,
				"      - {host}:{container}:ro",
				host = cfg.host_path.as_str(),
				container = SSH_CONFIG_CONTAINER_PATH,
			);
		}
		if let Some(kh) = options.ssh_known_hosts {
			// Read-only by design — the host owns the trust
			// store. Extends host-key trust beyond the image's
			// pre-seeded github/gitlab keys to every host the
			// user already accepted on the host.
			let _ = writeln!(
				yaml,
				"      - {host}:{container}:ro",
				host = kh.host_path.as_str(),
				container = SSH_KNOWN_HOSTS_CONTAINER_PATH,
			);
		}
		if let Some(gh) = options.gh_config {
			// Read-only by design — the container reads the
			// host's auth, never writes back.
			let _ = writeln!(
				yaml,
				"      - {host}:{container}:ro",
				host = gh.host_path.as_str(),
				container = GH_CONFIG_CONTAINER_PATH,
			);
		}
		if let Some(edit) = options.moon_edit_socket {
			// Read-write by necessity — Unix-socket `connect()`
			// needs write permission on the inode, and a
			// read-only directory mount makes the socket inside
			// unreachable. We mount the socket's parent `run/`
			// directory (which holds only the socket) rather than
			// the socket file, so the socket can be rebound across
			// IDE restarts without the mount going stale; see
			// ADR 0026.
			let _ = writeln!(
				yaml,
				"      - {host}:{container}",
				host = edit.host_dir.as_str(),
				container = MOON_EDIT_SOCKET_DIR_CONTAINER_PATH,
			);
		}
	}
	// Collect every env entry first so we can emit one
	// `environment:` block (or none). Order is stable for diff-
	// friendliness: SSH agent first (existing behaviour), then the
	// four git identity vars in canonical author/committer order.
	let mut env: Vec<(&str, String)> = Vec::new();
	if options.ssh_agent.is_some() {
		env.push(("SSH_AUTH_SOCK", SSH_AGENT_CONTAINER_PATH.to_owned()));
	}
	if let Some(id) = options.git_identity {
		env.push(("GIT_AUTHOR_NAME", id.name.clone()));
		env.push(("GIT_AUTHOR_EMAIL", id.email.clone()));
		env.push(("GIT_COMMITTER_NAME", id.name.clone()));
		env.push(("GIT_COMMITTER_EMAIL", id.email.clone()));
	}
	if let Some(token) = options.gh_token {
		env.push(("GH_TOKEN", token.token.clone()));
	}
	if !env.is_empty() {
		let _ = writeln!(yaml, "    environment:");
		for (key, value) in env {
			let _ = writeln!(yaml, "      {key}: {}", quote_yaml_double(&value));
		}
	}
	// `sleep infinity` is the conventional "keep this container
	// alive so we can `docker exec` into it" command. Phase 2.1
	// will keep the same shape; routed execution attaches via
	// `docker exec`, never via the entrypoint.
	let _ = writeln!(yaml, "    command: [\"sleep\", \"infinity\"]");
	let _ = writeln!(yaml);

	let _ = writeln!(yaml, "x-moon:");
	// The "which service do new terminals attach to?" key. Single
	// service today; 2.4 promotes this to a user-pickable default.
	let _ = writeln!(yaml, "  shell-service: dev");

	ComposeRender { yaml }
}

#[cfg(test)]
mod tests {
	use camino::Utf8PathBuf;

	use super::*;
	use crate::project::project_name_for_id;

	fn project() -> ProjectName {
		project_name_for_id("default").unwrap()
	}

	fn mount(host: &str, name: &str) -> BoundMount {
		BoundMount {
			host_path: Utf8PathBuf::from(host),
			mount_name: name.to_owned(),
		}
	}

	#[test]
	fn no_folders_renders_empty_volumes_list() {
		let project = project();
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(render.yaml.contains("name: moon-ws-default"));
		assert!(render.yaml.contains("    volumes:\n      []"));
		// Workspace compose no longer pulls in project services —
		// they're managed per-folder now.
		assert!(!render.yaml.contains("include:"));
		// No agent forward and no git identity → no environment
		// block on the dev service at all.
		assert!(!render.yaml.contains("    environment:"));
		assert!(!render.yaml.contains("SSH_AUTH_SOCK"));
		assert!(!render.yaml.contains("GIT_AUTHOR"));
	}

	#[test]
	fn dev_service_always_has_init_true() {
		// `init: true` injects `docker-init` (tini) as the
		// container's PID 1. We rely on it to reap any process
		// that orphans inside the container — most commonly a
		// child of an LSP server (e.g. tsgo's helper procs) that
		// outlives its `docker exec` session. Without it, `sleep
		// infinity` would be PID 1 and it does not reap SIGCHLD,
		// so zombies would accumulate over a long-running session.
		// This test exists so the line can't be dropped in a
		// future compose refactor without somebody noticing.
		let project = project();
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});
		assert!(render.yaml.contains("    init: true"));
	}

	#[test]
	fn bound_folders_render_as_volume_mounts_under_slash_workspace() {
		let project = project();
		let mounts = [
			mount("/home/me/code/moon-landing", "moon-landing"),
			mount("/home/me/code/moon-ide", "moon-ide"),
		];
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &mounts,
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(render
			.yaml
			.contains("- /home/me/code/moon-landing:/workspace/moon-landing"));
		assert!(render.yaml.contains("- /home/me/code/moon-ide:/workspace/moon-ide"));
		assert!(render.yaml.contains("working_dir: /workspace"));
	}

	#[test]
	fn output_is_deterministic_for_same_input() {
		let project = project();
		let mounts = [mount("/home/me/code/moon-landing", "moon-landing")];
		let opts = ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &mounts,
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		};
		let a = generate_compose(opts.clone());
		let b = generate_compose(opts);
		assert_eq!(a, b);
	}

	#[test]
	fn dev_image_override_lands_in_output() {
		// Once we publish, callers will pass the registry tag.
		let project = project();
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "huggingface/moon-base:0.1",
			bound_mounts: &[mount("/x", "x")],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});
		assert!(render.yaml.contains("image: huggingface/moon-base:0.1"));
	}

	#[test]
	fn project_name_lands_in_output() {
		let project = project_name_for_id("scratch").unwrap();
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});
		assert!(render.yaml.contains("name: moon-ws-scratch"));
	}

	#[test]
	fn ssh_agent_forward_emits_volume_and_environment_block() {
		let project = project();
		let agent = SshAgentForward::Socket {
			host_socket: Utf8PathBuf::from("/tmp/ssh-XXXX/agent.42"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: Some(&agent),
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(render
			.yaml
			.contains("- /tmp/ssh-XXXX/agent.42:/run/host-services/ssh-auth.sock"));
		assert!(render
			.yaml
			.contains("SSH_AUTH_SOCK: \"/run/host-services/ssh-auth.sock\""));
		// The bound-folder mount still lands above the agent mount.
		assert!(render.yaml.contains("- /home/me/code/moon-ide:/workspace/moon-ide"));
	}

	#[test]
	fn agent_proxy_dir_mounts_directory_and_pub_keys_mount_read_only() {
		// ADR 0060: the proxy variant mounts the *directory* (so the
		// host can rebind the socket inside without staling the
		// mount) and `~/.ssh/*.pub` files ride along read-only.
		let project = project();
		let agent = SshAgentForward::ProxyDir {
			host_dir: Utf8PathBuf::from("/home/me/.local/share/moon-ide/ssh-agent"),
		};
		let keys = [SshPublicKeyMount {
			host_path: Utf8PathBuf::from("/home/me/.ssh/id_ed25519.pub"),
			file_name: "id_ed25519.pub".into(),
		}];
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: Some(&agent),
			ssh_public_keys: &keys,
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});
		assert!(render
			.yaml
			.contains("- /home/me/.local/share/moon-ide/ssh-agent:/run/host-services"));
		assert!(render
			.yaml
			.contains("- /home/me/.ssh/id_ed25519.pub:/home/dev/.ssh/id_ed25519.pub:ro"));
		// The in-container env is identical to the file-mount shape.
		assert!(render
			.yaml
			.contains("SSH_AUTH_SOCK: \"/run/host-services/ssh-auth.sock\""));
	}

	#[test]
	fn ssh_agent_forward_renders_with_no_bound_folders() {
		// Pre-opt-in shape: no folders bound yet but the agent
		// is still available — the renderer must not regress to
		// `volumes: []` and drop the agent mount.
		let project = project();
		let agent = SshAgentForward::Socket {
			host_socket: Utf8PathBuf::from("/run/user/1000/keyring/ssh"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: Some(&agent),
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(!render.yaml.contains("    volumes:\n      []"));
		assert!(render
			.yaml
			.contains("- /run/user/1000/keyring/ssh:/run/host-services/ssh-auth.sock"));
		assert!(render
			.yaml
			.contains("SSH_AUTH_SOCK: \"/run/host-services/ssh-auth.sock\""));
	}

	#[test]
	fn git_identity_emits_author_and_committer_env_vars() {
		let project = project();
		let identity = HostGitIdentity {
			name: "Eli Smith".into(),
			email: "eli@example.com".into(),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: Some(&identity),
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(
			render.yaml.contains("    environment:"),
			"expected environment block, got:\n{}",
			render.yaml
		);
		assert!(render.yaml.contains("GIT_AUTHOR_NAME: \"Eli Smith\""));
		assert!(render.yaml.contains("GIT_AUTHOR_EMAIL: \"eli@example.com\""));
		assert!(render.yaml.contains("GIT_COMMITTER_NAME: \"Eli Smith\""));
		assert!(render.yaml.contains("GIT_COMMITTER_EMAIL: \"eli@example.com\""));
	}

	#[test]
	fn git_identity_and_ssh_agent_share_environment_block() {
		let project = project();
		let agent = SshAgentForward::Socket {
			host_socket: Utf8PathBuf::from("/run/user/1000/keyring/ssh"),
		};
		let identity = HostGitIdentity {
			name: "Eli".into(),
			email: "eli@example.com".into(),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: Some(&agent),
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: Some(&identity),
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		// One environment block, both keys inside it. Stable order
		// (SSH first, then identity in author/committer order).
		let block_start = render.yaml.find("    environment:\n").expect("env block");
		let body = &render.yaml[block_start..];
		assert!(body.starts_with("    environment:\n      SSH_AUTH_SOCK: "));
		assert!(body.contains("GIT_AUTHOR_NAME: \"Eli\""));
		// Only one `environment:` block exists (regression against
		// future code re-emitting it for the second feature).
		assert_eq!(render.yaml.matches("    environment:\n").count(), 1);
	}

	#[test]
	fn ssh_config_mount_renders_read_only_volume() {
		let project = project();
		let cfg = SshConfigMount {
			host_path: Utf8PathBuf::from("/home/me/.ssh/config"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: Some(&cfg),
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(
			render.yaml.contains("- /home/me/.ssh/config:/home/dev/.ssh/config:ro"),
			"got:\n{}",
			render.yaml,
		);
		// No environment block: ssh config is a pure file mount,
		// no env var carries with it (unlike SSH_AUTH_SOCK).
		assert!(!render.yaml.contains("    environment:"));
	}

	#[test]
	fn ssh_config_mount_renders_with_no_bound_folders_or_agent() {
		// Pre-opt-in shape: no folders bound yet but the host's
		// ssh config is still available — the renderer must not
		// regress to `volumes: []` and drop the config mount.
		let project = project();
		let cfg = SshConfigMount {
			host_path: Utf8PathBuf::from("/home/me/.ssh/config"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: Some(&cfg),
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(!render.yaml.contains("    volumes:\n      []"));
		assert!(
			render.yaml.contains("- /home/me/.ssh/config:/home/dev/.ssh/config:ro"),
			"got:\n{}",
			render.yaml,
		);
	}

	#[test]
	fn ssh_config_and_agent_coexist_in_volumes_block() {
		// Both forwards on at once — the common case for users
		// with Host aliases in their `~/.ssh/config`. Agent
		// handles auth; config handles alias resolution.
		let project = project();
		let agent = SshAgentForward::Socket {
			host_socket: Utf8PathBuf::from("/run/user/1000/keyring/ssh"),
		};
		let cfg = SshConfigMount {
			host_path: Utf8PathBuf::from("/home/me/.ssh/config"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: Some(&agent),
			ssh_public_keys: &[],
			ssh_config: Some(&cfg),
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(render
			.yaml
			.contains("- /run/user/1000/keyring/ssh:/run/host-services/ssh-auth.sock"));
		assert!(render.yaml.contains("- /home/me/.ssh/config:/home/dev/.ssh/config:ro"));
		// Agent still drives SSH_AUTH_SOCK; config carries no env.
		assert!(render
			.yaml
			.contains("SSH_AUTH_SOCK: \"/run/host-services/ssh-auth.sock\""));
	}

	#[test]
	fn ssh_known_hosts_mount_renders_read_only_volume() {
		let project = project();
		let kh = SshKnownHostsMount {
			host_path: Utf8PathBuf::from("/home/me/.ssh/known_hosts"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: Some(&kh),
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(
			render
				.yaml
				.contains("- /home/me/.ssh/known_hosts:/home/dev/.ssh/known_hosts:ro"),
			"got:\n{}",
			render.yaml,
		);
		// No environment block: known_hosts is a pure file mount,
		// no env var carries with it.
		assert!(!render.yaml.contains("    environment:"));
	}

	#[test]
	fn ssh_config_and_known_hosts_coexist_in_volumes_block() {
		// The realistic pairing: config for alias resolution,
		// known_hosts for host-key trust beyond the pre-seeded
		// github/gitlab keys.
		let project = project();
		let cfg = SshConfigMount {
			host_path: Utf8PathBuf::from("/home/me/.ssh/config"),
		};
		let kh = SshKnownHostsMount {
			host_path: Utf8PathBuf::from("/home/me/.ssh/known_hosts"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: Some(&cfg),
			ssh_known_hosts: Some(&kh),
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(render.yaml.contains("- /home/me/.ssh/config:/home/dev/.ssh/config:ro"));
		assert!(render
			.yaml
			.contains("- /home/me/.ssh/known_hosts:/home/dev/.ssh/known_hosts:ro"));
	}

	#[test]
	fn gh_config_mount_renders_read_only_volume() {
		let project = project();
		let gh = GhConfigMount {
			host_path: Utf8PathBuf::from("/home/me/.config/gh"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: Some(&gh),
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(
			render.yaml.contains("- /home/me/.config/gh:/home/dev/.config/gh:ro"),
			"got:\n{}",
			render.yaml
		);
	}

	#[test]
	fn gh_config_mount_renders_with_no_bound_folders_or_agent() {
		let project = project();
		let gh = GhConfigMount {
			host_path: Utf8PathBuf::from("/home/me/.config/gh"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: Some(&gh),
			gh_token: None,
			moon_edit_socket: None,
		});

		assert!(!render.yaml.contains("    volumes:\n      []"));
		assert!(
			render.yaml.contains("- /home/me/.config/gh:/home/dev/.config/gh:ro"),
			"got:\n{}",
			render.yaml
		);
	}

	#[test]
	fn gh_token_renders_as_gh_token_env() {
		let project = project();
		let token = HostGhToken {
			token: "gho_abc123".into(),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: Some(&token),
			moon_edit_socket: None,
		});

		assert!(render.yaml.contains("    environment:"), "got:\n{}", render.yaml);
		assert!(
			render.yaml.contains("GH_TOKEN: \"gho_abc123\""),
			"got:\n{}",
			render.yaml
		);
	}

	#[test]
	fn gh_token_quotes_value_safely() {
		let project = project();
		let token = HostGhToken {
			token: "weird\"value\\with$specials".into(),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: Some(&token),
			moon_edit_socket: None,
		});

		assert!(
			render.yaml.contains("GH_TOKEN: \"weird\\\"value\\\\with$specials\""),
			"got:\n{}",
			render.yaml
		);
	}

	#[test]
	fn git_identity_escapes_quote_and_backslash_in_name() {
		let project = project();
		let identity = HostGitIdentity {
			name: "Eli \"the\\test\" Smith".into(),
			email: "eli@example.com".into(),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: Some(&identity),
			gh_config: None,
			gh_token: None,
			moon_edit_socket: None,
		});
		// Double-quoted scalar with `"` → `\"` and `\` → `\\`.
		assert!(
			render
				.yaml
				.contains("GIT_AUTHOR_NAME: \"Eli \\\"the\\\\test\\\" Smith\""),
			"got:\n{}",
			render.yaml
		);
	}

	#[test]
	fn moon_edit_socket_emits_read_write_bind_mount() {
		let project = project();
		let edit = MoonEditSocketMount {
			host_dir: Utf8PathBuf::from("/home/me/.local/share/moon-ide/workspaces/default/run"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[mount("/home/me/code/moon-ide", "moon-ide")],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: Some(&edit),
		});
		// Read-write (no `:ro` suffix) — Unix-socket connect needs
		// write permission on the inode. We mount the socket's
		// parent `run/` directory (ADR 0026), not the socket file.
		assert!(
			render
				.yaml
				.contains("- /home/me/.local/share/moon-ide/workspaces/default/run:/run/moon\n"),
			"got:\n{}",
			render.yaml
		);
		// And **no** `:ro` on this particular mount.
		assert!(
			!render.yaml.contains("/run/moon:ro"),
			"moon-edit socket dir must not be read-only; got:\n{}",
			render.yaml
		);
	}

	#[test]
	fn moon_edit_socket_alone_does_not_render_empty_volumes_list() {
		// When the only "mount" is the moon-edit socket (no bound
		// folders, no ssh / gh forwards), we must still emit the
		// bind line — the empty-list shortcut would drop it.
		let project = project();
		let edit = MoonEditSocketMount {
			host_dir: Utf8PathBuf::from("/tmp/x/run"),
		};
		let render = generate_compose(ComposeRenderOptions {
			project: &project,
			dev_image: "moon-base:dev",
			bound_mounts: &[],
			ssh_agent: None,
			ssh_public_keys: &[],
			ssh_config: None,
			ssh_known_hosts: None,
			git_identity: None,
			gh_config: None,
			gh_token: None,
			moon_edit_socket: Some(&edit),
		});
		assert!(!render.yaml.contains("volumes:\n      []"));
		assert!(render.yaml.contains("- /tmp/x/run:/run/moon\n"));
	}
}
