From 4a6295e374ba7f6345b22e61a68d31891ad579ab Mon Sep 17 00:00:00 2001 From: Ginger Date: Thu, 22 Jan 2026 14:55:08 -0500 Subject: [PATCH] feat(stitched): Implement a simple REPL for testing stitched ordering --- Cargo.lock | 41 +++++++++++ src/stitcher/Cargo.toml | 1 + src/stitcher/examples/repl.rs | 90 +++++++++++++++++++++++ src/stitcher/memory_backend.rs | 130 +++++++++++++++++++++++++++++++++ src/stitcher/mod.rs | 2 + src/stitcher/test/mod.rs | 99 ++----------------------- 6 files changed, 269 insertions(+), 94 deletions(-) create mode 100644 src/stitcher/examples/repl.rs create mode 100644 src/stitcher/memory_backend.rs diff --git a/Cargo.lock b/Cargo.lock index eef085c2..409fe933 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -935,6 +935,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.57" @@ -1820,6 +1829,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.3.1" @@ -4613,6 +4628,25 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "libc", + "log", + "memchr", + "nix", + "unicode-segmentation", + "unicode-width 0.2.2", + "utf8parse", + "windows-sys 0.60.2", +] + [[package]] name = "rustyline-async" version = "0.4.6" @@ -5142,6 +5176,7 @@ dependencies = [ "indexmap", "itertools 0.14.0", "peg", + "rustyline", ] [[package]] @@ -5905,6 +5940,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.19.0" diff --git a/src/stitcher/Cargo.toml b/src/stitcher/Cargo.toml index 0259e3ce..79d22568 100644 --- a/src/stitcher/Cargo.toml +++ b/src/stitcher/Cargo.toml @@ -16,3 +16,4 @@ itertools.workspace = true [dev-dependencies] peg = "0.8.5" +rustyline = { version = "17.0.2", default-features = false } diff --git a/src/stitcher/examples/repl.rs b/src/stitcher/examples/repl.rs new file mode 100644 index 00000000..1f752dc7 --- /dev/null +++ b/src/stitcher/examples/repl.rs @@ -0,0 +1,90 @@ +use std::collections::HashSet; + +use rustyline::{DefaultEditor, Result, error::ReadlineError}; +use stitcher::{Batch, EventEdges, Stitcher, memory_backend::MemoryStitcherBackend}; + +const BANNER: &str = " +stitched ordering test repl +- append an event by typing its name: `A` +- to add prev events type an arrow and then space-separated event names: `A --> B C D` +- use `/reset` to clear the ordering +Ctrl-D to exit, Ctrl-C to clear input +" +.trim_ascii(); + +enum Command<'line> { + AppendEvents(EventEdges<'line>), + ResetOrder, +} + +peg::parser! { + // partially copied from the test case parser + grammar command_parser() for str { + /// Parse whitespace. + rule _ -> () = quiet! { $([' '])* {} } + + /// Parse empty lines and comments. + rule newline() -> () = quiet! { (("#" [^'\n']*)? "\n")+ {} } + + /// Parse an event ID. + rule event_id() -> &'input str + = quiet! { id:$([char if char.is_ascii_alphanumeric() || ['_', '-'].contains(&char)]+) { id } } + / expected!("event id") + + /// Parse an event and its prev events. + rule event() -> (&'input str, HashSet<&'input str>) + = id:event_id() prev_events:(_ "-->" _ id:(event_id() ++ _) { id })? { + (id, prev_events.into_iter().flatten().collect()) + } + + pub rule command() -> Command<'input> = + "/reset" { Command::ResetOrder } + / events:event() ++ newline() { Command::AppendEvents(events.into_iter().collect()) } + } +} + +fn main() -> Result<()> { + let mut backend = MemoryStitcherBackend::default(); + let mut reader = DefaultEditor::new()?; + + println!("{BANNER}"); + + loop { + match reader.readline("> ") { + | Ok(line) => match command_parser::command(&line) { + | Ok(Command::AppendEvents(events)) => { + let batch = Batch::from_edges(&events); + let stitcher = Stitcher::new(&backend); + let updates = stitcher.stitch(&batch); + + for update in &updates.gap_updates { + println!("update to gap {}:", update.key); + println!(" new gap contents: {:?}", update.gap); + println!(" inserted items: {:?}", update.inserted_items); + } + + println!("new items: {:?}", &updates.new_items); + println!("events added to gaps: {:?}", &updates.events_added_to_gaps); + + backend.extend(updates); + println!("\norder: {backend:?}"); + }, + | Ok(Command::ResetOrder) => { + backend.clear(); + println!("order cleared."); + }, + | Err(parse_error) => { + println!("parse error!! {parse_error}"); + }, + }, + | Err(ReadlineError::Interrupted) => { + println!("interrupt"); + }, + | Err(ReadlineError::Eof) => { + println!("goodbye :3"); + break Ok(()); + }, + | Err(err) => break Err(err), + } + } +} diff --git a/src/stitcher/memory_backend.rs b/src/stitcher/memory_backend.rs new file mode 100644 index 00000000..4babaff3 --- /dev/null +++ b/src/stitcher/memory_backend.rs @@ -0,0 +1,130 @@ +use std::{ + fmt::Debug, + sync::atomic::{AtomicU64, Ordering}, +}; + +use crate::{Gap, OrderUpdates, StitchedItem, StitcherBackend}; + +/// A version of [`StitchedItem`] which owns event IDs. +#[derive(Debug)] +enum MemoryStitcherItem { + Event(String), + Gap(Gap), +} + +impl From> for MemoryStitcherItem { + fn from(value: StitchedItem) -> Self { + match value { + | StitchedItem::Event(id) => MemoryStitcherItem::Event(id.to_string()), + | StitchedItem::Gap(gap) => MemoryStitcherItem::Gap(gap), + } + } +} + +impl<'id> From<&'id MemoryStitcherItem> for StitchedItem<'id> { + fn from(value: &'id MemoryStitcherItem) -> Self { + match value { + | MemoryStitcherItem::Event(id) => StitchedItem::Event(id), + | MemoryStitcherItem::Gap(gap) => StitchedItem::Gap(gap.clone()), + } + } +} + +/// A stitcher backend which holds a stitched ordering in RAM. +#[derive(Default)] +pub struct MemoryStitcherBackend { + items: Vec<(u64, MemoryStitcherItem)>, + counter: AtomicU64, +} + +impl MemoryStitcherBackend { + fn next_id(&self) -> u64 { self.counter.fetch_add(1, Ordering::Relaxed) } + + /// Extend this ordering with new updates. + pub fn extend(&mut self, results: OrderUpdates<'_, ::Key>) { + for update in results.gap_updates { + let Some(gap_index) = self.items.iter().position(|(key, _)| *key == update.key) + else { + panic!("bad update key {}", update.key); + }; + + let insertion_index = if update.gap.is_empty() { + self.items.remove(gap_index); + gap_index + } else { + match self.items.get_mut(gap_index) { + | Some((_, MemoryStitcherItem::Gap(gap))) => { + *gap = update.gap; + }, + | Some((key, other)) => { + panic!("expected item with key {key} to be a gap, it was {other:?}"); + }, + | None => unreachable!("we just checked that this index is valid"), + } + gap_index.checked_add(1).expect( + "should never allocate usize::MAX ids. what kind of test are you running", + ) + }; + + let to_insert: Vec<_> = update + .inserted_items + .into_iter() + .map(|item| (self.next_id(), item.into())) + .collect(); + self.items + .splice(insertion_index..insertion_index, to_insert.into_iter()) + .for_each(drop); + } + + let new_items: Vec<_> = results + .new_items + .into_iter() + .map(|item| (self.next_id(), item.into())) + .collect(); + self.items.extend(new_items); + } + + /// Iterate over the items in this ordering. + pub fn iter(&self) -> impl Iterator> { + self.items.iter().map(|(_, item)| item.into()) + } + + /// Clear this ordering. + pub fn clear(&mut self) { self.items.clear(); } +} + +impl StitcherBackend for MemoryStitcherBackend { + type Key = u64; + + fn find_matching_gaps<'a>( + &'a self, + events: impl Iterator, + ) -> impl Iterator { + // nobody cares about test suite performance right + let mut gaps = vec![]; + + for event in events { + for (key, item) in &self.items { + if let MemoryStitcherItem::Gap(gap) = item + && gap.contains(event) + { + gaps.push((*key, gap.clone())); + } + } + } + + gaps.into_iter() + } + + fn event_exists<'a>(&'a self, event: &'a str) -> bool { + self.items + .iter() + .any(|item| matches!(&item.1, MemoryStitcherItem::Event(id) if event == id)) + } +} + +impl Debug for MemoryStitcherBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list().entries(self.iter()).finish() + } +} diff --git a/src/stitcher/mod.rs b/src/stitcher/mod.rs index 2f0bcd22..32af4363 100644 --- a/src/stitcher/mod.rs +++ b/src/stitcher/mod.rs @@ -3,8 +3,10 @@ use std::{cmp::Ordering, collections::HashSet}; use indexmap::IndexMap; pub mod algorithm; +pub mod memory_backend; #[cfg(test)] mod test; + pub use algorithm::*; /// A gap in the stitched order. diff --git a/src/stitcher/test/mod.rs b/src/stitcher/test/mod.rs index ca065def..e2995279 100644 --- a/src/stitcher/test/mod.rs +++ b/src/stitcher/test/mod.rs @@ -1,101 +1,12 @@ -use std::sync::atomic::{AtomicU64, Ordering}; - use itertools::Itertools; use super::{algorithm::*, *}; +use crate::memory_backend::MemoryStitcherBackend; mod parser; -/// A stitcher backend which holds a stitched ordering in RAM. -#[derive(Default)] -struct TestStitcherBackend<'id> { - items: Vec<(u64, StitchedItem<'id>)>, - counter: AtomicU64, -} - -impl<'id> TestStitcherBackend<'id> { - fn next_id(&self) -> u64 { self.counter.fetch_add(1, Ordering::Relaxed) } - - fn extend(&mut self, results: OrderUpdates<'id, ::Key>) { - for update in results.gap_updates { - let Some(gap_index) = self.items.iter().position(|(key, _)| *key == update.key) - else { - panic!("bad update key {}", update.key); - }; - - let insertion_index = if update.gap.is_empty() { - self.items.remove(gap_index); - gap_index - } else { - match self.items.get_mut(gap_index) { - | Some((_, StitchedItem::Gap(gap))) => { - *gap = update.gap; - }, - | Some((key, other)) => { - panic!("expected item with key {key} to be a gap, it was {other:?}"); - }, - | None => unreachable!("we just checked that this index is valid"), - } - gap_index.checked_add(1).expect( - "should never allocate usize::MAX ids. what kind of test are you running", - ) - }; - - let to_insert: Vec<_> = update - .inserted_items - .into_iter() - .map(|item| (self.next_id(), item)) - .collect(); - self.items - .splice(insertion_index..insertion_index, to_insert.into_iter()) - .for_each(drop); - } - - let new_items: Vec<_> = results - .new_items - .into_iter() - .map(|item| (self.next_id(), item)) - .collect(); - self.items.extend(new_items); - } - - fn iter(&self) -> impl Iterator> { - self.items.iter().map(|(_, item)| item) - } -} - -impl StitcherBackend for TestStitcherBackend<'_> { - type Key = u64; - - fn find_matching_gaps<'a>( - &'a self, - events: impl Iterator, - ) -> impl Iterator { - // nobody cares about test suite performance right - let mut gaps = vec![]; - - for event in events { - for (key, item) in &self.items { - if let StitchedItem::Gap(gap) = item - && gap.contains(event) - { - gaps.push((*key, gap.clone())); - } - } - } - - gaps.into_iter() - } - - fn event_exists<'a>(&'a self, event: &'a str) -> bool { - self.items - .iter() - .any(|item| matches!(item.1, StitchedItem::Event(id) if event == id)) - } -} - fn run_testcase(testcase: parser::TestCase<'_>) { - let mut backend = TestStitcherBackend::default(); + let mut backend = MemoryStitcherBackend::default(); for (index, phase) in testcase.into_iter().enumerate() { let stitcher = Stitcher::new(&backend); @@ -107,7 +18,7 @@ fn run_testcase(testcase: parser::TestCase<'_>) { for update in &updates.gap_updates { println!("update to gap {}:", update.key); println!(" new gap contents: {:?}", update.gap); - println!(" new items: {:?}", update.inserted_items); + println!(" inserted items: {:?}", update.inserted_items); } println!("expected new items: {:?}", &phase.order.new_items); @@ -134,9 +45,9 @@ fn run_testcase(testcase: parser::TestCase<'_>) { } backend.extend(updates); - println!("extended ordering: {:?}", backend.items); + println!("extended ordering: {:?}", backend); - for (expected, actual) in phase.order.iter().zip_eq(backend.iter()) { + for (expected, ref actual) in phase.order.iter().zip_eq(backend.iter()) { assert_eq!( expected, actual, "bad item in order, expected {expected:?} but got {actual:?}",