Skip to content
35 changes: 28 additions & 7 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,18 @@ only the workspace-root lockfile is processed, its lone failure is re-raised as
the original cargo error rather than wrapped in the aggregate message.

`BumpOptions` carries the dependency-injection points used by
`lading.commands.bump.run`. The `command_runner` field accepts an optional
`CommandRunner`, matching the command-runner protocol used by publish
execution. When `command_runner` is `None`, bump falls back to the default
subprocess runner. Tests pass a runner explicitly so lockfile commands can be
observed without invoking real Cargo processes.
`lading.commands.bump.run`. Lockfile operations are reached through the
`lockfile_repository` field, a `bump_lockfiles.LockfileRepository` port
introduced by issue #82: the bump domain never holds a raw command runner. When
the field is `None`, bump uses `bump_lockfiles.CargoLockfileRepository`, the
cargo-backed adapter bound to the default subprocess runner; the CLI binds the
adapter to its selected runner. Tests inject a repository (or bind the adapter
to a recording runner) so lockfile commands can be observed without invoking
real Cargo processes. The port's scope is bump-side lockfile projection and
regeneration; publish-side discovery and validation go through the sibling
`lockfile.LockfileInspectionRepository` port (see the Lockfile helpers section
below), so neither the bump nor the publish lockfile domain holds a raw
`CommandRunner` (issue #82).

Bump-time crate-set derivation is centralized in the bump context: the
`excluded` and `updated_crate_names` sets are computed exactly once in
Expand Down Expand Up @@ -330,8 +337,22 @@ manifest rewrites, while publish only probes freshness read-only via
`cargo metadata --locked --manifest-path ... --format-version=1`. It returns a
`LockfileFreshness` result that distinguishes fresh lockfiles, lockfiles that
Cargo says need updating under `--locked`, and unrelated Cargo failures.
`_validate_lockfile_freshness` in `publish_preflight.py` calls it before the
cargo check/test pre-flight.

The publish pre-flight domain reaches both operations through the
`LockfileInspectionRepository` port (issue #82) rather than holding a command
runner: `_validate_lockfile_freshness` and `_collect_stale_lockfiles` in
`publish_preflight.py` depend only on the port, so VCS (git), filesystem, and
cargo execution concerns stay out of the freshness-classification logic.
`CargoLockfileInspectionRepository` is the git- and cargo-backed adapter; it
binds a `CommandRunner` and the optional pre-flight base environment, applying
that environment to any invocation that does not supply its own.
`publish_preflight._run_preflight_checks` is the composition root: it binds the
adapter to the selected command runner and pre-flight environment. Tests inject
a port double at the `_validate_lockfile_freshness` seam, observing discovery
and validation without invoking real git or cargo. This is the publish-side
counterpart to the bump-side `bump_lockfiles.LockfileRepository`; together they
complete issue #82's separation of lockfile VCS/filesystem concerns from the
command domain.

`LockfileDiscoveryError` inherits `LadingError`; its messages include the git
failure detail.
Expand Down
29 changes: 29 additions & 0 deletions docs/lading-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,19 @@ lading bump <new_version> [--dry-run]
documentation counts, and documentation entries are suffixed with
`(documentation)` for clarity.

### Lockfile repository port (bump side)

The bump domain reaches Cargo lockfile projection and regeneration through
a `LockfileRepository` port defined in
`lading.commands.bump_lockfiles`. `CargoLockfileRepository` is the
cargo-backed adapter; it is constructed with an optional `CommandRunner`
and delegates to the module-level helpers.
`BumpOptions.lockfile_repository` is the injection point; when `None`,
bump substitutes `CargoLockfileRepository` bound to the default
subprocess runner, and the CLI binds the adapter at the composition
root. This keeps the bump domain free of a raw `CommandRunner`
(issue #82).

## 4. `publish` Subcommand Design

The `publish` command orchestrates the publication of crates to the designated
Expand Down Expand Up @@ -477,6 +490,22 @@ names are listed before returning the user-specified order.
compiletest `*.stderr` files when cargo test fails, exposing the debug
diff directly in the CLI output.

### Lockfile inspection repository port (publish side)

The publish pre-flight domain reaches tracked-lockfile discovery and
freshness validation through a `LockfileInspectionRepository` port
defined in `lading.commands.lockfile`.
`CargoLockfileInspectionRepository` is the git- and cargo-backed
adapter; it binds a `CommandRunner` and the optional pre-flight base
environment, applying that environment to invocations that do not
supply their own. `publish_preflight._run_preflight_checks` is the
composition root: it constructs the adapter and passes it to the
domain helpers `_collect_stale_lockfiles` and
`_validate_lockfile_freshness`, which depend only on the port.
Together with the bump-side `LockfileRepository`, the two ports keep
VCS, filesystem, and cargo execution concerns out of the lockfile
domain logic (issue #82).

### Publish Preflight Sequence

The preflight sequence diagram illustrates the pre-flight checks that run
Expand Down
4 changes: 3 additions & 1 deletion lading/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,9 @@ def bump(
rebuild_lockfiles=rebuild_lockfiles,
configuration=configuration,
workspace=workspace,
command_runner=command_runner,
lockfile_repository=commands.bump_lockfiles.CargoLockfileRepository(
runner=command_runner
),
),
),
)
Expand Down
4 changes: 2 additions & 2 deletions lading/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

from __future__ import annotations

from . import bump, publish
from . import bump, bump_lockfiles, publish

__all__ = ["bump", "publish"]
__all__ = ["bump", "bump_lockfiles", "publish"]
27 changes: 13 additions & 14 deletions lading/commands/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@

from lading.commands.bump_manifests import _BumpContext
from lading.config import LadingConfig
from lading.runtime import CommandRunner
from lading.workspace import WorkspaceCrate, WorkspaceGraph

LOGGER = logging.getLogger(__name__)
Expand All @@ -88,9 +87,11 @@ class BumpOptions:
workspace : WorkspaceGraph | None, default None
Loaded workspace graph. Programmatic callers may omit it only when they
want ``run`` to inspect the workspace.
command_runner : CommandRunner | None, default None
Callable with the runtime command-runner interface. It is used for
lockfile rebuild commands; ``None`` uses the default subprocess runner.
lockfile_repository : bump_lockfiles.LockfileRepository | None, default None
Port used for lockfile projection and regeneration. ``None`` selects
the cargo-backed adapter with the default subprocess runner. Tests
inject a repository so lockfile behaviour can be observed without
invoking real Cargo processes.
dependency_sections : Mapping[str, Collection[str]]
Explicit dependency sections to rewrite by crate name.
include_workspace_sections : bool, default False
Expand All @@ -106,7 +107,7 @@ class BumpOptions:
rebuild_lockfiles: bool | None = None
configuration: LadingConfig | None = None
workspace: WorkspaceGraph | None = None
command_runner: CommandRunner | None = None
lockfile_repository: bump_lockfiles.LockfileRepository | None = None
dependency_sections: cabc.Mapping[str, cabc.Collection[str]] = dc.field(
default_factory=lambda: types.MappingProxyType({})
)
Expand Down Expand Up @@ -192,7 +193,7 @@ def _initialize_bump_context(
rebuild_lockfiles=rebuild_lockfiles,
configuration=configuration,
workspace=workspace,
command_runner=resolved_options.command_runner,
lockfile_repository=resolved_options.lockfile_repository,
dependency_sections=resolved_options.dependency_sections,
include_workspace_sections=resolved_options.include_workspace_sections,
)
Expand Down Expand Up @@ -319,15 +320,13 @@ def _process_lockfiles(
if context.base_options.rebuild_lockfiles is not True or not changed_manifests:
return ()
lockfile_manifests = context.configuration.bump.lockfile_manifests
if context.base_options.dry_run:
return bump_lockfiles.resolve_lockfile_paths(
context.root_path, lockfile_manifests
)
return bump_lockfiles.regenerate_lockfiles(
context.root_path,
lockfile_manifests,
runner=context.base_options.command_runner,
repository = (
context.base_options.lockfile_repository
or bump_lockfiles.CargoLockfileRepository()
)
if context.base_options.dry_run:
return repository.resolve_lockfile_paths(context.root_path, lockfile_manifests)
return repository.regenerate_lockfiles(context.root_path, lockfile_manifests)


def _prepare_sorted_changes(
Expand Down
146 changes: 146 additions & 0 deletions lading/commands/bump_lockfiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,149 @@ def _run_workspace_lockfile_update(
stderr,
)
raise LockfileRegenerationError(message)


@dc.dataclass(frozen=True, slots=True)
class CargoLockfileRepository:
"""Cargo-backed :class:`LockfileRepository` bound to a command runner."""

runner: CommandRunner | None = None

def resolve_lockfile_paths(
self,
workspace_root: Path,
lockfile_manifests: cabc.Sequence[str],
) -> tuple[Path, ...]:
"""Return the lockfile paths a regeneration run would touch.

Parameters
----------
workspace_root :
Absolute path to the Cargo workspace root.
lockfile_manifests :
Configured manifest paths relative to *workspace_root*. The
workspace-root ``Cargo.toml`` is always prepended and
de-duplicated.

Returns
-------
tuple[Path, ...]
The lockfile paths that a regeneration run would touch,
in manifest execution order.

Raises
------
LockfileRegenerationError
If any configured manifest path is invalid (outside the
workspace or not named ``Cargo.toml``), propagated from
:func:`_resolve_manifest_paths`.
"""
return resolve_lockfile_paths(workspace_root, lockfile_manifests)

def regenerate_lockfiles(
self,
workspace_root: Path,
lockfile_manifests: cabc.Sequence[str],
) -> tuple[Path, ...]:
"""Regenerate lockfiles via ``cargo update --workspace``.

Parameters
----------
workspace_root :
Absolute path to the Cargo workspace root.
lockfile_manifests :
Configured manifest paths relative to *workspace_root*. The
workspace-root ``Cargo.toml`` is always prepended and
de-duplicated.

Returns
-------
tuple[Path, ...]
Paths to every ``Cargo.lock`` regenerated, in manifest
execution order.

Raises
------
LockfileRegenerationError
If any configured manifest path is invalid (outside the
workspace or not named ``Cargo.toml``), or — after every
manifest has been attempted — if ``cargo update --workspace``
failed. A lone workspace-root failure re-raises the original
cargo error unchanged; multiple failures raise one aggregated
error listing each failed manifest with a repair command.
"""
return regenerate_lockfiles(
workspace_root, lockfile_manifests, runner=self.runner
)


class LockfileRepository(typ.Protocol):
"""Port for projecting and regenerating Cargo lockfiles after a bump.

The bump domain depends on this protocol rather than on a command
runner, keeping execution infrastructure out of the public bump options
(issue #82).
"""

def resolve_lockfile_paths(
self,
workspace_root: Path,
lockfile_manifests: cabc.Sequence[str],
) -> tuple[Path, ...]:
"""Return the lockfile paths a regeneration run would touch.

Parameters
----------
workspace_root :
Absolute path to the Cargo workspace root.
lockfile_manifests :
Configured manifest paths relative to *workspace_root*. The
workspace-root ``Cargo.toml`` is always prepended and
de-duplicated.

Returns
-------
tuple[Path, ...]
The lockfile paths that a regeneration run would touch,
in manifest execution order.

Raises
------
LockfileRegenerationError
If any configured manifest path is invalid (outside the
workspace or not named ``Cargo.toml``), propagated from
:func:`_resolve_manifest_paths`.
"""

def regenerate_lockfiles(
self,
workspace_root: Path,
lockfile_manifests: cabc.Sequence[str],
) -> tuple[Path, ...]:
"""Regenerate lockfiles and return the rewritten paths.

Parameters
----------
workspace_root :
Absolute path to the Cargo workspace root.
lockfile_manifests :
Configured manifest paths relative to *workspace_root*. The
workspace-root ``Cargo.toml`` is always prepended and
de-duplicated.

Returns
-------
tuple[Path, ...]
Paths to every ``Cargo.lock`` regenerated, in manifest
execution order.

Raises
------
LockfileRegenerationError
If any configured manifest path is invalid (outside the
workspace or not named ``Cargo.toml``), or — after every
manifest has been attempted — if ``cargo update --workspace``
failed. A lone workspace-root failure re-raises the original
cargo error unchanged; multiple failures raise one aggregated
error listing each failed manifest with a repair command.
"""
Loading
Loading