Introduce the StagedLinuxRustPayloadRole enum
Intent: Define a central enum that enumerates the two payload roles (WorkspaceDeps and WorkspaceBuild) and exposes their associated string identifiers through methods rather than scattered literals.
Affected files: crates/pikaci/src/model.rs
Evidence
@@ -167,6 +167,36 @@ pub struct StagedLinuxRustTargetConfig {
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum StagedLinuxRustPayloadRole {
+ WorkspaceDeps,
+ WorkspaceBuild,
+}
+
+impl StagedLinuxRustPayloadRole {
+ pub fn prepare_node_suffix(self) -> &'static str {
+ match self {
+ Self::WorkspaceDeps => "workspace-deps",
+ Self::WorkspaceBuild => "workspace-build",
+ }
+ }
+
+ pub fn mount_dir_name(self) -> &'static str {
+ self.prepare_node_suffix()
+ }
+
+ pub fn device_prefix(self) -> &'static str {
+ self.prepare_node_suffix()
+ }
+
+ pub fn prepare_description(self) -> &'static str {
+ match self {
+ Self::WorkspaceDeps => "Build staged Linux Rust dependencies",
+ Self::WorkspaceBuild => "Build staged Linux Rust test artifacts",
+ }
+ }
+}
A new StagedLinuxRustPayloadRole enum is added directly after StagedLinuxRustTargetConfig. It carries two variants — WorkspaceDeps and WorkspaceBuild — and provides four accessor methods:
prepare_node_suffix — returns the kebab-case suffix used when constructing prepare-node IDs (e.g. "workspace-deps").
mount_dir_name — returns the directory name under staged-linux-rust/ where the payload is mounted. Currently delegates to prepare_node_suffix since the values are identical, but having a separate method preserves the semantic distinction.
device_prefix — same delegation, used when matching StagedPayloadMount entries.
prepare_description — returns a human-readable label used in plan-node descriptions.
By deriving Clone, Copy, Eq, PartialEq, the enum can be cheaply passed by value and compared in tests.
Add payload_roles() and payload_output_name() to StagedLinuxRustLane
Intent: Give each lane a canonical ordered list of its payload roles and a single dispatch method to resolve the Nix output name for a given role, replacing the two separate output-name methods that callers previously used directly.
Affected files: crates/pikaci/src/model.rs
Evidence
@@ -253,6 +283,20 @@ impl StagedLinuxRustLane {
+ pub fn payload_roles(self) -> [StagedLinuxRustPayloadRole; 2] {
+ [
+ StagedLinuxRustPayloadRole::WorkspaceDeps,
+ StagedLinuxRustPayloadRole::WorkspaceBuild,
+ ]
+ }
+
+ pub fn payload_output_name(self, role: StagedLinuxRustPayloadRole) -> &'static str {
+ match role {
+ StagedLinuxRustPayloadRole::WorkspaceDeps => self.workspace_deps_output_name(),
+ StagedLinuxRustPayloadRole::WorkspaceBuild => self.workspace_build_output_name(),
+ }
+ }
payload_roles() returns a fixed-size [StagedLinuxRustPayloadRole; 2] array. The ordering is significant: WorkspaceDeps comes first because the build step depends on the deps step, and the run-plan builder in run.rs will chain dependencies in iteration order.
payload_output_name() dispatches to the existing per-role output-name methods (workspace_deps_output_name / workspace_build_output_name). This provides a single entry point that the plan builder can call generically without knowing which role it is processing.
Add a contract test for the new payload-role API
Intent: Lock down the string values returned by the enum and the output-name mapping so that future changes to these identifiers break a test before they break a deployment.
Affected files: crates/pikaci/src/model.rs
Evidence
@@ -936,6 +980,34 @@ mod tests {
+ #[test]
+ fn staged_linux_payload_roles_use_stable_mount_and_output_contracts() {
+ let lane = StagedLinuxRustLane::NotificationsServerPackageTests;
+ let roles = lane.payload_roles();
+
+ assert_eq!(
+ roles,
+ [
+ StagedLinuxRustPayloadRole::WorkspaceDeps,
+ StagedLinuxRustPayloadRole::WorkspaceBuild,
+ ]
+ );
+ assert_eq!(roles[0].prepare_node_suffix(), "workspace-deps");
+ assert_eq!(roles[0].mount_dir_name(), "workspace-deps");
+ assert_eq!(roles[0].device_prefix(), "workspace-deps");
+ assert_eq!(
+ lane.payload_output_name(roles[0]),
+ "ci.x86_64-linux.notificationsWorkspaceDeps"
+ );
+ assert_eq!(roles[1].prepare_node_suffix(), "workspace-build");
+ assert_eq!(roles[1].mount_dir_name(), "workspace-build");
+ assert_eq!(roles[1].device_prefix(), "workspace-build");
+ assert_eq!(
+ lane.payload_output_name(roles[1]),
+ "ci.x86_64-linux.notificationsWorkspaceBuild"
+ );
+ }
The test picks the NotificationsServerPackageTests lane as a representative and asserts:
payload_roles() returns the two roles in the expected order.
- Each role's
prepare_node_suffix, mount_dir_name, and device_prefix return the expected kebab-case strings.
payload_output_name for each role resolves to the full Nix output attribute path (e.g. ci.x86_64-linux.notificationsWorkspaceDeps).
These assertions act as a contract: if anyone changes a string constant, they will see a test failure that explicitly shows the expected value.
Replace duplicated plan-node blocks with a role-driven loop
Intent: Eliminate the copy-pasted blocks that separately handled workspace-deps and workspace-build prepare nodes, replacing them with a single `for role in lane.payload_roles()` loop that constructs nodes generically.
Affected files: crates/pikaci/src/run.rs
Evidence
@@ -1492,59 +1488,18 @@ fn build_run_plan(
- let deps_node_id = format!("prepare-{prefix}-workspace-deps");
- let build_node_id = format!("prepare-{prefix}-workspace-build");
+ let mut previous_node_id = None;
+ for role in lane.payload_roles() {
+ let node_id = format!("prepare-{prefix}-{}", role.prepare_node_suffix());
+ let installable = staged_linux_rust_installable(&snapshot.snapshot_dir, lane, role);
@@ -1555,16 +1510,17 @@ fn build_run_plan(
- id: build_node_id.clone(),
+ id: node_id.clone(),
description: format!(
- "Build staged Linux Rust test artifacts for {}",
+ "{} for {}",
+ role.prepare_description(),
lane.shared_prepare_description()
),
@@ -1573,31 +1529,27 @@ fn build_run_plan(
+ depends_on.push(node_id.clone());
+ previous_node_id = Some(node_id);
}
- depends_on.push(deps_node_id);
- depends_on.push(build_node_id);
The most impactful change is in build_run_plan. Previously there were two nearly identical blocks: one constructing a prepare-…-workspace-deps node and another for prepare-…-workspace-build, each with duplicated PlannedPrepare / PlanNodeRecord::Prepare construction, mount-lookup logic, and dependency wiring.
The new code replaces both blocks with a single loop:
let mut previous_node_id = None;
for role in lane.payload_roles() {
let node_id = format!("prepare-{prefix}-{}", role.prepare_node_suffix());
...
previous_node_id = Some(node_id);
}
Key behavioral details preserved by the loop:
- Dependency chaining:
previous_node_id tracks the last node so that each subsequent role depends on the one before it (workspace-build depends on workspace-deps).
- Description formatting: Uses
role.prepare_description() instead of hard-coded strings, producing the same output ("Build staged Linux Rust dependencies for …").
- Output name resolution: Calls
lane.payload_output_name(role) instead of choosing between workspace_deps_output_name() and workspace_build_output_name().
- Mount and device prefix lookup: Uses
role.mount_dir_name() and role.device_prefix() instead of string literals.
Refactor staged_linux_rust_installable to accept a role instead of a boolean
Intent: Replace the boolean `deps_only` parameter with the typed `StagedLinuxRustPayloadRole` enum, making the function signature self-documenting and consistent with the rest of the refactor.
Affected files: crates/pikaci/src/run.rs
Evidence
@@ -1776,13 +1728,9 @@ fn remove_path_if_exists(path: &Path) -> anyhow::Result<()> {
fn staged_linux_rust_installable(
snapshot_dir: &Path,
lane: StagedLinuxRustLane,
- deps_only: bool,
+ role: StagedLinuxRustPayloadRole,
) -> String {
- let output_name = if deps_only {
- lane.workspace_deps_output_name()
- } else {
- lane.workspace_build_output_name()
- };
+ let output_name = lane.payload_output_name(role);
format!("path:{}#{output_name}", snapshot_dir.display())
}
The helper function staged_linux_rust_installable previously took a bool parameter deps_only and used an if/else to select the output name. This is the classic boolean-parameter code smell — at the call site, true and false carry no inherent meaning.
The new signature accepts role: StagedLinuxRustPayloadRole, delegates to lane.payload_output_name(role), and removes the conditional entirely. The single call site in the loop naturally passes the current role value.
Refactor staged payload mount construction to use roles
Intent: Replace the two hand-written `StagedPayloadMount` structs with a role-driven iterator, ensuring the mount paths and device prefixes stay consistent with the enum's single source of truth.
Affected files: crates/pikaci/src/run.rs
Evidence
@@ -1468,21 +1469,16 @@ fn build_run_plan(
- .map(|_| {
- vec![
- StagedPayloadMount {
+ .map(|lane| {
+ lane.payload_roles()
+ .into_iter()
+ .map(|role| StagedPayloadMount {
local_mount_path: job_dir
.join("staged-linux-rust")
- .join("workspace-deps"),
- device_prefix: "workspace-deps".to_string(),
- },
- StagedPayloadMount {
- local_mount_path: job_dir
- .join("staged-linux-rust")
- .join("workspace-build"),
- device_prefix: "workspace-build".to_string(),
- },
- ]
+ .join(role.mount_dir_name()),
+ device_prefix: role.device_prefix().to_string(),
+ })
+ .collect()
Earlier in build_run_plan, the staged_payload_mounts field was populated by manually constructing a two-element Vec with hard-coded "workspace-deps" and "workspace-build" strings.
The new code maps over lane.payload_roles() and builds each StagedPayloadMount from the role's mount_dir_name() and device_prefix() methods. This keeps mount construction in sync with the prepare-node loop — if a role's identifiers change, both locations update from the same enum method.