From fa1bd32800b65aab31ea732dd240261b4047522c Mon Sep 17 00:00:00 2001 From: main Date: Thu, 19 Mar 2026 15:49:41 -0400 Subject: Release adequate-rust-mcp 1.0.0 --- crates/ra-mcp-domain/src/fault.rs | 129 ++++++++++ crates/ra-mcp-domain/src/lib.rs | 5 + crates/ra-mcp-domain/src/lifecycle.rs | 259 +++++++++++++++++++ crates/ra-mcp-domain/src/types.rs | 460 ++++++++++++++++++++++++++++++++++ 4 files changed, 853 insertions(+) create mode 100644 crates/ra-mcp-domain/src/fault.rs create mode 100644 crates/ra-mcp-domain/src/lib.rs create mode 100644 crates/ra-mcp-domain/src/lifecycle.rs create mode 100644 crates/ra-mcp-domain/src/types.rs (limited to 'crates/ra-mcp-domain/src') diff --git a/crates/ra-mcp-domain/src/fault.rs b/crates/ra-mcp-domain/src/fault.rs new file mode 100644 index 0000000..6d404ab --- /dev/null +++ b/crates/ra-mcp-domain/src/fault.rs @@ -0,0 +1,129 @@ +//! Fault taxonomy and recovery guidance. + +use crate::types::Generation; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Logical fault class. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FaultClass { + /// Underlying I/O or transport channel failure. + Transport, + /// Child process startup/liveness/exiting failures. + Process, + /// Malformed or unexpected protocol payloads. + Protocol, + /// Deadline exceeded. + Timeout, + /// Internal resource budget exhaustion. + Resource, +} + +/// Fine-grained fault code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FaultCode { + /// Pipe write failed with `EPIPE`. + BrokenPipe, + /// Pipe reached EOF. + UnexpectedEof, + /// Child process exited unexpectedly. + ChildExited, + /// Child process failed to spawn. + SpawnFailed, + /// Startup sequence exceeded deadline. + StartupTimedOut, + /// Request exceeded deadline. + RequestTimedOut, + /// Received an invalid protocol frame. + InvalidFrame, + /// Received invalid JSON. + InvalidJson, + /// Response could not be correlated with a pending request. + UnknownResponseId, +} + +/// Recovery strategy for a fault. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RecoveryDirective { + /// Retry the request on the same process. + RetryInPlace, + /// Restart the worker process and retry once. + RestartAndReplay, + /// Fail-fast and bubble to the caller. + AbortRequest, +} + +/// Structured fault event. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Fault { + /// Generation in which this fault happened. + pub generation: Generation, + /// Broad fault class. + pub class: FaultClass, + /// Specific fault code. + pub code: FaultCode, + /// Caller-facing context. + pub detail: FaultDetail, +} + +impl Fault { + /// Constructs a new fault. + #[must_use] + pub fn new( + generation: Generation, + class: FaultClass, + code: FaultCode, + detail: FaultDetail, + ) -> Self { + Self { + generation, + class, + code, + detail, + } + } + + /// Returns the default recovery directive for this fault. + #[must_use] + pub fn directive(&self) -> RecoveryDirective { + match (self.class, self.code) { + (FaultClass::Transport, FaultCode::BrokenPipe) + | (FaultClass::Transport, FaultCode::UnexpectedEof) + | (FaultClass::Process, FaultCode::ChildExited) + | (FaultClass::Process, FaultCode::SpawnFailed) + | (FaultClass::Timeout, FaultCode::StartupTimedOut) => { + RecoveryDirective::RestartAndReplay + } + (FaultClass::Timeout, FaultCode::RequestTimedOut) => { + RecoveryDirective::RestartAndReplay + } + (FaultClass::Protocol, FaultCode::UnknownResponseId) => RecoveryDirective::RetryInPlace, + (FaultClass::Protocol, FaultCode::InvalidFrame) + | (FaultClass::Protocol, FaultCode::InvalidJson) + | (FaultClass::Resource, _) => RecoveryDirective::AbortRequest, + _ => RecoveryDirective::AbortRequest, + } + } +} + +/// Typed detail payload for a fault. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FaultDetail { + /// Human-consumable context. + pub message: String, +} + +impl FaultDetail { + /// Creates a new detail message. + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +/// Domain fault conversion error. +#[derive(Debug, Error)] +#[error("fault conversion failure: {0}")] +pub struct FaultConversionError(String); diff --git a/crates/ra-mcp-domain/src/lib.rs b/crates/ra-mcp-domain/src/lib.rs new file mode 100644 index 0000000..90285f0 --- /dev/null +++ b/crates/ra-mcp-domain/src/lib.rs @@ -0,0 +1,5 @@ +//! Domain model for the Adequate Rust MCP server. + +pub mod fault; +pub mod lifecycle; +pub mod types; diff --git a/crates/ra-mcp-domain/src/lifecycle.rs b/crates/ra-mcp-domain/src/lifecycle.rs new file mode 100644 index 0000000..91007ac --- /dev/null +++ b/crates/ra-mcp-domain/src/lifecycle.rs @@ -0,0 +1,259 @@ +//! Typestate machine for worker lifecycle. + +use crate::{ + fault::Fault, + types::{Generation, InvariantViolation}, +}; +use serde::{Deserialize, Serialize}; + +/// A worker in cold state (no process). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cold; + +/// A worker in startup handshake. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Starting; + +/// A healthy worker ready to serve requests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Ready; + +/// A worker currently recovering from failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recovering { + last_fault: Fault, +} + +impl Recovering { + /// Constructs recovering state from the most recent fault. + #[must_use] + pub fn new(last_fault: Fault) -> Self { + Self { last_fault } + } + + /// Returns the most recent fault. + #[must_use] + pub fn last_fault(&self) -> &Fault { + &self.last_fault + } +} + +/// Lifecycle state with a typestate payload. +#[derive(Debug, Clone)] +pub struct Lifecycle { + generation: Generation, + state: S, +} + +impl Lifecycle { + /// Constructs a cold lifecycle. + #[must_use] + pub fn cold() -> Self { + Self { + generation: Generation::genesis(), + state: Cold, + } + } + + /// Begins startup sequence. + #[must_use] + pub fn ignite(self) -> Lifecycle { + Lifecycle { + generation: self.generation, + state: Starting, + } + } +} + +impl Lifecycle { + /// Marks startup as successful. + #[must_use] + pub fn arm(self) -> Lifecycle { + Lifecycle { + generation: self.generation, + state: Ready, + } + } + + /// Marks startup as failed and enters recovery. + #[must_use] + pub fn fracture(self, fault: Fault) -> Lifecycle { + Lifecycle { + generation: self.generation, + state: Recovering::new(fault), + } + } +} + +impl Lifecycle { + /// Moves from ready to recovering after a fault. + #[must_use] + pub fn fracture(self, fault: Fault) -> Lifecycle { + Lifecycle { + generation: self.generation, + state: Recovering::new(fault), + } + } +} + +impl Lifecycle { + /// Advances generation and retries startup. + #[must_use] + pub fn respawn(self) -> Lifecycle { + Lifecycle { + generation: self.generation.next(), + state: Starting, + } + } + + /// Returns the most recent fault. + #[must_use] + pub fn last_fault(&self) -> &Fault { + self.state.last_fault() + } +} + +impl Lifecycle { + /// Returns the active generation. + #[must_use] + pub fn generation(&self) -> Generation { + self.generation + } + + /// Returns the typestate payload. + #[must_use] + pub fn state(&self) -> &S { + &self.state + } +} + +/// Serializable lifecycle snapshot for diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum LifecycleSnapshot { + /// No worker is currently running. + Cold { + /// Current generation counter. + generation: Generation, + }, + /// Worker startup is in progress. + Starting { + /// Current generation counter. + generation: Generation, + }, + /// Worker is ready for requests. + Ready { + /// Current generation counter. + generation: Generation, + }, + /// Worker is recovering after a fault. + Recovering { + /// Current generation counter. + generation: Generation, + /// Most recent fault. + last_fault: Fault, + }, +} + +/// Dynamically typed lifecycle state for runtime storage. +#[derive(Debug, Clone)] +pub enum DynamicLifecycle { + /// Cold typestate wrapper. + Cold(Lifecycle), + /// Starting typestate wrapper. + Starting(Lifecycle), + /// Ready typestate wrapper. + Ready(Lifecycle), + /// Recovering typestate wrapper. + Recovering(Lifecycle), +} + +impl DynamicLifecycle { + /// Creates a cold dynamic lifecycle. + #[must_use] + pub fn cold() -> Self { + Self::Cold(Lifecycle::cold()) + } + + /// Returns the serializable snapshot. + #[must_use] + pub fn snapshot(&self) -> LifecycleSnapshot { + match self { + Self::Cold(state) => LifecycleSnapshot::Cold { + generation: state.generation(), + }, + Self::Starting(state) => LifecycleSnapshot::Starting { + generation: state.generation(), + }, + Self::Ready(state) => LifecycleSnapshot::Ready { + generation: state.generation(), + }, + Self::Recovering(state) => LifecycleSnapshot::Recovering { + generation: state.generation(), + last_fault: state.last_fault().clone(), + }, + } + } + + /// Enters startup from cold or recovering. + pub fn begin_startup(self) -> Result { + match self { + Self::Cold(state) => Ok(Self::Starting(state.ignite())), + Self::Recovering(state) => Ok(Self::Starting(state.respawn())), + Self::Starting(_) | Self::Ready(_) => Err(InvariantViolation::new( + "invalid lifecycle transition to starting", + )), + } + } + + /// Marks startup as complete. + pub fn complete_startup(self) -> Result { + match self { + Self::Starting(state) => Ok(Self::Ready(state.arm())), + _ => Err(InvariantViolation::new( + "invalid lifecycle transition to ready", + )), + } + } + + /// Records a fault and enters recovering state. + pub fn fracture(self, fault: Fault) -> Result { + match self { + Self::Starting(state) => Ok(Self::Recovering(state.fracture(fault))), + Self::Ready(state) => Ok(Self::Recovering(state.fracture(fault))), + Self::Recovering(state) => Ok(Self::Recovering(Lifecycle { + generation: state.generation(), + state: Recovering::new(fault), + })), + Self::Cold(_) => Err(InvariantViolation::new("cannot fracture cold lifecycle")), + } + } +} + +#[cfg(test)] +mod tests { + use super::{DynamicLifecycle, Lifecycle, LifecycleSnapshot}; + use crate::fault::{Fault, FaultClass, FaultCode, FaultDetail}; + + #[test] + fn typestate_chain_advances_generation_on_recovery() { + let cold = Lifecycle::cold(); + let starting = cold.ignite(); + let ready = starting.arm(); + let ready_generation = ready.generation(); + let fault = Fault::new( + ready_generation, + FaultClass::Transport, + FaultCode::BrokenPipe, + FaultDetail::new("broken pipe"), + ); + let recovering = ready.fracture(fault); + let restarted = recovering.respawn(); + assert!(restarted.generation() > ready_generation); + } + + #[test] + fn dynamic_snapshot_of_recovering_is_infallible() { + let cold = DynamicLifecycle::cold(); + assert!(matches!(cold.snapshot(), LifecycleSnapshot::Cold { .. })); + } +} diff --git a/crates/ra-mcp-domain/src/types.rs b/crates/ra-mcp-domain/src/types.rs new file mode 100644 index 0000000..db709d6 --- /dev/null +++ b/crates/ra-mcp-domain/src/types.rs @@ -0,0 +1,460 @@ +//! Fundamental domain types. + +use serde::{Deserialize, Deserializer, Serialize}; +use std::{ + num::NonZeroU64, + path::{Path, PathBuf}, +}; +use thiserror::Error; + +/// A value that failed a domain-level invariant. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("domain invariant violated: {detail}")] +pub struct InvariantViolation { + detail: &'static str, +} + +impl InvariantViolation { + /// Creates a new invariant violation. + #[must_use] + pub fn new(detail: &'static str) -> Self { + Self { detail } + } +} + +/// Process generation for a rust-analyzer worker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Generation(NonZeroU64); + +impl Generation { + /// Returns the first generation. + #[must_use] + pub fn genesis() -> Self { + Self(NonZeroU64::MIN) + } + + /// Returns the inner integer value. + #[must_use] + pub fn get(self) -> u64 { + self.0.get() + } + + /// Advances to the next generation. + #[must_use] + pub fn next(self) -> Self { + let next = self.get().saturating_add(1); + let non_zero = NonZeroU64::new(next).map_or(NonZeroU64::MAX, |value| value); + Self(non_zero) + } +} + +/// A non-empty absolute workspace root. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceRoot(PathBuf); + +impl WorkspaceRoot { + /// Constructs a validated workspace root. + pub fn try_new(path: PathBuf) -> Result { + if !path.is_absolute() { + return Err(InvariantViolation::new("workspace root must be absolute")); + } + if path.as_os_str().is_empty() { + return Err(InvariantViolation::new("workspace root must be non-empty")); + } + Ok(Self(path)) + } + + /// Returns the root path. + #[must_use] + pub fn as_path(&self) -> &Path { + self.0.as_path() + } +} + +/// A non-empty absolute source file path. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct SourceFilePath(PathBuf); + +impl SourceFilePath { + /// Constructs a validated source file path. + pub fn try_new(path: PathBuf) -> Result { + if !path.is_absolute() { + return Err(InvariantViolation::new("source file path must be absolute")); + } + if path.as_os_str().is_empty() { + return Err(InvariantViolation::new( + "source file path must be non-empty", + )); + } + Ok(Self(path)) + } + + /// Returns the underlying path. + #[must_use] + pub fn as_path(&self) -> &Path { + self.0.as_path() + } +} + +impl<'de> Deserialize<'de> for SourceFilePath { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let path = PathBuf::deserialize(deserializer)?; + Self::try_new(path).map_err(serde::de::Error::custom) + } +} + +/// One-indexed source line number. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OneIndexedLine(NonZeroU64); + +impl OneIndexedLine { + /// Constructs a one-indexed line. + pub fn try_new(raw: u64) -> Result { + let line = NonZeroU64::new(raw).ok_or(InvariantViolation::new("line must be >= 1"))?; + Ok(Self(line)) + } + + /// Returns the one-indexed value. + #[must_use] + pub fn get(self) -> u64 { + self.0.get() + } + + /// Returns the corresponding zero-indexed value for LSP. + #[must_use] + pub fn to_zero_indexed(self) -> u32 { + let raw = self.get().saturating_sub(1); + u32::try_from(raw).unwrap_or(u32::MAX) + } +} + +/// One-indexed source column number. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OneIndexedColumn(NonZeroU64); + +impl OneIndexedColumn { + /// Constructs a one-indexed column. + pub fn try_new(raw: u64) -> Result { + let column = NonZeroU64::new(raw).ok_or(InvariantViolation::new("column must be >= 1"))?; + Ok(Self(column)) + } + + /// Returns the one-indexed value. + #[must_use] + pub fn get(self) -> u64 { + self.0.get() + } + + /// Returns the corresponding zero-indexed value for LSP. + #[must_use] + pub fn to_zero_indexed(self) -> u32 { + let raw = self.get().saturating_sub(1); + u32::try_from(raw).unwrap_or(u32::MAX) + } +} + +/// A file-local source point. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct SourcePoint { + line: OneIndexedLine, + column: OneIndexedColumn, +} + +impl SourcePoint { + /// Constructs a file-local source point. + #[must_use] + pub fn new(line: OneIndexedLine, column: OneIndexedColumn) -> Self { + Self { line, column } + } + + /// Returns the line component. + #[must_use] + pub fn line(self) -> OneIndexedLine { + self.line + } + + /// Returns the column component. + #[must_use] + pub fn column(self) -> OneIndexedColumn { + self.column + } +} + +/// Request position in a source file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourcePosition { + file_path: SourceFilePath, + #[serde(flatten)] + point: SourcePoint, +} + +impl SourcePosition { + /// Constructs a request position. + #[must_use] + pub fn new(file_path: SourceFilePath, point: SourcePoint) -> Self { + Self { file_path, point } + } + + /// Returns the source file path. + #[must_use] + pub fn file_path(&self) -> &SourceFilePath { + &self.file_path + } + + /// Returns the file-local point. + #[must_use] + pub fn point(&self) -> SourcePoint { + self.point + } + + /// Returns the one-indexed line. + #[must_use] + pub fn line(&self) -> OneIndexedLine { + self.point.line() + } + + /// Returns the one-indexed column. + #[must_use] + pub fn column(&self) -> OneIndexedColumn { + self.point.column() + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SourcePositionWire { + file_path: SourceFilePath, + #[serde(flatten)] + point: SourcePoint, +} + +impl<'de> Deserialize<'de> for SourcePosition { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let SourcePositionWire { file_path, point } = + SourcePositionWire::deserialize(deserializer)?; + Ok(Self::new(file_path, point)) + } +} + +/// A concrete source location. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourceLocation { + file_path: SourceFilePath, + #[serde(flatten)] + point: SourcePoint, +} + +impl SourceLocation { + /// Constructs a source location. + #[must_use] + pub fn new(file_path: SourceFilePath, point: SourcePoint) -> Self { + Self { file_path, point } + } + + /// Returns the source file path. + #[must_use] + pub fn file_path(&self) -> &SourceFilePath { + &self.file_path + } + + /// Returns the file-local point. + #[must_use] + pub fn point(&self) -> SourcePoint { + self.point + } + + /// Returns the one-indexed line. + #[must_use] + pub fn line(&self) -> OneIndexedLine { + self.point.line() + } + + /// Returns the one-indexed column. + #[must_use] + pub fn column(&self) -> OneIndexedColumn { + self.point.column() + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SourceLocationWire { + file_path: SourceFilePath, + #[serde(flatten)] + point: SourcePoint, +} + +impl<'de> Deserialize<'de> for SourceLocation { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let SourceLocationWire { file_path, point } = + SourceLocationWire::deserialize(deserializer)?; + Ok(Self::new(file_path, point)) + } +} + +/// A source range in a specific file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourceRange { + file_path: SourceFilePath, + start: SourcePoint, + end: SourcePoint, +} + +impl SourceRange { + /// Constructs a validated source range. + pub fn try_new( + file_path: SourceFilePath, + start: SourcePoint, + end: SourcePoint, + ) -> Result { + if end < start { + return Err(InvariantViolation::new( + "source range end must not precede start", + )); + } + Ok(Self { + file_path, + start, + end, + }) + } + + /// Returns the source file path. + #[must_use] + pub fn file_path(&self) -> &SourceFilePath { + &self.file_path + } + + /// Returns the start point. + #[must_use] + pub fn start(&self) -> SourcePoint { + self.start + } + + /// Returns the end point. + #[must_use] + pub fn end(&self) -> SourcePoint { + self.end + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SourceRangeWire { + file_path: SourceFilePath, + start: SourcePoint, + end: SourcePoint, +} + +impl<'de> Deserialize<'de> for SourceRange { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let SourceRangeWire { + file_path, + start, + end, + } = SourceRangeWire::deserialize(deserializer)?; + Self::try_new(file_path, start, end).map_err(serde::de::Error::custom) + } +} + +/// A monotonically increasing request sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RequestSequence(NonZeroU64); + +impl RequestSequence { + /// Starts a fresh sequence. + #[must_use] + pub fn genesis() -> Self { + Self(NonZeroU64::MIN) + } + + /// Returns the current integer value. + #[must_use] + pub fn get(self) -> u64 { + self.0.get() + } + + /// Consumes and returns the next sequence. + #[must_use] + pub fn next(self) -> Self { + let next = self.get().saturating_add(1); + let non_zero = NonZeroU64::new(next).map_or(NonZeroU64::MAX, |value| value); + Self(non_zero) + } +} + +#[cfg(test)] +mod tests { + use super::{ + Generation, InvariantViolation, OneIndexedColumn, OneIndexedLine, RequestSequence, + SourceFilePath, SourcePoint, SourceRange, + }; + use assert_matches::assert_matches; + use std::{num::NonZeroU64, path::PathBuf}; + + #[test] + fn generation_advances_monotonically() { + let first = Generation::genesis(); + let second = first.next(); + let third = second.next(); + assert!(first < second); + assert!(second < third); + } + + #[test] + fn generation_saturates_at_maximum() { + let max = Generation(NonZeroU64::MAX); + assert_eq!(max.next(), max); + } + + #[test] + fn line_must_be_one_or_greater() { + assert_matches!(OneIndexedLine::try_new(0), Err(InvariantViolation { .. })); + } + + #[test] + fn column_must_be_one_or_greater() { + assert_matches!(OneIndexedColumn::try_new(0), Err(InvariantViolation { .. })); + } + + #[test] + fn source_range_rejects_reversed_points() { + let file_path = SourceFilePath::try_new(PathBuf::from("/tmp/range.rs")); + assert!(file_path.is_ok()); + let file_path = match file_path { + Ok(value) => value, + Err(_) => return, + }; + let start = SourcePoint::new( + OneIndexedLine::try_new(4).unwrap_or(OneIndexedLine(NonZeroU64::MIN)), + OneIndexedColumn::try_new(3).unwrap_or(OneIndexedColumn(NonZeroU64::MIN)), + ); + let end = SourcePoint::new( + OneIndexedLine::try_new(2).unwrap_or(OneIndexedLine(NonZeroU64::MIN)), + OneIndexedColumn::try_new(1).unwrap_or(OneIndexedColumn(NonZeroU64::MIN)), + ); + assert_matches!( + SourceRange::try_new(file_path, start, end), + Err(InvariantViolation { .. }) + ); + } + + #[test] + fn request_sequence_saturates_at_maximum() { + let max = RequestSequence(NonZeroU64::MAX); + assert_eq!(max.next().get(), u64::MAX); + } +} -- cgit v1.2.3