use crate::{
buffer::{BufferEditKind, BufferEditShape},
ecs::{
components::buffer::BufferEntity,
events::edit::{
PluginBufferEditProposalReceipt, PluginBufferEditProvenance, PluginProposalId,
RevisionGuardedBufferEditRequested,
},
},
plugin::{
PluginIdentity, PluginOperationalEvent, PluginOperationalQueue, PluginProposalReviewMode,
ValidatedPluginRegistry,
},
text_stream::{TextRange, TextRevision},
};
use bevy::prelude::Resource;
use std::{
collections::VecDeque,
fmt::{Debug, Display, Formatter},
num::{NonZeroU64, NonZeroUsize},
};
pub const DEFAULT_PLUGIN_PROPOSAL_LANE_LIMIT: usize = 1024;
pub const DEFAULT_PLUGIN_PROPOSAL_RECEIPT_LOG_LIMIT: usize = 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PluginProposalLaneLimit {
max_pending: NonZeroUsize,
}
impl PluginProposalLaneLimit {
pub const fn try_new(max_pending: usize) -> Result<Self, PluginProposalLaneError> {
match NonZeroUsize::new(max_pending) {
Some(max_pending) => Ok(Self { max_pending }),
None => Err(PluginProposalLaneError::ZeroLimit),
}
}
#[must_use]
pub const fn max_pending(self) -> usize {
self.max_pending.get()
}
#[must_use]
const fn max_pending_limit(self) -> NonZeroUsize {
self.max_pending
}
}
impl Default for PluginProposalLaneLimit {
fn default() -> Self {
Self::try_new(DEFAULT_PLUGIN_PROPOSAL_LANE_LIMIT)
.expect("default proposal lane limit should be non-zero")
}
}
#[derive(Clone, Debug, Eq, PartialEq, Resource)]
pub struct PluginProposalLanePolicy {
default_buffer_edit_mode: PluginProposalReviewMode,
plugin_buffer_edit_modes: Vec<(PluginIdentity, PluginProposalReviewMode)>,
}
impl PluginProposalLanePolicy {
#[must_use]
pub const fn new(mode: PluginProposalReviewMode) -> Self {
Self {
default_buffer_edit_mode: mode,
plugin_buffer_edit_modes: Vec::new(),
}
}
#[must_use]
pub fn from_validated_registry(registry: &ValidatedPluginRegistry) -> Self {
let plugin_buffer_edit_modes = registry
.enabled_plugins()
.map(|plugin| {
(
plugin.identity().clone(),
plugin.proposal_policy().buffer_edit_review_mode(),
)
})
.collect();
Self {
default_buffer_edit_mode: PluginProposalReviewMode::AutoApply,
plugin_buffer_edit_modes,
}
}
#[must_use]
pub const fn default_buffer_edit_mode(&self) -> PluginProposalReviewMode {
self.default_buffer_edit_mode
}
#[must_use]
pub fn buffer_edit_mode_for(&self, identity: &PluginIdentity) -> PluginProposalReviewMode {
self.plugin_buffer_edit_modes
.iter()
.find_map(|(plugin, mode)| (plugin == identity).then_some(*mode))
.unwrap_or(self.default_buffer_edit_mode)
}
}
impl Default for PluginProposalLanePolicy {
fn default() -> Self {
Self::new(PluginProposalReviewMode::AutoApply)
}
}
#[derive(Resource)]
pub struct PluginProposalLane {
limit: PluginProposalLaneLimit,
next_id: NonZeroU64,
buffer_edits: VecDeque<PendingPluginBufferEditProposal>,
}
impl PluginProposalLane {
#[must_use]
pub const fn with_limit(limit: PluginProposalLaneLimit) -> Self {
Self {
limit,
next_id: NonZeroU64::MIN,
buffer_edits: VecDeque::new(),
}
}
pub fn push_buffer_edit(
&mut self,
request: RevisionGuardedBufferEditRequested,
) -> Result<PluginProposalId, PluginProposalLaneError> {
let identity = request.provenance.source_identity_proof().clone();
if self.buffer_edits.len() >= self.limit.max_pending() {
return Err(PluginProposalLaneError::TooManyPending {
identity,
limit: self.limit.max_pending_limit(),
});
}
let id = self.allocate_id(&identity)?;
let request = request.with_proposal_id(id);
self.buffer_edits
.push_back(PendingPluginBufferEditProposal { id, request });
Ok(id)
}
pub fn take_buffer_edit_for_apply(
&mut self,
id: PluginProposalId,
) -> Result<RevisionGuardedBufferEditRequested, PluginProposalDecisionError> {
let index = self
.buffer_edits
.iter()
.position(|proposal| proposal.id == id)
.ok_or(PluginProposalDecisionError::UnknownProposal { id })?;
Ok(self
.buffer_edits
.remove(index)
.expect("proposal index came from the same queue")
.into_request())
}
pub fn reject_buffer_edit(
&mut self,
id: PluginProposalId,
) -> Result<RejectedPluginBufferEditProposal, PluginProposalDecisionError> {
let index = self
.buffer_edits
.iter()
.position(|proposal| proposal.id == id)
.ok_or(PluginProposalDecisionError::UnknownProposal { id })?;
let proposal = self
.buffer_edits
.remove(index)
.expect("proposal index came from the same queue");
Ok(RejectedPluginBufferEditProposal {
id,
target: proposal.request.target,
provenance: proposal.request.provenance,
rejected: BufferEditShape::from_edit(&proposal.request.edit),
})
}
#[must_use]
pub const fn limit(&self) -> PluginProposalLaneLimit {
self.limit
}
#[must_use]
pub fn len(&self) -> usize {
self.buffer_edits.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.buffer_edits.is_empty()
}
pub fn pending_buffer_edits(&self) -> impl Iterator<Item = &PendingPluginBufferEditProposal> {
self.buffer_edits.iter()
}
#[must_use]
pub fn pending_buffer_edit(
&self,
id: PluginProposalId,
) -> Option<&PendingPluginBufferEditProposal> {
self.buffer_edits.iter().find(|proposal| proposal.id == id)
}
#[must_use]
pub fn first_pending_id(&self) -> Option<PluginProposalId> {
self.buffer_edits.front().map(|proposal| proposal.id)
}
fn allocate_id(
&mut self,
identity: &PluginIdentity,
) -> Result<PluginProposalId, PluginProposalLaneError> {
let id = self.next_id;
self.next_id = NonZeroU64::new(id.get().checked_add(1).ok_or_else(|| {
PluginProposalLaneError::IdExhausted {
identity: identity.clone(),
}
})?)
.expect("checked increment from non-zero remains non-zero");
Ok(PluginProposalId::from_nonzero(id))
}
}
impl Default for PluginProposalLane {
fn default() -> Self {
Self::with_limit(PluginProposalLaneLimit::default())
}
}
impl Debug for PluginProposalLane {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PluginProposalLane")
.field("limit", &self.limit)
.field("next_id", &self.next_id)
.field("pending_buffer_edits", &self.buffer_edits.len())
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PluginProposalReceiptLogLimit {
max_receipts: NonZeroUsize,
}
impl PluginProposalReceiptLogLimit {
pub const fn try_new(max_receipts: usize) -> Result<Self, PluginProposalReceiptLogError> {
match NonZeroUsize::new(max_receipts) {
Some(max_receipts) => Ok(Self { max_receipts }),
None => Err(PluginProposalReceiptLogError::ZeroLimit),
}
}
#[must_use]
pub const fn max_receipts(self) -> usize {
self.max_receipts.get()
}
}
impl Default for PluginProposalReceiptLogLimit {
fn default() -> Self {
Self::try_new(DEFAULT_PLUGIN_PROPOSAL_RECEIPT_LOG_LIMIT)
.expect("default proposal receipt log limit should be non-zero")
}
}
#[derive(Resource)]
pub struct PluginProposalReceiptLog {
limit: PluginProposalReceiptLogLimit,
receipts: VecDeque<PluginBufferEditProposalReceipt>,
}
impl PluginProposalReceiptLog {
#[must_use]
pub const fn with_limit(limit: PluginProposalReceiptLogLimit) -> Self {
Self {
limit,
receipts: VecDeque::new(),
}
}
pub fn push(&mut self, receipt: PluginBufferEditProposalReceipt) {
if self.receipts.len() >= self.limit.max_receipts() {
let _oldest = self.receipts.pop_front();
}
self.receipts.push_back(receipt);
}
pub fn receipts(&self) -> impl ExactSizeIterator<Item = &PluginBufferEditProposalReceipt> {
self.receipts.iter()
}
#[must_use]
pub fn latest(&self) -> Option<&PluginBufferEditProposalReceipt> {
self.receipts.back()
}
#[must_use]
pub const fn limit(&self) -> PluginProposalReceiptLogLimit {
self.limit
}
#[must_use]
pub fn len(&self) -> usize {
self.receipts.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.receipts.is_empty()
}
}
impl Default for PluginProposalReceiptLog {
fn default() -> Self {
Self::with_limit(PluginProposalReceiptLogLimit::default())
}
}
impl Debug for PluginProposalReceiptLog {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PluginProposalReceiptLog")
.field("limit", &self.limit)
.field("receipt_count", &self.receipts.len())
.finish()
}
}
pub struct PendingPluginBufferEditProposal {
id: PluginProposalId,
request: RevisionGuardedBufferEditRequested,
}
impl PendingPluginBufferEditProposal {
#[must_use]
pub const fn id(&self) -> PluginProposalId {
self.id
}
#[must_use]
pub const fn target(&self) -> BufferEntity {
self.request.target
}
#[must_use]
pub const fn provenance(&self) -> &PluginBufferEditProvenance {
&self.request.provenance
}
#[must_use]
pub fn edit_shape(&self) -> BufferEditShape {
BufferEditShape::from_edit(&self.request.edit)
}
#[must_use]
pub(crate) fn diff_preview(&self) -> PluginBufferEditDiffPreview {
PluginBufferEditDiffPreview::from_shape(
self.request.provenance.base_revision(),
&self.edit_shape(),
)
}
fn into_request(self) -> RevisionGuardedBufferEditRequested {
self.request
}
}
impl Debug for PendingPluginBufferEditProposal {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PendingPluginBufferEditProposal")
.field("id", &self.id)
.field("target", &PluginProposalTargetShape::Buffer)
.field("provenance", &self.request.provenance)
.field("edit", &BufferEditShape::from_edit(&self.request.edit))
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PluginBufferEditDiffPreview {
SetAll {
base_revision: TextRevision,
added_byte_len: usize,
},
Insert {
base_revision: TextRevision,
byte_index: usize,
added_byte_len: usize,
},
Replace {
base_revision: TextRevision,
range: TextRange,
removed_byte_len: Option<usize>,
added_byte_len: usize,
},
Delete {
base_revision: TextRevision,
range: TextRange,
removed_byte_len: Option<usize>,
},
}
impl PluginBufferEditDiffPreview {
#[must_use]
fn from_shape(base_revision: TextRevision, shape: &BufferEditShape) -> Self {
match shape.kind() {
BufferEditKind::SetAll => Self::SetAll {
base_revision,
added_byte_len: shape.replacement_byte_len().unwrap_or(0),
},
BufferEditKind::Insert => Self::Insert {
base_revision,
byte_index: shape.byte_index().unwrap_or(0),
added_byte_len: shape.replacement_byte_len().unwrap_or(0),
},
BufferEditKind::Replace => {
let range = shape.range().unwrap_or_else(|| TextRange::new(0, 0));
Self::Replace {
base_revision,
range,
removed_byte_len: text_range_len(range),
added_byte_len: shape.replacement_byte_len().unwrap_or(0),
}
}
BufferEditKind::Delete => {
let range = shape.range().unwrap_or_else(|| TextRange::new(0, 0));
Self::Delete {
base_revision,
range,
removed_byte_len: text_range_len(range),
}
}
}
}
}
const fn text_range_len(range: TextRange) -> Option<usize> {
if range.end() >= range.start() {
Some(range.end() - range.start())
} else {
None
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct RejectedPluginBufferEditProposal {
id: PluginProposalId,
target: BufferEntity,
provenance: PluginBufferEditProvenance,
rejected: BufferEditShape,
}
impl Debug for RejectedPluginBufferEditProposal {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RejectedPluginBufferEditProposal")
.field("id", &self.id)
.field("target", &PluginProposalTargetShape::Buffer)
.field("provenance", &self.provenance)
.field("rejected", &self.rejected)
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PluginProposalTargetShape {
Buffer,
}
impl RejectedPluginBufferEditProposal {
#[must_use]
pub const fn id(&self) -> PluginProposalId {
self.id
}
#[must_use]
pub const fn target(&self) -> BufferEntity {
self.target
}
#[must_use]
pub const fn provenance(&self) -> &PluginBufferEditProvenance {
&self.provenance
}
#[must_use]
pub const fn rejected(&self) -> &BufferEditShape {
&self.rejected
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum PluginProposalLaneError {
ZeroLimit,
IdExhausted {
identity: PluginIdentity,
},
TooManyPending {
identity: PluginIdentity,
limit: NonZeroUsize,
},
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum PluginProposalLaneErrorKind {
ZeroLimit,
IdExhausted,
TooManyPending,
}
impl PluginProposalLaneErrorKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ZeroLimit => "zero-limit",
Self::IdExhausted => "id-exhausted",
Self::TooManyPending => "too-many-pending",
}
}
}
impl Display for PluginProposalLaneErrorKind {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
impl Debug for PluginProposalLaneErrorKind {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum PluginProposalDecisionError {
UnknownProposal {
id: PluginProposalId,
},
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum PluginProposalDecisionErrorKind {
UnknownProposal,
}
impl PluginProposalDecisionErrorKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::UnknownProposal => "unknown-proposal",
}
}
}
impl Display for PluginProposalDecisionErrorKind {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
impl Debug for PluginProposalDecisionErrorKind {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum PluginProposalReceiptLogError {
ZeroLimit,
}
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum PluginProposalReceiptLogErrorKind {
ZeroLimit,
}
impl PluginProposalReceiptLogErrorKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ZeroLimit => "zero-limit",
}
}
}
impl Display for PluginProposalReceiptLogErrorKind {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
impl Debug for PluginProposalReceiptLogErrorKind {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
impl Display for PluginProposalLaneError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ZeroLimit => formatter.write_str("plugin proposal lane limit must be non-zero"),
Self::IdExhausted { identity } => {
let identity = identity.as_str();
write!(
formatter,
"plugin {identity:?} exhausted proposal identifiers"
)
}
Self::TooManyPending { identity, limit } => {
let identity = identity.as_str();
let limit = limit.get();
write!(
formatter,
"plugin {identity:?} exceeded proposal lane pending limit of {limit}"
)
}
}
}
}
impl PluginProposalLaneError {
#[must_use]
pub const fn kind(&self) -> PluginProposalLaneErrorKind {
match self {
Self::ZeroLimit => PluginProposalLaneErrorKind::ZeroLimit,
Self::IdExhausted { .. } => PluginProposalLaneErrorKind::IdExhausted,
Self::TooManyPending { .. } => PluginProposalLaneErrorKind::TooManyPending,
}
}
#[must_use]
pub fn identity(&self) -> Option<&str> {
self.identity_proof().map(PluginIdentity::as_str)
}
#[must_use]
pub const fn identity_proof(&self) -> Option<&PluginIdentity> {
match self {
Self::ZeroLimit => None,
Self::IdExhausted { identity } | Self::TooManyPending { identity, .. } => {
Some(identity)
}
}
}
#[must_use]
pub fn operational_event(&self) -> Option<PluginOperationalEvent> {
match self {
Self::TooManyPending { identity, limit } => {
Some(PluginOperationalEvent::queue_saturated_for(
identity,
PluginOperationalQueue::ProposalLane,
*limit,
))
}
Self::ZeroLimit | Self::IdExhausted { .. } => None,
}
}
}
impl PluginProposalDecisionError {
#[must_use]
pub const fn kind(&self) -> PluginProposalDecisionErrorKind {
match self {
Self::UnknownProposal { .. } => PluginProposalDecisionErrorKind::UnknownProposal,
}
}
}
impl Display for PluginProposalDecisionError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownProposal { id } => {
write!(formatter, "plugin proposal {} is not pending", id.get())
}
}
}
}
impl PluginProposalReceiptLogError {
#[must_use]
pub const fn kind(&self) -> PluginProposalReceiptLogErrorKind {
match self {
Self::ZeroLimit => PluginProposalReceiptLogErrorKind::ZeroLimit,
}
}
}
impl Display for PluginProposalReceiptLogError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::ZeroLimit => {
formatter.write_str("plugin proposal receipt log limit must be non-zero")
}
}
}
}
#[cfg(test)]
mod tests {
use super::{
PluginProposalDecisionError, PluginProposalDecisionErrorKind, PluginProposalLane,
PluginProposalLaneError, PluginProposalLaneErrorKind, PluginProposalLaneLimit,
PluginProposalReceiptLog, PluginProposalReceiptLogError, PluginProposalReceiptLogErrorKind,
PluginProposalReceiptLogLimit,
};
use crate::{
buffer::{BufferEdit, BufferEditKind},
ecs::{
components::buffer::BufferEntity,
events::edit::{
PluginBufferEditProposalReceipt, PluginBufferEditProvenance,
RevisionGuardedBufferEditRequested,
},
},
plugin::PluginIdentity,
text_stream::{TextRange, TextRevision},
};
use bevy::prelude::Entity;
use std::num::{NonZeroU64, NonZeroUsize};
#[test]
fn proposal_lane_error_kinds_are_closed() {
let identity = plugin_identity("proposal-test");
let cases = [
(
PluginProposalLaneError::ZeroLimit,
PluginProposalLaneErrorKind::ZeroLimit,
"zero-limit",
),
(
PluginProposalLaneError::IdExhausted {
identity: identity.clone(),
},
PluginProposalLaneErrorKind::IdExhausted,
"id-exhausted",
),
(
PluginProposalLaneError::TooManyPending {
identity,
limit: count_limit(1),
},
PluginProposalLaneErrorKind::TooManyPending,
"too-many-pending",
),
];
for (error, kind, expected) in cases {
assert_eq!(error.kind(), kind);
assert_eq!(kind.as_str(), expected);
assert_eq!(kind.to_string(), expected);
assert_eq!(format!("{kind:?}"), expected);
}
}
#[test]
fn proposal_decision_and_receipt_error_kinds_are_closed() {
let proposal = crate::ecs::events::edit::PluginProposalId::from_nonzero(
NonZeroU64::new(1).expect("proposal id should be non-zero"),
);
let decision = PluginProposalDecisionError::UnknownProposal { id: proposal };
let receipt = PluginProposalReceiptLogError::ZeroLimit;
assert_eq!(
decision.kind(),
PluginProposalDecisionErrorKind::UnknownProposal
);
assert_eq!(
PluginProposalDecisionErrorKind::UnknownProposal.as_str(),
"unknown-proposal"
);
assert_eq!(
PluginProposalDecisionErrorKind::UnknownProposal.to_string(),
"unknown-proposal"
);
assert_eq!(
format!("{:?}", PluginProposalDecisionErrorKind::UnknownProposal),
"unknown-proposal"
);
assert_eq!(receipt.kind(), PluginProposalReceiptLogErrorKind::ZeroLimit);
assert_eq!(
PluginProposalReceiptLogErrorKind::ZeroLimit.as_str(),
"zero-limit"
);
assert_eq!(
PluginProposalReceiptLogErrorKind::ZeroLimit.to_string(),
"zero-limit"
);
assert_eq!(
format!("{:?}", PluginProposalReceiptLogErrorKind::ZeroLimit),
"zero-limit"
);
}
#[test]
fn proposal_lane_is_bounded_without_partial_enqueue() {
let mut lane = PluginProposalLane::with_limit(
PluginProposalLaneLimit::try_new(1).expect("limit should be valid"),
);
let _id = lane
.push_buffer_edit(request("first secret"))
.expect("first proposal should fit");
let error = lane
.push_buffer_edit(request("second secret"))
.expect_err("second proposal should exceed the lane limit");
assert_eq!(
error,
PluginProposalLaneError::TooManyPending {
identity: plugin_identity("proposal-test"),
limit: count_limit(1),
}
);
assert_eq!(error.identity(), Some("proposal-test"));
assert_eq!(
error
.identity_proof()
.map(crate::plugin::PluginIdentity::as_str),
Some("proposal-test")
);
assert_eq!(
error.to_string(),
"plugin \"proposal-test\" exceeded proposal lane pending limit of 1"
);
assert_eq!(lane.len(), 1);
}
#[test]
fn proposal_lane_id_exhaustion_preserves_rejected_identity() {
let mut lane = PluginProposalLane {
next_id: NonZeroU64::new(u64::MAX).expect("max id should be non-zero"),
..PluginProposalLane::default()
};
let error = lane
.push_buffer_edit(request("secret plugin payload"))
.expect_err("exhausted id allocator should reject");
assert_eq!(
error,
PluginProposalLaneError::IdExhausted {
identity: plugin_identity("proposal-test"),
}
);
assert_eq!(error.identity(), Some("proposal-test"));
assert_eq!(error.operational_event(), None);
assert!(lane.is_empty());
}
#[test]
fn proposal_lane_saturation_event_is_redacted() {
let identity = PluginIdentity::try_new("proposal-test").expect("identity");
let error = PluginProposalLaneError::TooManyPending {
identity,
limit: count_limit(1),
};
let event = error
.operational_event()
.expect("saturation should produce an event");
assert_eq!(event.identity(), Some("proposal-test"));
assert_eq!(
event.kind(),
crate::plugin::PluginOperationalEventKind::QueueSaturated {
queue: crate::plugin::PluginOperationalQueue::ProposalLane,
limit: count_limit(1),
}
);
assert!(!format!("{event:?}").contains("secret"));
assert_eq!(
event.to_string(),
"plugin \"proposal-test\" saturated proposal-lane queue at 1"
);
}
#[test]
fn pending_proposal_debug_redacts_edit_text() {
let mut lane = PluginProposalLane::default();
let id = lane
.push_buffer_edit(request("secret plugin payload"))
.expect("proposal should fit");
let proposal = lane.pending_buffer_edit(id).expect("pending proposal");
assert_eq!(proposal.edit_shape().kind(), BufferEditKind::Insert);
assert_eq!(proposal.provenance().proposal_id(), Some(id));
let debug = format!("{proposal:?}");
assert!(debug.contains("PendingPluginBufferEditProposal"));
assert!(debug.contains("Insert"));
assert!(!debug.contains("Entity"));
assert!(!debug.contains("BufferEntity"));
assert!(!debug.contains("secret"));
assert!(!debug.contains("payload"));
}
#[test]
fn pending_proposal_diff_preview_redacts_edit_text() {
let mut lane = PluginProposalLane::default();
let id = lane
.push_buffer_edit(RevisionGuardedBufferEditRequested {
target: BufferEntity(Entity::from_raw_u32(1).expect("entity")),
provenance: PluginBufferEditProvenance::buffer_propose_edit(
plugin_identity("proposal-test"),
TextRevision::from(7),
),
edit: BufferEdit::replace_unchecked(1..3, "secret replacement"),
})
.expect("proposal should fit");
let proposal = lane.pending_buffer_edit(id).expect("pending proposal");
assert_eq!(
proposal.diff_preview(),
super::PluginBufferEditDiffPreview::Replace {
base_revision: TextRevision::from(7),
range: TextRange::new(1, 3),
removed_byte_len: Some(2),
added_byte_len: 18,
}
);
let debug = format!("{:?}", proposal.diff_preview());
assert!(!debug.contains("secret"));
assert!(!debug.contains("replacement"));
}
#[test]
fn receipt_log_retains_bounded_recent_receipts() {
let mut lane = PluginProposalLane::default();
let mut log = PluginProposalReceiptLog::with_limit(
PluginProposalReceiptLogLimit::try_new(1).expect("limit should be valid"),
);
let first = rejected_receipt(&mut lane, "first");
let second = rejected_receipt(&mut lane, "second");
log.push(first);
log.push(second.clone());
assert_eq!(log.len(), 1);
assert_eq!(
log.receipts()
.next()
.expect("most recent receipt should be retained")
.proposal(),
second.proposal()
);
}
fn request(text: &str) -> RevisionGuardedBufferEditRequested {
RevisionGuardedBufferEditRequested {
target: BufferEntity(Entity::from_raw_u32(1).expect("entity")),
provenance: PluginBufferEditProvenance::buffer_propose_edit(
plugin_identity("proposal-test"),
TextRevision::from(0),
),
edit: BufferEdit::insert(0, text),
}
}
fn rejected_receipt(
lane: &mut PluginProposalLane,
text: &str,
) -> PluginBufferEditProposalReceipt {
let id = lane
.push_buffer_edit(request(text))
.expect("proposal should fit");
let rejected = lane.reject_buffer_edit(id).expect("proposal should reject");
PluginBufferEditProposalReceipt::rejected_before_owner(
rejected.id(),
rejected.provenance(),
rejected.target(),
rejected.rejected().clone(),
)
}
fn plugin_identity(identity: &str) -> PluginIdentity {
PluginIdentity::try_new(identity).expect("test identity should be valid")
}
fn count_limit(value: usize) -> NonZeroUsize {
NonZeroUsize::new(value).expect("test count limit should be non-zero")
}
}