use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestResult {
Fail,
Pass,
Unresolved,
}
impl fmt::Display for TestResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Fail => write!(f, "FAIL"),
Self::Pass => write!(f, "PASS"),
Self::Unresolved => write!(f, "UNRESOLVED"),
}
}
}
#[derive(Debug, Clone)]
pub struct BisectStep {
pub number: usize,
pub indices: Vec<usize>,
pub subset_size: usize,
pub total_size: usize,
pub result: Option<TestResult>,
pub description: String,
}
impl BisectStep {
#[must_use]
pub fn new(number: usize, indices: Vec<usize>, total_size: usize) -> Self {
let subset_size = indices.len();
Self {
number,
indices,
subset_size,
total_size,
result: None,
description: String::new(),
}
}
pub fn with_result(mut self, result: TestResult) -> Self {
self.result = Some(result);
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = desc.into();
self
}
#[must_use]
pub fn progress_percent(&self) -> f64 {
if self.total_size == 0 {
100.0
} else {
let reduced = self.total_size - self.subset_size;
(reduced as f64 / self.total_size as f64) * 100.0
}
}
#[must_use]
pub fn reduction_ratio(&self) -> f64 {
if self.total_size == 0 {
1.0
} else {
self.subset_size as f64 / self.total_size as f64
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BisectState {
InProgress,
Found,
Minimal,
NoFailure,
Canceled,
}
impl fmt::Display for BisectState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InProgress => write!(f, "IN PROGRESS"),
Self::Found => write!(f, "FOUND"),
Self::Minimal => write!(f, "MINIMAL"),
Self::NoFailure => write!(f, "NO FAILURE"),
Self::Canceled => write!(f, "CANCELED"),
}
}
}
#[derive(Debug, Clone)]
pub struct DeltaDebugger<T> {
items: Vec<T>,
failing_indices: Vec<usize>,
steps: Vec<BisectStep>,
state: BisectState,
granularity: usize,
max_steps: usize,
}
impl<T: Clone> DeltaDebugger<T> {
#[must_use]
pub fn new(items: Vec<T>) -> Self {
let len = items.len();
Self {
items,
failing_indices: (0..len).collect(),
steps: Vec::new(),
state: BisectState::InProgress,
granularity: 2,
max_steps: 100,
}
}
pub fn with_max_steps(mut self, max: usize) -> Self {
self.max_steps = max;
self
}
#[must_use]
pub fn state(&self) -> BisectState {
self.state
}
#[must_use]
pub fn step_count(&self) -> usize {
self.steps.len()
}
#[must_use]
pub fn steps(&self) -> &[BisectStep] {
&self.steps
}
#[must_use]
pub fn current_size(&self) -> usize {
self.failing_indices.len()
}
#[must_use]
pub fn original_size(&self) -> usize {
self.items.len()
}
#[must_use]
pub fn current_subset(&self) -> Vec<&T> {
self.failing_indices
.iter()
.filter_map(|&i| self.items.get(i))
.collect()
}
#[must_use]
pub fn failing_indices(&self) -> &[usize] {
&self.failing_indices
}
#[must_use]
pub fn reduction_percent(&self) -> f64 {
if self.items.is_empty() {
0.0
} else {
let reduced = self.items.len() - self.failing_indices.len();
(reduced as f64 / self.items.len() as f64) * 100.0
}
}
#[must_use]
pub fn is_done(&self) -> bool {
self.state != BisectState::InProgress
}
#[must_use]
pub fn next_subset(&self) -> Option<Vec<usize>> {
if self.is_done() {
return None;
}
if self.steps.len() >= self.max_steps {
return None;
}
let n = self.failing_indices.len();
if n <= 1 {
return None;
}
let chunk_size = n.div_ceil(self.granularity);
if chunk_size == 0 {
return None;
}
let subset: Vec<usize> = self
.failing_indices
.iter()
.take(chunk_size)
.copied()
.collect();
if subset.is_empty() {
None
} else {
Some(subset)
}
}
#[must_use]
pub fn complement_of(&self, subset: &[usize]) -> Vec<usize> {
self.failing_indices
.iter()
.filter(|&i| !subset.contains(i))
.copied()
.collect()
}
pub fn record_result(&mut self, tested_indices: Vec<usize>, result: TestResult) {
let step_num = self.steps.len() + 1;
let step =
BisectStep::new(step_num, tested_indices.clone(), self.items.len()).with_result(result);
self.steps.push(step);
match result {
TestResult::Fail => {
if tested_indices.len() < self.failing_indices.len() {
self.failing_indices = tested_indices;
self.granularity = 2; }
}
TestResult::Pass => {
if self.granularity < self.failing_indices.len() {
self.granularity = (self.granularity * 2).min(self.failing_indices.len());
}
}
TestResult::Unresolved => {
if self.granularity < self.failing_indices.len() {
self.granularity += 1;
}
}
}
if self.failing_indices.len() <= 1 {
self.state = BisectState::Minimal;
} else if self.steps.len() >= self.max_steps {
self.state = BisectState::Minimal;
} else if self.granularity >= self.failing_indices.len() && result != TestResult::Fail {
self.state = BisectState::Minimal;
}
}
pub fn mark_found(&mut self) {
self.state = BisectState::Found;
}
pub fn mark_no_failure(&mut self) {
self.state = BisectState::NoFailure;
}
pub fn cancel(&mut self) {
self.state = BisectState::Canceled;
}
#[must_use]
pub fn render(&self, width: usize) -> String {
let mut lines = vec![format!(
"{}╭{}╮",
" ".repeat(2),
"─".repeat(width.saturating_sub(4))
)];
lines.push(format!(
"{}│ {:^width$} │",
" ".repeat(2),
"DELTA DEBUGGING (ddmin)",
width = width.saturating_sub(6)
));
lines.push(format!(
"{}├{}┤",
" ".repeat(2),
"─".repeat(width.saturating_sub(4))
));
lines.push(format!(
"{}│ State: {:width$} │",
" ".repeat(2),
self.state.to_string(),
width = width.saturating_sub(10)
));
lines.push(format!(
"{}│ Steps: {}/{:width$} │",
" ".repeat(2),
self.steps.len(),
self.max_steps,
width = width.saturating_sub(12)
));
lines.push(format!(
"{}│ Size: {} → {} ({:.1}% reduced){:width$} │",
" ".repeat(2),
self.original_size(),
self.current_size(),
self.reduction_percent(),
"",
width = width.saturating_sub(35)
));
if !self.steps.is_empty() {
lines.push(format!(
"{}├{}┤",
" ".repeat(2),
"─".repeat(width.saturating_sub(4))
));
for step in self.steps.iter().rev().take(5) {
let result_str = step.result.map_or("?".to_string(), |r| r.to_string());
let icon = match step.result {
Some(TestResult::Fail) => "✗",
Some(TestResult::Pass) => "✓",
Some(TestResult::Unresolved) => "?",
None => "○",
};
lines.push(format!(
"{}│ {} Step {:2}: {} (n={}){:width$} │",
" ".repeat(2),
icon,
step.number,
result_str,
step.subset_size,
"",
width = width.saturating_sub(28)
));
}
}
lines.push(format!(
"{}╰{}╯",
" ".repeat(2),
"─".repeat(width.saturating_sub(4))
));
lines.join("\n")
}
}
#[derive(Debug, Clone)]
pub struct BisectSession {
pub id: String,
pub files: Vec<String>,
pub range: (usize, usize),
pub good: Vec<usize>,
pub bad: Vec<usize>,
pub steps: Vec<BisectStep>,
pub state: BisectState,
}
impl BisectSession {
#[must_use]
pub fn new(files: Vec<String>) -> Self {
let end = files.len().saturating_sub(1);
Self {
id: format!(
"bisect-{}",
std::time::UNIX_EPOCH
.elapsed()
.unwrap_or_default()
.as_secs()
),
files,
range: (0, end),
good: Vec::new(),
bad: Vec::new(),
steps: Vec::new(),
state: BisectState::InProgress,
}
}
#[must_use]
pub fn midpoint(&self) -> Option<usize> {
if self.range.0 >= self.range.1 {
None
} else {
Some(usize::midpoint(self.range.0, self.range.1))
}
}
pub fn mark_good(&mut self, index: usize) {
self.good.push(index);
if index >= self.range.0 && index < self.range.1 {
self.range.0 = index + 1;
}
self.update_state();
}
pub fn mark_bad(&mut self, index: usize) {
self.bad.push(index);
if index >= self.range.0 && index <= self.range.1 {
self.range.1 = index;
}
self.update_state();
}
fn update_state(&mut self) {
if self.range.0 >= self.range.1 {
self.state = BisectState::Found;
}
}
#[must_use]
pub fn steps_remaining(&self) -> usize {
let range_size = self.range.1.saturating_sub(self.range.0);
if range_size <= 1 {
0
} else {
(range_size as f64).log2().ceil() as usize
}
}
#[must_use]
pub fn current_file(&self) -> Option<&str> {
self.midpoint()
.and_then(|i| self.files.get(i).map(String::as_str))
}
#[must_use]
pub fn first_bad(&self) -> Option<&str> {
if self.state == BisectState::Found {
self.files.get(self.range.0).map(String::as_str)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_test_result_display() {
assert_eq!(TestResult::Fail.to_string(), "FAIL");
assert_eq!(TestResult::Pass.to_string(), "PASS");
assert_eq!(TestResult::Unresolved.to_string(), "UNRESOLVED");
}
#[test]
fn test_bisect_step_new() {
let step = BisectStep::new(1, vec![0, 1, 2], 10);
assert_eq!(step.number, 1);
assert_eq!(step.subset_size, 3);
assert_eq!(step.total_size, 10);
assert!(step.result.is_none());
}
#[test]
fn test_bisect_step_with_result() {
let step = BisectStep::new(1, vec![0, 1], 10).with_result(TestResult::Fail);
assert_eq!(step.result, Some(TestResult::Fail));
}
#[test]
fn test_bisect_step_progress() {
let step = BisectStep::new(1, vec![0, 1], 10); assert!((step.progress_percent() - 80.0).abs() < 0.01);
}
#[test]
fn test_bisect_step_reduction_ratio() {
let step = BisectStep::new(1, vec![0, 1, 2], 10); assert!((step.reduction_ratio() - 0.3).abs() < 0.01);
}
#[test]
fn test_bisect_state_display() {
assert_eq!(BisectState::InProgress.to_string(), "IN PROGRESS");
assert_eq!(BisectState::Found.to_string(), "FOUND");
assert_eq!(BisectState::Minimal.to_string(), "MINIMAL");
assert_eq!(BisectState::NoFailure.to_string(), "NO FAILURE");
assert_eq!(BisectState::Canceled.to_string(), "CANCELED");
}
#[test]
fn test_delta_debugger_new() {
let items = vec!["a", "b", "c", "d", "e"];
let dd = DeltaDebugger::new(items);
assert_eq!(dd.original_size(), 5);
assert_eq!(dd.current_size(), 5);
assert_eq!(dd.state(), BisectState::InProgress);
}
#[test]
fn test_delta_debugger_next_subset() {
let items = vec!["a", "b", "c", "d"];
let dd = DeltaDebugger::new(items);
let subset = dd.next_subset().unwrap();
assert_eq!(subset.len(), 2); }
#[test]
fn test_delta_debugger_complement() {
let items = vec!["a", "b", "c", "d"];
let dd = DeltaDebugger::new(items);
let subset = vec![0, 1];
let complement = dd.complement_of(&subset);
assert_eq!(complement, vec![2, 3]);
}
#[test]
fn test_delta_debugger_record_fail() {
let items = vec!["a", "b", "c", "d"];
let mut dd = DeltaDebugger::new(items);
dd.record_result(vec![0, 1], TestResult::Fail);
assert_eq!(dd.step_count(), 1);
assert_eq!(dd.current_size(), 2); }
#[test]
fn test_delta_debugger_record_pass() {
let items = vec!["a", "b", "c", "d"];
let mut dd = DeltaDebugger::new(items);
dd.record_result(vec![0, 1], TestResult::Pass);
assert_eq!(dd.step_count(), 1);
assert_eq!(dd.current_size(), 4); }
#[test]
fn test_delta_debugger_reduction_percent() {
let items = vec!["a", "b", "c", "d", "e"];
let mut dd = DeltaDebugger::new(items);
dd.record_result(vec![0, 1], TestResult::Fail);
assert!((dd.reduction_percent() - 60.0).abs() < 0.01); }
#[test]
fn test_delta_debugger_minimal_state() {
let items = vec!["a", "b"];
let mut dd = DeltaDebugger::new(items);
dd.record_result(vec![0], TestResult::Fail);
assert_eq!(dd.state(), BisectState::Minimal);
}
#[test]
fn test_delta_debugger_current_subset() {
let items = vec!["a", "b", "c", "d"];
let mut dd = DeltaDebugger::new(items);
dd.record_result(vec![1, 2], TestResult::Fail);
let subset = dd.current_subset();
assert_eq!(subset, vec![&"b", &"c"]);
}
#[test]
fn test_delta_debugger_max_steps() {
let items = vec!["a", "b", "c"];
let dd = DeltaDebugger::new(items).with_max_steps(5);
assert!(!dd.is_done());
}
#[test]
fn test_delta_debugger_cancel() {
let items = vec!["a", "b", "c"];
let mut dd = DeltaDebugger::new(items);
dd.cancel();
assert_eq!(dd.state(), BisectState::Canceled);
assert!(dd.is_done());
}
#[test]
fn test_delta_debugger_render() {
let items = vec!["a", "b", "c", "d"];
let mut dd = DeltaDebugger::new(items);
dd.record_result(vec![0, 1], TestResult::Fail);
let output = dd.render(50);
assert!(output.contains("DELTA DEBUGGING"));
assert!(output.contains("Step"));
}
#[test]
fn test_bisect_session_new() {
let files = vec![
"a.ruchy".to_string(),
"b.ruchy".to_string(),
"c.ruchy".to_string(),
];
let session = BisectSession::new(files);
assert_eq!(session.range, (0, 2));
assert!(session.id.starts_with("bisect-"));
}
#[test]
fn test_bisect_session_midpoint() {
let files = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
"e".to_string(),
];
let session = BisectSession::new(files);
assert_eq!(session.midpoint(), Some(2)); }
#[test]
fn test_bisect_session_mark_good() {
let files = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
let mut session = BisectSession::new(files);
session.mark_good(1);
assert_eq!(session.range.0, 2); }
#[test]
fn test_bisect_session_mark_bad() {
let files = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
let mut session = BisectSession::new(files);
session.mark_bad(2);
assert_eq!(session.range.1, 2); }
#[test]
fn test_bisect_session_found() {
let files = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let mut session = BisectSession::new(files);
session.mark_good(0);
session.mark_bad(1);
assert_eq!(session.state, BisectState::Found);
assert_eq!(session.first_bad(), Some("b"));
}
#[test]
fn test_bisect_session_steps_remaining() {
let files: Vec<String> = (0..16).map(|i| format!("{i}.ruchy")).collect();
let session = BisectSession::new(files);
assert!(session.steps_remaining() <= 4);
}
#[test]
fn test_bisect_session_current_file() {
let files = vec![
"a.ruchy".to_string(),
"b.ruchy".to_string(),
"c.ruchy".to_string(),
];
let session = BisectSession::new(files);
assert_eq!(session.current_file(), Some("b.ruchy"));
}
}