/// A parsed wardley map. #[derive(Debug)] pub struct Map { pub components: Vec, pub dependencies: Vec, pub notes: Vec, pub stages: Vec, pub groups: Vec, pub inertias: Vec, pub evolutions: Vec, } /// 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, } /// 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, notes: &mut Vec, groups: &mut Vec, inertias: &mut Vec, evolutions: &mut Vec, ) { 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 { let value = rest.trim().parse::().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 { 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 { let components: Vec = 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 { 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::().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 { 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 { 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::().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::().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 } ); } }