diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2025-12-12 14:30:45 +0100 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2025-12-13 21:32:44 +0100 |
| commit | 9163c3ae5b55abcb27f15dd39203143b8e1e443b (patch) | |
| tree | ee7760a8e91110812e7e6c90e95fc23ccc443e0f /src/lib.rs | |
Diffstat (limited to 'src/lib.rs')
| -rw-r--r-- | src/lib.rs | 1354 |
1 files changed, 1354 insertions, 0 deletions
diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..cb003f2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,1354 @@ +/// A parsed wardley map. +#[derive(Debug)] +pub struct Map { + pub components: Vec<Component>, + pub dependencies: Vec<Dependency>, + pub notes: Vec<Note>, + pub stages: Vec<StageData>, + pub groups: Vec<Group>, + pub inertias: Vec<Inertia>, + pub evolutions: Vec<Evolution>, +} + +/// Any of the potential shapes in a component. +#[derive(Debug, PartialEq)] +pub enum Shape { + Circle, + X, + Square, + Triangle, +} + +/// The potential stages of evolution. +#[derive(Debug, PartialEq)] +pub enum Stage { + I, + Ii, + Iii, + Iv, +} + +/// A component in the map. +#[derive(Debug, PartialEq)] +pub struct Component { + pub label: String, + pub coordinates: (f64, f64), + pub shape: Shape, +} + +/// A dependency between two components. +#[derive(Debug, PartialEq)] +pub struct Dependency { + pub from: String, + pub to: String, + pub is_directed: bool, +} + +/// A note. +#[derive(Debug, PartialEq)] +pub struct Note { + pub text: String, + pub coordinates: (f64, f64), +} + +/// An override for the width of an evolution stage. +#[derive(Debug, PartialEq)] +pub struct StageData { + pub stage: Stage, + pub value: f64, +} + +/// A group of components. +#[derive(Debug, PartialEq)] +pub struct Group { + pub components: Vec<String>, +} + +/// Inertia associated with a component. +#[derive(Debug, PartialEq)] +pub struct Inertia { + pub component: String, +} + +/// Evolution associated with a component. +#[derive(Debug, PartialEq)] +pub struct Evolution { + pub component: String, + pub value: f64, +} + +/// Parses a wmap source string. +#[must_use] +pub fn parse(source: &str) -> Map { + // Estimate capacity based on input size + let estimated_lines = source.len() / 30; + + let mut components = Vec::with_capacity(estimated_lines); + let mut dependencies = Vec::with_capacity(estimated_lines); + let mut notes = Vec::with_capacity(estimated_lines / 10); + let mut stages = Vec::with_capacity(4); + let mut groups = Vec::with_capacity(estimated_lines / 20); + let mut inertias = Vec::with_capacity(estimated_lines / 20); + let mut evolutions = Vec::with_capacity(estimated_lines / 20); + + let bytes = source.as_bytes(); + let mut i = 0; + let len = bytes.len(); + + while i < len { + // Skip line separators + while i < len && (bytes[i] == b'\n' || bytes[i] == b'\r') { + i += 1; + } + + let line_start = i; + + // Find line end + while i < len && bytes[i] != b'\n' && bytes[i] != b'\r' { + i += 1; + } + + let line = source[line_start..i].trim(); + + if line.is_empty() { + continue; + } + + // At this point the line is trimmed. + let bytes = line.as_bytes(); + if bytes[0] == b'[' { + parse_keyword_line( + line, + &mut stages, + &mut notes, + &mut groups, + &mut inertias, + &mut evolutions, + ); + } else if has_dependency(bytes) { + if let Some(dep) = parse_dependency(line) { + dependencies.push(dep); + } + } else if let Some(comp) = parse_component(line) { + components.push(comp); + } + } + + Map { + components, + dependencies, + notes, + stages, + groups, + inertias, + evolutions, + } +} + +/// Checks if the line has the -- or -> of a dependency. +#[inline] +fn has_dependency(bytes: &[u8]) -> bool { + for i in 0..bytes.len() { + if bytes[i] == b'(' { + return false; + } + if bytes[i] == b'-' && i + 1 < bytes.len() { + let next = bytes[i + 1]; + if next == b'>' || next == b'-' { + return true; + } + } + } + false +} + +/// Parses a Keyword line. +/// IMPORTANT: This function assumes the line has been trimmed, and confirmed +/// to start with [. +fn parse_keyword_line( + line: &str, + stages: &mut Vec<StageData>, + notes: &mut Vec<Note>, + groups: &mut Vec<Group>, + inertias: &mut Vec<Inertia>, + evolutions: &mut Vec<Evolution>, +) { + let bytes = line.as_bytes(); + let mut i = 0; + + // Skip the first [ + i += 1; + + let keyword_start = i; + while i < bytes.len() && bytes[i] != b']' { + i += 1; + } + if i >= bytes.len() { + return; + } + + let keyword = &line[keyword_start..i]; + i += 1; + + i = skip_whitespace(bytes, i); + let rest = &line[i..]; + + if keyword.eq_ignore_ascii_case("i") + || keyword.eq_ignore_ascii_case("ii") + || keyword.eq_ignore_ascii_case("iii") + || keyword.eq_ignore_ascii_case("iv") + { + stages.extend(parse_stage(keyword, rest)); + } else if keyword.eq_ignore_ascii_case("note") { + notes.extend(parse_note(rest)); + } else if keyword.eq_ignore_ascii_case("group") { + groups.extend(parse_group(rest)); + } else if keyword.eq_ignore_ascii_case("inertia") { + let component = rest.trim(); + if !component.is_empty() { + inertias.push(Inertia { + component: component.to_string(), + }); + } + } else if keyword.eq_ignore_ascii_case("evolution") { + evolutions.extend(parse_evolution(rest)); + } +} + +/// Parses the stage of evolution. +/// IMPORTANT: Assumes we already checked for i, ii, iii, or iv. +fn parse_stage(stage_str: &str, rest: &str) -> Option<StageData> { + let value = rest.trim().parse::<f64>().ok()?; + let stage = if stage_str.eq_ignore_ascii_case("i") { + Stage::I + } else if stage_str.eq_ignore_ascii_case("ii") { + Stage::Ii + } else if stage_str.eq_ignore_ascii_case("iii") { + Stage::Iii + } else { + Stage::Iv + }; + Some(StageData { stage, value }) +} + +/// Parses a note with a position. +fn parse_note(rest: &str) -> Option<Note> { + let bytes = rest.as_bytes(); + let mut i = skip_whitespace(bytes, 0); + + if i >= bytes.len() || bytes[i] != b'(' { + return None; + } + i += 1; + + let (x, y, end_i) = parse_coordinates(rest, i)?; + i = end_i; + + i = skip_whitespace(bytes, i); + let text = rest[i..].trim().to_string(); + + Some(Note { + text, + coordinates: (x, y), + }) +} + +/// Parses a comma separated group of nodes. +fn parse_group(rest: &str) -> Option<Group> { + let components: Vec<String> = rest + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(std::string::ToString::to_string) + .collect(); + + if components.is_empty() { + None + } else { + Some(Group { components }) + } +} + +/// Parses the evolution of a node +fn parse_evolution(rest: &str) -> Option<Evolution> { + let bytes = rest.as_bytes(); + let mut position_of_sign = None; + let mut sign = 1.0; + + for (i, byte) in bytes.iter().enumerate() { + if byte == &b'+' || byte == &b'-' { + position_of_sign = Some(i); + sign = if byte == &b'-' { -1.0 } else { 1.0 }; + break; + } + } + + let i = position_of_sign?; + let component = rest[..i].trim().to_string(); + if component.is_empty() { + return None; + } + + let value_str = rest[i + 1..].trim(); + let value = value_str.parse::<f64>().ok()?; + + Some(Evolution { + component, + value: sign * value, + }) +} + +/// Parses a dependency. +/// IMPORTANT: This function assumes we confirmed this string has a -- or -> +fn parse_dependency(line: &str) -> Option<Dependency> { + let index_of_arrow = line.find("->"); + let index_of_dash = line.find("--"); + + let index = index_of_arrow.or(index_of_dash)?; + let from = line[..index].trim(); + let to = line[index + 2..].trim(); + + if from.is_empty() || to.is_empty() { + return None; + } + + Some(Dependency { + from: from.to_string(), + to: to.to_string(), + is_directed: index_of_arrow.is_some(), + }) +} + +/// Parses a component with coordinates, and optionally a cusotm shape. +fn parse_component(line: &str) -> Option<Component> { + let bytes = line.as_bytes(); + let index_of_parenthesis = line.find('(')?; + let label = line[..index_of_parenthesis].trim().to_string(); + if label.is_empty() { + return None; + } + + let (x, y, end_i) = parse_coordinates(line, index_of_parenthesis + 1)?; + + let mut i = skip_whitespace(bytes, end_i); + let shape = if i < bytes.len() && bytes[i] == b'[' { + i += 1; + let shape_start = i; + while i < bytes.len() && bytes[i] != b']' { + i += 1; + } + if i > shape_start && i < bytes.len() { + let shape_label = line[shape_start..i].trim(); + parse_shape(shape_label) + } else { + Shape::Circle + } + } else { + Shape::Circle + }; + + Some(Component { + label, + coordinates: (x, y), + shape, + }) +} + +/// Parses the name of a shape. +#[inline] +fn parse_shape(shape: &str) -> Shape { + if shape.eq_ignore_ascii_case("x") { + Shape::X + } else if shape.eq_ignore_ascii_case("square") { + Shape::Square + } else if shape.eq_ignore_ascii_case("triangle") { + Shape::Triangle + } else { + Shape::Circle + } +} + +/// Parses a pair of comma separated coordinates. +fn parse_coordinates(line: &str, start: usize) -> Option<(f64, f64, usize)> { + let bytes = line.as_bytes(); + let mut i = skip_whitespace(bytes, start); + + // Parse x + let x_start = i; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { + i += 1; + } + let x = line[x_start..i].parse::<f64>().ok()?; + + // Expect comma + i = skip_whitespace(bytes, i); + if i >= bytes.len() || bytes[i] != b',' { + return None; + } + i += 1; + + // Parse y + i = skip_whitespace(bytes, i); + let y_start = i; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { + i += 1; + } + let y = line[y_start..i].parse::<f64>().ok()?; + + // Expect closing paren + i = skip_whitespace(bytes, i); + if i >= bytes.len() || bytes[i] != b')' { + return None; + } + i += 1; + + Some((x, y, i)) +} + +// Parsing Helpers ///////////////////////////////////////////////////////////// + +/// Advances the iterator while there's only whitespace. +#[inline] +fn skip_whitespace(bytes: &[u8], mut i: usize) -> usize { + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + i +} + +// Tests /////////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod tests { + use super::*; + + /* Components */ + + #[test] + fn should_parse_a_basic_component() { + let source = "Dominoes (0.9, 0.5)"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Dominoes".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Circle + } + ); + } + + #[test] + fn should_parse_a_component_with_a_shape() { + let source = "Database (0.33, 0.81) [Square]"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Database".to_string(), + coordinates: (0.33, 0.81), + shape: Shape::Square + } + ); + } + + #[test] + fn should_parse_a_component_with_x_shape() { + let source = "API (0.5, 0.6) [x]"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "API".to_string(), + coordinates: (0.5, 0.6), + shape: Shape::X + } + ); + } + + #[test] + fn should_parse_a_component_with_triangle_shape() { + let source = "Service (0.4, 0.7) [Triangle]"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Service".to_string(), + coordinates: (0.4, 0.7), + shape: Shape::Triangle + } + ); + } + + #[test] + fn should_parse_a_component_with_circle_shape() { + let source = "Process (0.2, 0.9) [CIRCLE]"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Process".to_string(), + coordinates: (0.2, 0.9), + shape: Shape::Circle + } + ); + } + + #[test] + fn should_default_shape_to_circle_if_invalid() { + let source = "Figures (0.12, 0.711) ["; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Figures".to_string(), + coordinates: (0.12, 0.711), + shape: Shape::Circle + } + ); + } + + #[test] + fn should_handle_decimal_numbers() { + let source = "Item (.5, .123)"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Item".to_string(), + coordinates: (0.5, 0.123), + shape: Shape::Circle + } + ); + } + + #[test] + fn should_handle_inteers_as_coordinates() { + let source = "Item (1, 0)"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Item".to_string(), + coordinates: (1.0, 0.0), + shape: Shape::Circle + } + ); + } + + #[test] + fn should_preserve_case_in_component_labels() { + let source = "RubberGaskets (0.5, 0.5)"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "RubberGaskets".to_string(), + coordinates: (0.5, 0.5), + shape: Shape::Circle + } + ); + } + + #[test] + fn should_handle_component_labels_with_spaces() { + let source = "Big Lawnmowers (0.5, 0.5)"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Big Lawnmowers".to_string(), + coordinates: (0.5, 0.5), + shape: Shape::Circle + } + ); + } + + #[test] + fn should_handle_a_whitespace_around_tokens() { + let source = " Outer Space ( 0.9 , 0.5 ) [ Square ] "; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Outer Space".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Square + } + ); + } + + /* Dependencies */ + + #[test] + fn should_parse_undirected_dependency() { + let source = "Smoke -- Fire"; + let result = parse(source); + assert_eq!(result.dependencies.len(), 1); + assert_eq!( + result.dependencies.first().unwrap(), + &Dependency { + from: "Smoke".to_string(), + to: "Fire".to_string(), + is_directed: false + } + ); + } + + #[test] + fn should_parse_directed_dependency() { + let source = "Cool Thing -> Different Cool Thing"; + let result = parse(source); + assert_eq!(result.dependencies.len(), 1); + assert_eq!( + result.dependencies.first().unwrap(), + &Dependency { + from: "Cool Thing".to_string(), + to: "Different Cool Thing".to_string(), + is_directed: true + } + ); + } + + #[test] + fn should_preserve_case_in_dependency_labels() { + let source = "SaRcAsM -> Seriousness."; + let result = parse(source); + assert_eq!(result.dependencies.len(), 1); + assert_eq!( + result.dependencies.first().unwrap(), + &Dependency { + from: "SaRcAsM".to_string(), + to: "Seriousness.".to_string(), + is_directed: true + } + ); + } + + #[test] + fn should_ignore_whitespace_in_dependencies() { + let source = " loose --TIGHT"; + let result = parse(source); + assert_eq!(result.dependencies.len(), 1); + assert_eq!( + result.dependencies.first().unwrap(), + &Dependency { + from: "loose".to_string(), + to: "TIGHT".to_string(), + is_directed: false + } + ); + } + + /* Notes */ + + #[test] + fn should_parse_a_note() { + let source = "[Note] (0.5, 0.5) This is a note"; + let result = parse(source); + assert_eq!(result.notes.len(), 1); + assert_eq!( + result.notes.first().unwrap(), + &Note { + text: "This is a note".to_string(), + coordinates: (0.5, 0.5) + } + ); + } + + #[test] + fn should_handle_case_insensitive_note_keyword() { + let source = "[note] (0.1, 0.2) Text"; + let result = parse(source); + assert_eq!(result.notes.len(), 1); + assert_eq!( + result.notes.first().unwrap(), + &Note { + text: "Text".to_string(), + coordinates: (0.1, 0.2) + } + ); + } + + #[test] + fn should_preserve_case_in_note_text() { + let source = "[NOTE] (0.77, 0.19) Important NOTE"; + let result = parse(source); + assert_eq!(result.notes.len(), 1); + assert_eq!( + result.notes.first().unwrap(), + &Note { + text: "Important NOTE".to_string(), + coordinates: (0.77, 0.19) + } + ); + } + + #[test] + fn should_handle_empty_note_text() { + let source = "[Note] (0.5, 0.5) "; + let result = parse(source); + assert_eq!(result.notes.len(), 1); + assert_eq!( + result.notes.first().unwrap(), + &Note { + text: "".to_string(), + coordinates: (0.5, 0.5) + } + ); + } + + /* Stages */ + + #[test] + fn should_parse_stage_i() { + let source = "[I] 0.25"; + let result = parse(source); + assert_eq!(result.stages.len(), 1); + assert_eq!( + result.stages.first().unwrap(), + &StageData { + stage: Stage::I, + value: 0.25 + } + ); + } + + #[test] + fn should_parse_stage_ii() { + let source = "[ii] 0.5"; + let result = parse(source); + assert_eq!(result.stages.len(), 1); + assert_eq!( + result.stages.first().unwrap(), + &StageData { + stage: Stage::Ii, + value: 0.5 + } + ); + } + + #[test] + fn should_parse_stage_iii() { + let source = "[III] 0.75"; + let result = parse(source); + assert_eq!(result.stages.len(), 1); + assert_eq!( + result.stages.first().unwrap(), + &StageData { + stage: Stage::Iii, + value: 0.75 + } + ); + } + + #[test] + fn should_parse_stage_iv() { + let source = "[Iv] 1.0"; + let result = parse(source); + assert_eq!(result.stages.len(), 1); + assert_eq!( + result.stages.first().unwrap(), + &StageData { + stage: Stage::Iv, + value: 1.0 + } + ); + } + + #[test] + fn should_handle_decimal_values() { + let source = "[i] .333"; + let result = parse(source); + assert_eq!(result.stages.len(), 1); + assert_eq!( + result.stages.first().unwrap(), + &StageData { + stage: Stage::I, + value: 0.333 + } + ); + } + + /* Groups */ + + #[test] + fn should_parse_a_group_with_a_single_component() { + let source = "[Group] Lonely"; + let result = parse(source); + assert_eq!(result.groups.len(), 1); + assert_eq!( + result.groups.first().unwrap().components, + vec!["Lonely".to_string()] + ); + } + + #[test] + fn should_parse_a_group_with_multiple_components() { + let source = "[GrouP] Huey, Dewey, Louie"; + let result = parse(source); + assert_eq!(result.groups.len(), 1); + assert_eq!( + result.groups.first().unwrap().components, + vec!["Huey".to_string(), "Dewey".to_string(), "Louie".to_string()] + ); + } + + #[test] + fn should_handle_case_insensitive_group_keyword() { + let source = "[group] Salt, Pepper"; + let result = parse(source); + assert_eq!(result.groups.len(), 1); + assert_eq!( + result.groups.first().unwrap().components, + vec!["Salt".to_string(), "Pepper".to_string()] + ); + } + + #[test] + fn should_preserve_case_in_group_component_label() { + let source = "[GROUP] XML, LaTeX"; + let result = parse(source); + assert_eq!(result.groups.len(), 1); + assert_eq!( + result.groups.first().unwrap().components, + vec!["XML".to_string(), "LaTeX".to_string()] + ); + } + + #[test] + fn should_handle_whitespace_around_commas() { + let source = "[Group] sky, is large, emperor"; + let result = parse(source); + assert_eq!(result.groups.len(), 1); + assert_eq!( + result.groups.first().unwrap().components, + vec![ + "sky".to_string(), + "is large".to_string(), + "emperor".to_string() + ] + ); + } + + /* Inertia */ + #[test] + fn should_parse_inertia() { + let source = "[Inertia] Print Media"; + let result = parse(source); + assert_eq!(result.inertias.len(), 1); + assert_eq!(result.inertias.first().unwrap().component, "Print Media"); + } + + #[test] + fn should_handle_case_insensitive_inertia_keyword() { + let source = "[inertia] Yes"; + let result = parse(source); + assert_eq!(result.inertias.len(), 1); + assert_eq!(result.inertias.first().unwrap().component, "Yes"); + } + + #[test] + fn should_preserve_case_in_inertia_component_label() { + let source = "[INERTIA] SpOnGeBoB"; + let result = parse(source); + assert_eq!(result.inertias.len(), 1); + assert_eq!(result.inertias.first().unwrap().component, "SpOnGeBoB"); + } + + /* Evolution */ + #[test] + fn should_parse_positive_evolution() { + let source = "[Evolution] Nanomachines + 0.5"; + let result = parse(source); + assert_eq!(result.evolutions.len(), 1); + assert_eq!( + result.evolutions.first().unwrap(), + &Evolution { + component: "Nanomachines".to_string(), + value: 0.5 + } + ); + } + + #[test] + fn should_parse_negative_evolution() { + let source = "[Evolution] FOXDIE - 0.3"; + let result = parse(source); + assert_eq!(result.evolutions.len(), 1); + assert_eq!( + result.evolutions.first().unwrap(), + &Evolution { + component: "FOXDIE".to_string(), + value: -0.3 + } + ); + } + + #[test] + fn should_handle_case_insensitive_evolution_keyword() { + let source = "[Evolution] tamales + 0.73"; + let result = parse(source); + assert_eq!(result.evolutions.len(), 1); + assert_eq!( + result.evolutions.first().unwrap(), + &Evolution { + component: "tamales".to_string(), + value: 0.73 + } + ); + } + + #[test] + fn should_preserve_case_in_evolution_component_label() { + let source = "[EVOLUTION] iMac - 0.2"; + let result = parse(source); + assert_eq!(result.evolutions.len(), 1); + assert_eq!( + result.evolutions.first().unwrap(), + &Evolution { + component: "iMac".to_string(), + value: -0.2 + } + ); + } + + #[test] + fn should_handle_decimal_evolution_values() { + let source = "[Evolution] Numerals + .75"; + let result = parse(source); + assert_eq!(result.evolutions.len(), 1); + assert_eq!( + result.evolutions.first().unwrap(), + &Evolution { + component: "Numerals".to_string(), + value: 0.75 + } + ); + } + + /* Multiple Entities */ + #[test] + fn should_parse_multiple_entities() { + let source = "Water (0.9, 0.5) +Bucket (0.8, 0.4) [x] +Water -> Bucket"; + let result = parse(source); + assert_eq!(result.components.len(), 2); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Water".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Circle + } + ); + assert_eq!( + result.components.get(1).unwrap(), + &Component { + label: "Bucket".to_string(), + coordinates: (0.8, 0.4), + shape: Shape::X + } + ); + assert_eq!(result.dependencies.len(), 1); + assert_eq!( + result.dependencies.first().unwrap(), + &Dependency { + from: "Water".to_string(), + to: "Bucket".to_string(), + is_directed: true + } + ); + } + + #[test] + fn should_handle_unix_line_endings() { + let source = "Penguins (0.9, 0.5)\nI Use Arch BTW (0.8, 0.4) [x]"; + let result = parse(source); + assert_eq!(result.components.len(), 2); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Penguins".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Circle + } + ); + assert_eq!( + result.components.get(1).unwrap(), + &Component { + label: "I Use Arch BTW".to_string(), + coordinates: (0.8, 0.4), + shape: Shape::X + } + ); + } + + #[test] + fn should_handle_windows_line_endings() { + let source = "Clippy (0.9, 0.5)\r\nSpace Cadet (0.8, 0.4) [x]"; + let result = parse(source); + assert_eq!(result.components.len(), 2); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Clippy".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Circle + } + ); + assert_eq!( + result.components.get(1).unwrap(), + &Component { + label: "Space Cadet".to_string(), + coordinates: (0.8, 0.4), + shape: Shape::X + } + ); + } + + #[test] + fn should_handle_mac_line_endings() { + let source = "SE/30 (0.9, 0.5)\rPerforma (0.8, 0.4) [x]"; + let result = parse(source); + assert_eq!(result.components.len(), 2); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "SE/30".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Circle + } + ); + assert_eq!( + result.components.get(1).unwrap(), + &Component { + label: "Performa".to_string(), + coordinates: (0.8, 0.4), + shape: Shape::X + } + ); + } + + #[test] + fn should_ignore_empty_lines() { + let source = "The Void (0.9, 0.5) + +The Abyss (0.8, 0.4)"; + let result = parse(source); + assert_eq!(result.components.len(), 2); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "The Void".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Circle + } + ); + assert_eq!( + result.components.get(1).unwrap(), + &Component { + label: "The Abyss".to_string(), + coordinates: (0.8, 0.4), + shape: Shape::Circle + } + ); + } + + #[test] + fn should_handle_incomplete_notes() { + let source = "[Note]"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_incomplete_groups() { + let source = "[Group]"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_incomplete_nodes_with_no_coordinates() { + let source = "I am a node? ("; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_incomplete_nodes_with_incomplete_coordinates() { + let source = "I am a node? (12"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_incomplete_nodes_with_missing_y_coordinates() { + let source = "I am a node? (12,"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_incomplete_nodes_with_unclosed_coordinates() { + let source = "I am a node? (12,21"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_incomplete_evolution_without_value() { + let source = "[Evolution] I accidentally a +"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_incomplete_evolution_without_component() { + let source = "[Evolution]+"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + #[test] + fn should_handle_just_a_poarenthesis() { + let source = " ("; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_ignore_invalid_lines() { + let source = "Hello (0.9, 0.5) +Oh No +It's OK (0.8, 0.4) +[ijole] NO IT ISN'T +A-- +B-> +( +Hello -> It's OK +["; + let result = parse(source); + assert_eq!(result.components.len(), 2); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Hello".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Circle + } + ); + assert_eq!( + result.components.get(1).unwrap(), + &Component { + label: "It's OK".to_string(), + coordinates: (0.8, 0.4), + shape: Shape::Circle + } + ); + assert_eq!(result.dependencies.len(), 1); + assert_eq!( + result.dependencies.first().unwrap(), + &Dependency { + from: "Hello".to_string(), + to: "It's OK".to_string(), + is_directed: true + } + ); + } + + /* Edge Cases */ + #[test] + fn should_handle_empty_input() { + let source = ""; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_only_whitespace() { + let source = " \n \n "; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn only_line_breaks() { + let source = "\n"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_only_invalid_lines() { + let source = "invalid\nstill invalid"; + let result = parse(source); + assert_eq!(result.components.len(), 0); + } + + #[test] + fn should_handle_complex_component_labels() { + let source = "My Sü'per Complex Label 123 (0.5, 0.5)"; + let result = parse(source); + assert_eq!(result.components.len(), 1); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "My Sü'per Complex Label 123".to_string(), + coordinates: (0.5, 0.5), + shape: Shape::Circle + } + ); + } + + /* Comprehensive Example */ + #[test] + fn should_parse_a_complete_wardley_map() { + let source = "[I] 0.25 +[II] 0.5 +[III] 0.75 +[IV] 1.0 + +Tea (0.9, 0.5) [Circle] +Cup (0.8, 0.4) +Kettle (0.7, 0.3) [Square] +Hot Water (0.6, 0.2) + +Tea -> Cup +Cup -> Kettle +Kettle -> Hot Water + +[Note] (0.5, 0.5) This is our tea supply chain + +[Group] Tea, Cup, Kettle +[Inertia] Kettle +[Evolution] Tea + 0.1"; + + let result = parse(source); + + // 4 stages + 4 components + 3 dependencies + 1 note + 1 group + 1 inertia + 1 evolution + assert_eq!(result.stages.len(), 4); + assert_eq!(result.components.len(), 4); + assert_eq!(result.dependencies.len(), 3); + assert_eq!(result.notes.len(), 1); + assert_eq!(result.groups.len(), 1); + assert_eq!(result.inertias.len(), 1); + assert_eq!(result.evolutions.len(), 1); + + assert_eq!( + result.stages.first().unwrap(), + &StageData { + stage: Stage::I, + value: 0.25 + } + ); + assert_eq!( + result.stages.get(1).unwrap(), + &StageData { + stage: Stage::Ii, + value: 0.5 + } + ); + assert_eq!( + result.stages.get(2).unwrap(), + &StageData { + stage: Stage::Iii, + value: 0.75 + } + ); + assert_eq!( + result.stages.get(3).unwrap(), + &StageData { + stage: Stage::Iv, + value: 1.0 + } + ); + assert_eq!( + result.components.first().unwrap(), + &Component { + label: "Tea".to_string(), + coordinates: (0.9, 0.5), + shape: Shape::Circle + } + ); + assert_eq!( + result.components.get(1).unwrap(), + &Component { + label: "Cup".to_string(), + coordinates: (0.8, 0.4), + shape: Shape::Circle + } + ); + assert_eq!( + result.components.get(2).unwrap(), + &Component { + label: "Kettle".to_string(), + coordinates: (0.7, 0.3), + shape: Shape::Square + } + ); + assert_eq!( + result.components.get(3).unwrap(), + &Component { + label: "Hot Water".to_string(), + coordinates: (0.6, 0.2), + shape: Shape::Circle + } + ); + assert_eq!( + result.dependencies.first().unwrap(), + &Dependency { + from: "Tea".to_string(), + to: "Cup".to_string(), + is_directed: true + } + ); + assert_eq!( + result.dependencies.get(1).unwrap(), + &Dependency { + from: "Cup".to_string(), + to: "Kettle".to_string(), + is_directed: true + } + ); + assert_eq!( + result.dependencies.get(2).unwrap(), + &Dependency { + from: "Kettle".to_string(), + to: "Hot Water".to_string(), + is_directed: true + } + ); + assert_eq!( + result.notes.first().unwrap(), + &Note { + text: "This is our tea supply chain".to_string(), + coordinates: (0.5, 0.5) + } + ); + assert_eq!( + result.groups.first().unwrap().components, + vec!["Tea".to_string(), "Cup".to_string(), "Kettle".to_string()] + ); + assert_eq!(result.inertias.first().unwrap().component, "Kettle"); + assert_eq!( + result.evolutions.first().unwrap(), + &Evolution { + component: "Tea".to_string(), + value: 0.1 + } + ); + } +} |