Skip to main content

wmap_parser/
lib.rs

1/// A parsed wardley map.
2#[derive(Debug)]
3pub struct Map {
4    pub components: Vec<Component>,
5    pub dependencies: Vec<Dependency>,
6    pub notes: Vec<Note>,
7    pub stages: Vec<StageData>,
8    pub groups: Vec<Group>,
9    pub inertias: Vec<Inertia>,
10    pub evolutions: Vec<Evolution>,
11}
12
13/// Any of the potential shapes in a component.
14#[derive(Debug, PartialEq)]
15pub enum Shape {
16    Circle,
17    X,
18    Square,
19    Triangle,
20}
21
22/// The potential stages of evolution.
23#[derive(Debug, PartialEq)]
24pub enum Stage {
25    I,
26    Ii,
27    Iii,
28    Iv,
29}
30
31/// A component in the map.
32#[derive(Debug, PartialEq)]
33pub struct Component {
34    pub label: String,
35    pub coordinates: (f64, f64),
36    pub shape: Shape,
37}
38
39/// A dependency between two components.
40#[derive(Debug, PartialEq)]
41pub struct Dependency {
42    pub from: String,
43    pub to: String,
44    pub is_directed: bool,
45}
46
47/// A note.
48#[derive(Debug, PartialEq)]
49pub struct Note {
50    pub text: String,
51    pub coordinates: (f64, f64),
52}
53
54/// An override for the width of an evolution stage.
55#[derive(Debug, PartialEq)]
56pub struct StageData {
57    pub stage: Stage,
58    pub value: f64,
59}
60
61/// A group of components.
62#[derive(Debug, PartialEq)]
63pub struct Group {
64    pub components: Vec<String>,
65}
66
67/// Inertia associated with a component.
68#[derive(Debug, PartialEq)]
69pub struct Inertia {
70    pub component: String,
71}
72
73/// Evolution associated with a component.
74#[derive(Debug, PartialEq)]
75pub struct Evolution {
76    pub component: String,
77    pub value: f64,
78}
79
80/// Parses a wmap source string.
81#[must_use]
82pub fn parse(source: &str) -> Map {
83    // Estimate capacity based on input size
84    let estimated_lines = source.len() / 30;
85
86    let mut components = Vec::with_capacity(estimated_lines);
87    let mut dependencies = Vec::with_capacity(estimated_lines);
88    let mut notes = Vec::with_capacity(estimated_lines / 10);
89    let mut stages = Vec::with_capacity(4);
90    let mut groups = Vec::with_capacity(estimated_lines / 20);
91    let mut inertias = Vec::with_capacity(estimated_lines / 20);
92    let mut evolutions = Vec::with_capacity(estimated_lines / 20);
93
94    let bytes = source.as_bytes();
95    let mut i = 0;
96    let len = bytes.len();
97
98    while i < len {
99        // Skip line separators
100        while i < len && (bytes[i] == b'\n' || bytes[i] == b'\r') {
101            i += 1;
102        }
103
104        let line_start = i;
105
106        // Find line end
107        while i < len && bytes[i] != b'\n' && bytes[i] != b'\r' {
108            i += 1;
109        }
110
111        let line = source[line_start..i].trim();
112
113        if line.is_empty() {
114            continue;
115        }
116
117        // At this point the line is trimmed.
118        let bytes = line.as_bytes();
119        if bytes[0] == b'[' {
120            parse_keyword_line(
121                line,
122                &mut stages,
123                &mut notes,
124                &mut groups,
125                &mut inertias,
126                &mut evolutions,
127            );
128        } else if has_dependency(bytes) {
129            if let Some(dep) = parse_dependency(line) {
130                dependencies.push(dep);
131            }
132        } else if let Some(comp) = parse_component(line) {
133            components.push(comp);
134        }
135    }
136
137    Map {
138        components,
139        dependencies,
140        notes,
141        stages,
142        groups,
143        inertias,
144        evolutions,
145    }
146}
147
148/// Checks if the line has the -- or -> of a dependency.
149#[inline]
150fn has_dependency(bytes: &[u8]) -> bool {
151    for i in 0..bytes.len() {
152        if bytes[i] == b'(' {
153            return false;
154        }
155        if bytes[i] == b'-' && i + 1 < bytes.len() {
156            let next = bytes[i + 1];
157            if next == b'>' || next == b'-' {
158                return true;
159            }
160        }
161    }
162    false
163}
164
165/// Parses a Keyword line.
166/// IMPORTANT: This function assumes the line has been trimmed, and confirmed
167/// to start with [.
168fn parse_keyword_line(
169    line: &str,
170    stages: &mut Vec<StageData>,
171    notes: &mut Vec<Note>,
172    groups: &mut Vec<Group>,
173    inertias: &mut Vec<Inertia>,
174    evolutions: &mut Vec<Evolution>,
175) {
176    let bytes = line.as_bytes();
177    let mut i = 0;
178
179    // Skip the first [
180    i += 1;
181
182    let keyword_start = i;
183    while i < bytes.len() && bytes[i] != b']' {
184        i += 1;
185    }
186    if i >= bytes.len() {
187        return;
188    }
189
190    let keyword = &line[keyword_start..i];
191    i += 1;
192
193    i = skip_whitespace(bytes, i);
194    let rest = &line[i..];
195
196    if keyword.eq_ignore_ascii_case("i")
197        || keyword.eq_ignore_ascii_case("ii")
198        || keyword.eq_ignore_ascii_case("iii")
199        || keyword.eq_ignore_ascii_case("iv")
200    {
201        stages.extend(parse_stage(keyword, rest));
202    } else if keyword.eq_ignore_ascii_case("note") {
203        notes.extend(parse_note(rest));
204    } else if keyword.eq_ignore_ascii_case("group") {
205        groups.extend(parse_group(rest));
206    } else if keyword.eq_ignore_ascii_case("inertia") {
207        let component = rest.trim();
208        if !component.is_empty() {
209            inertias.push(Inertia {
210                component: component.to_string(),
211            });
212        }
213    } else if keyword.eq_ignore_ascii_case("evolution") {
214        evolutions.extend(parse_evolution(rest));
215    }
216}
217
218/// Parses the stage of evolution.
219/// IMPORTANT: Assumes we already checked for i, ii, iii, or iv.
220fn parse_stage(stage_str: &str, rest: &str) -> Option<StageData> {
221    let value = rest.trim().parse::<f64>().ok()?;
222    let stage = if stage_str.eq_ignore_ascii_case("i") {
223        Stage::I
224    } else if stage_str.eq_ignore_ascii_case("ii") {
225        Stage::Ii
226    } else if stage_str.eq_ignore_ascii_case("iii") {
227        Stage::Iii
228    } else {
229        Stage::Iv
230    };
231    Some(StageData { stage, value })
232}
233
234/// Parses a note with a position.
235fn parse_note(rest: &str) -> Option<Note> {
236    let bytes = rest.as_bytes();
237    let mut i = skip_whitespace(bytes, 0);
238
239    if i >= bytes.len() || bytes[i] != b'(' {
240        return None;
241    }
242    i += 1;
243
244    let (x, y, end_i) = parse_coordinates(rest, i)?;
245    i = end_i;
246
247    i = skip_whitespace(bytes, i);
248    let text = rest[i..].trim().to_string();
249
250    Some(Note {
251        text,
252        coordinates: (x, y),
253    })
254}
255
256/// Parses a comma separated group of nodes.
257fn parse_group(rest: &str) -> Option<Group> {
258    let components: Vec<String> = rest
259        .split(',')
260        .map(str::trim)
261        .filter(|s| !s.is_empty())
262        .map(std::string::ToString::to_string)
263        .collect();
264
265    if components.is_empty() {
266        None
267    } else {
268        Some(Group { components })
269    }
270}
271
272/// Parses the evolution of a node
273fn parse_evolution(rest: &str) -> Option<Evolution> {
274    let bytes = rest.as_bytes();
275    let mut position_of_sign = None;
276    let mut sign = 1.0;
277
278    for (i, byte) in bytes.iter().enumerate() {
279        if byte == &b'+' || byte == &b'-' {
280            position_of_sign = Some(i);
281            sign = if byte == &b'-' { -1.0 } else { 1.0 };
282            break;
283        }
284    }
285
286    let i = position_of_sign?;
287    let component = rest[..i].trim().to_string();
288    if component.is_empty() {
289        return None;
290    }
291
292    let value_str = rest[i + 1..].trim();
293    let value = value_str.parse::<f64>().ok()?;
294
295    Some(Evolution {
296        component,
297        value: sign * value,
298    })
299}
300
301/// Parses a dependency.
302/// IMPORTANT: This function assumes we confirmed this string has a -- or ->
303fn parse_dependency(line: &str) -> Option<Dependency> {
304    let index_of_arrow = line.find("->");
305    let index_of_dash = line.find("--");
306
307    let index = index_of_arrow.or(index_of_dash)?;
308    let from = line[..index].trim();
309    let to = line[index + 2..].trim();
310
311    if from.is_empty() || to.is_empty() {
312        return None;
313    }
314
315    Some(Dependency {
316        from: from.to_string(),
317        to: to.to_string(),
318        is_directed: index_of_arrow.is_some(),
319    })
320}
321
322/// Parses a component with coordinates, and optionally a cusotm shape.
323fn parse_component(line: &str) -> Option<Component> {
324    let bytes = line.as_bytes();
325    let index_of_parenthesis = line.find('(')?;
326    let label = line[..index_of_parenthesis].trim().to_string();
327    if label.is_empty() {
328        return None;
329    }
330
331    let (x, y, end_i) = parse_coordinates(line, index_of_parenthesis + 1)?;
332
333    let mut i = skip_whitespace(bytes, end_i);
334    let shape = if i < bytes.len() && bytes[i] == b'[' {
335        i += 1;
336        let shape_start = i;
337        while i < bytes.len() && bytes[i] != b']' {
338            i += 1;
339        }
340        if i > shape_start && i < bytes.len() {
341            let shape_label = line[shape_start..i].trim();
342            parse_shape(shape_label)
343        } else {
344            Shape::Circle
345        }
346    } else {
347        Shape::Circle
348    };
349
350    Some(Component {
351        label,
352        coordinates: (x, y),
353        shape,
354    })
355}
356
357/// Parses the name of a shape.
358#[inline]
359fn parse_shape(shape: &str) -> Shape {
360    if shape.eq_ignore_ascii_case("x") {
361        Shape::X
362    } else if shape.eq_ignore_ascii_case("square") {
363        Shape::Square
364    } else if shape.eq_ignore_ascii_case("triangle") {
365        Shape::Triangle
366    } else {
367        Shape::Circle
368    }
369}
370
371/// Parses a pair of comma separated coordinates.
372fn parse_coordinates(line: &str, start: usize) -> Option<(f64, f64, usize)> {
373    let bytes = line.as_bytes();
374    let mut i = skip_whitespace(bytes, start);
375
376    // Parse x
377    let x_start = i;
378    while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
379        i += 1;
380    }
381    let x = line[x_start..i].parse::<f64>().ok()?;
382
383    // Expect comma
384    i = skip_whitespace(bytes, i);
385    if i >= bytes.len() || bytes[i] != b',' {
386        return None;
387    }
388    i += 1;
389
390    // Parse y
391    i = skip_whitespace(bytes, i);
392    let y_start = i;
393    while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
394        i += 1;
395    }
396    let y = line[y_start..i].parse::<f64>().ok()?;
397
398    // Expect closing paren
399    i = skip_whitespace(bytes, i);
400    if i >= bytes.len() || bytes[i] != b')' {
401        return None;
402    }
403    i += 1;
404
405    Some((x, y, i))
406}
407
408// Parsing Helpers /////////////////////////////////////////////////////////////
409
410/// Advances the iterator while there's only whitespace.
411#[inline]
412fn skip_whitespace(bytes: &[u8], mut i: usize) -> usize {
413    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
414        i += 1;
415    }
416    i
417}
418
419// Tests ///////////////////////////////////////////////////////////////////////
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    /* Components */
426
427    #[test]
428    fn should_parse_a_basic_component() {
429        let source = "Dominoes (0.9, 0.5)";
430        let result = parse(source);
431        assert_eq!(result.components.len(), 1);
432        assert_eq!(
433            result.components.first().unwrap(),
434            &Component {
435                label: "Dominoes".to_string(),
436                coordinates: (0.9, 0.5),
437                shape: Shape::Circle
438            }
439        );
440    }
441
442    #[test]
443    fn should_parse_a_component_with_a_shape() {
444        let source = "Database (0.33, 0.81) [Square]";
445        let result = parse(source);
446        assert_eq!(result.components.len(), 1);
447        assert_eq!(
448            result.components.first().unwrap(),
449            &Component {
450                label: "Database".to_string(),
451                coordinates: (0.33, 0.81),
452                shape: Shape::Square
453            }
454        );
455    }
456
457    #[test]
458    fn should_parse_a_component_with_x_shape() {
459        let source = "API (0.5, 0.6) [x]";
460        let result = parse(source);
461        assert_eq!(result.components.len(), 1);
462        assert_eq!(
463            result.components.first().unwrap(),
464            &Component {
465                label: "API".to_string(),
466                coordinates: (0.5, 0.6),
467                shape: Shape::X
468            }
469        );
470    }
471
472    #[test]
473    fn should_parse_a_component_with_triangle_shape() {
474        let source = "Service (0.4, 0.7) [Triangle]";
475        let result = parse(source);
476        assert_eq!(result.components.len(), 1);
477        assert_eq!(
478            result.components.first().unwrap(),
479            &Component {
480                label: "Service".to_string(),
481                coordinates: (0.4, 0.7),
482                shape: Shape::Triangle
483            }
484        );
485    }
486
487    #[test]
488    fn should_parse_a_component_with_circle_shape() {
489        let source = "Process (0.2, 0.9) [CIRCLE]";
490        let result = parse(source);
491        assert_eq!(result.components.len(), 1);
492        assert_eq!(
493            result.components.first().unwrap(),
494            &Component {
495                label: "Process".to_string(),
496                coordinates: (0.2, 0.9),
497                shape: Shape::Circle
498            }
499        );
500    }
501
502    #[test]
503    fn should_default_shape_to_circle_if_invalid() {
504        let source = "Figures (0.12, 0.711) [";
505        let result = parse(source);
506        assert_eq!(result.components.len(), 1);
507        assert_eq!(
508            result.components.first().unwrap(),
509            &Component {
510                label: "Figures".to_string(),
511                coordinates: (0.12, 0.711),
512                shape: Shape::Circle
513            }
514        );
515    }
516
517    #[test]
518    fn should_handle_decimal_numbers() {
519        let source = "Item (.5, .123)";
520        let result = parse(source);
521        assert_eq!(result.components.len(), 1);
522        assert_eq!(
523            result.components.first().unwrap(),
524            &Component {
525                label: "Item".to_string(),
526                coordinates: (0.5, 0.123),
527                shape: Shape::Circle
528            }
529        );
530    }
531
532    #[test]
533    fn should_handle_inteers_as_coordinates() {
534        let source = "Item (1, 0)";
535        let result = parse(source);
536        assert_eq!(result.components.len(), 1);
537        assert_eq!(
538            result.components.first().unwrap(),
539            &Component {
540                label: "Item".to_string(),
541                coordinates: (1.0, 0.0),
542                shape: Shape::Circle
543            }
544        );
545    }
546
547    #[test]
548    fn should_preserve_case_in_component_labels() {
549        let source = "RubberGaskets (0.5, 0.5)";
550        let result = parse(source);
551        assert_eq!(result.components.len(), 1);
552        assert_eq!(
553            result.components.first().unwrap(),
554            &Component {
555                label: "RubberGaskets".to_string(),
556                coordinates: (0.5, 0.5),
557                shape: Shape::Circle
558            }
559        );
560    }
561
562    #[test]
563    fn should_handle_component_labels_with_spaces() {
564        let source = "Big Lawnmowers (0.5, 0.5)";
565        let result = parse(source);
566        assert_eq!(result.components.len(), 1);
567        assert_eq!(
568            result.components.first().unwrap(),
569            &Component {
570                label: "Big Lawnmowers".to_string(),
571                coordinates: (0.5, 0.5),
572                shape: Shape::Circle
573            }
574        );
575    }
576
577    #[test]
578    fn should_handle_a_whitespace_around_tokens() {
579        let source = "  Outer Space  (  0.9  ,  0.5  )  [  Square  ]  ";
580        let result = parse(source);
581        assert_eq!(result.components.len(), 1);
582        assert_eq!(
583            result.components.first().unwrap(),
584            &Component {
585                label: "Outer Space".to_string(),
586                coordinates: (0.9, 0.5),
587                shape: Shape::Square
588            }
589        );
590    }
591
592    /* Dependencies */
593
594    #[test]
595    fn should_parse_undirected_dependency() {
596        let source = "Smoke -- Fire";
597        let result = parse(source);
598        assert_eq!(result.dependencies.len(), 1);
599        assert_eq!(
600            result.dependencies.first().unwrap(),
601            &Dependency {
602                from: "Smoke".to_string(),
603                to: "Fire".to_string(),
604                is_directed: false
605            }
606        );
607    }
608
609    #[test]
610    fn should_parse_directed_dependency() {
611        let source = "Cool Thing -> Different Cool Thing";
612        let result = parse(source);
613        assert_eq!(result.dependencies.len(), 1);
614        assert_eq!(
615            result.dependencies.first().unwrap(),
616            &Dependency {
617                from: "Cool Thing".to_string(),
618                to: "Different Cool Thing".to_string(),
619                is_directed: true
620            }
621        );
622    }
623
624    #[test]
625    fn should_preserve_case_in_dependency_labels() {
626        let source = "SaRcAsM -> Seriousness.";
627        let result = parse(source);
628        assert_eq!(result.dependencies.len(), 1);
629        assert_eq!(
630            result.dependencies.first().unwrap(),
631            &Dependency {
632                from: "SaRcAsM".to_string(),
633                to: "Seriousness.".to_string(),
634                is_directed: true
635            }
636        );
637    }
638
639    #[test]
640    fn should_ignore_whitespace_in_dependencies() {
641        let source = "     loose      --TIGHT";
642        let result = parse(source);
643        assert_eq!(result.dependencies.len(), 1);
644        assert_eq!(
645            result.dependencies.first().unwrap(),
646            &Dependency {
647                from: "loose".to_string(),
648                to: "TIGHT".to_string(),
649                is_directed: false
650            }
651        );
652    }
653
654    /* Notes */
655
656    #[test]
657    fn should_parse_a_note() {
658        let source = "[Note] (0.5, 0.5) This is a note";
659        let result = parse(source);
660        assert_eq!(result.notes.len(), 1);
661        assert_eq!(
662            result.notes.first().unwrap(),
663            &Note {
664                text: "This is a note".to_string(),
665                coordinates: (0.5, 0.5)
666            }
667        );
668    }
669
670    #[test]
671    fn should_handle_case_insensitive_note_keyword() {
672        let source = "[note] (0.1, 0.2) Text";
673        let result = parse(source);
674        assert_eq!(result.notes.len(), 1);
675        assert_eq!(
676            result.notes.first().unwrap(),
677            &Note {
678                text: "Text".to_string(),
679                coordinates: (0.1, 0.2)
680            }
681        );
682    }
683
684    #[test]
685    fn should_preserve_case_in_note_text() {
686        let source = "[NOTE] (0.77, 0.19) Important NOTE";
687        let result = parse(source);
688        assert_eq!(result.notes.len(), 1);
689        assert_eq!(
690            result.notes.first().unwrap(),
691            &Note {
692                text: "Important NOTE".to_string(),
693                coordinates: (0.77, 0.19)
694            }
695        );
696    }
697
698    #[test]
699    fn should_handle_empty_note_text() {
700        let source = "[Note] (0.5, 0.5) ";
701        let result = parse(source);
702        assert_eq!(result.notes.len(), 1);
703        assert_eq!(
704            result.notes.first().unwrap(),
705            &Note {
706                text: "".to_string(),
707                coordinates: (0.5, 0.5)
708            }
709        );
710    }
711
712    /* Stages */
713
714    #[test]
715    fn should_parse_stage_i() {
716        let source = "[I] 0.25";
717        let result = parse(source);
718        assert_eq!(result.stages.len(), 1);
719        assert_eq!(
720            result.stages.first().unwrap(),
721            &StageData {
722                stage: Stage::I,
723                value: 0.25
724            }
725        );
726    }
727
728    #[test]
729    fn should_parse_stage_ii() {
730        let source = "[ii] 0.5";
731        let result = parse(source);
732        assert_eq!(result.stages.len(), 1);
733        assert_eq!(
734            result.stages.first().unwrap(),
735            &StageData {
736                stage: Stage::Ii,
737                value: 0.5
738            }
739        );
740    }
741
742    #[test]
743    fn should_parse_stage_iii() {
744        let source = "[III] 0.75";
745        let result = parse(source);
746        assert_eq!(result.stages.len(), 1);
747        assert_eq!(
748            result.stages.first().unwrap(),
749            &StageData {
750                stage: Stage::Iii,
751                value: 0.75
752            }
753        );
754    }
755
756    #[test]
757    fn should_parse_stage_iv() {
758        let source = "[Iv] 1.0";
759        let result = parse(source);
760        assert_eq!(result.stages.len(), 1);
761        assert_eq!(
762            result.stages.first().unwrap(),
763            &StageData {
764                stage: Stage::Iv,
765                value: 1.0
766            }
767        );
768    }
769
770    #[test]
771    fn should_handle_decimal_values() {
772        let source = "[i] .333";
773        let result = parse(source);
774        assert_eq!(result.stages.len(), 1);
775        assert_eq!(
776            result.stages.first().unwrap(),
777            &StageData {
778                stage: Stage::I,
779                value: 0.333
780            }
781        );
782    }
783
784    /* Groups */
785
786    #[test]
787    fn should_parse_a_group_with_a_single_component() {
788        let source = "[Group] Lonely";
789        let result = parse(source);
790        assert_eq!(result.groups.len(), 1);
791        assert_eq!(
792            result.groups.first().unwrap().components,
793            vec!["Lonely".to_string()]
794        );
795    }
796
797    #[test]
798    fn should_parse_a_group_with_multiple_components() {
799        let source = "[GrouP] Huey, Dewey, Louie";
800        let result = parse(source);
801        assert_eq!(result.groups.len(), 1);
802        assert_eq!(
803            result.groups.first().unwrap().components,
804            vec!["Huey".to_string(), "Dewey".to_string(), "Louie".to_string()]
805        );
806    }
807
808    #[test]
809    fn should_handle_case_insensitive_group_keyword() {
810        let source = "[group] Salt, Pepper";
811        let result = parse(source);
812        assert_eq!(result.groups.len(), 1);
813        assert_eq!(
814            result.groups.first().unwrap().components,
815            vec!["Salt".to_string(), "Pepper".to_string()]
816        );
817    }
818
819    #[test]
820    fn should_preserve_case_in_group_component_label() {
821        let source = "[GROUP] XML, LaTeX";
822        let result = parse(source);
823        assert_eq!(result.groups.len(), 1);
824        assert_eq!(
825            result.groups.first().unwrap().components,
826            vec!["XML".to_string(), "LaTeX".to_string()]
827        );
828    }
829
830    #[test]
831    fn should_handle_whitespace_around_commas() {
832        let source = "[Group]    sky,    is  large,                      emperor";
833        let result = parse(source);
834        assert_eq!(result.groups.len(), 1);
835        assert_eq!(
836            result.groups.first().unwrap().components,
837            vec![
838                "sky".to_string(),
839                "is  large".to_string(),
840                "emperor".to_string()
841            ]
842        );
843    }
844
845    /* Inertia */
846    #[test]
847    fn should_parse_inertia() {
848        let source = "[Inertia] Print Media";
849        let result = parse(source);
850        assert_eq!(result.inertias.len(), 1);
851        assert_eq!(result.inertias.first().unwrap().component, "Print Media");
852    }
853
854    #[test]
855    fn should_handle_case_insensitive_inertia_keyword() {
856        let source = "[inertia] Yes";
857        let result = parse(source);
858        assert_eq!(result.inertias.len(), 1);
859        assert_eq!(result.inertias.first().unwrap().component, "Yes");
860    }
861
862    #[test]
863    fn should_preserve_case_in_inertia_component_label() {
864        let source = "[INERTIA] SpOnGeBoB";
865        let result = parse(source);
866        assert_eq!(result.inertias.len(), 1);
867        assert_eq!(result.inertias.first().unwrap().component, "SpOnGeBoB");
868    }
869
870    /* Evolution */
871    #[test]
872    fn should_parse_positive_evolution() {
873        let source = "[Evolution] Nanomachines + 0.5";
874        let result = parse(source);
875        assert_eq!(result.evolutions.len(), 1);
876        assert_eq!(
877            result.evolutions.first().unwrap(),
878            &Evolution {
879                component: "Nanomachines".to_string(),
880                value: 0.5
881            }
882        );
883    }
884
885    #[test]
886    fn should_parse_negative_evolution() {
887        let source = "[Evolution] FOXDIE - 0.3";
888        let result = parse(source);
889        assert_eq!(result.evolutions.len(), 1);
890        assert_eq!(
891            result.evolutions.first().unwrap(),
892            &Evolution {
893                component: "FOXDIE".to_string(),
894                value: -0.3
895            }
896        );
897    }
898
899    #[test]
900    fn should_handle_case_insensitive_evolution_keyword() {
901        let source = "[Evolution] tamales + 0.73";
902        let result = parse(source);
903        assert_eq!(result.evolutions.len(), 1);
904        assert_eq!(
905            result.evolutions.first().unwrap(),
906            &Evolution {
907                component: "tamales".to_string(),
908                value: 0.73
909            }
910        );
911    }
912
913    #[test]
914    fn should_preserve_case_in_evolution_component_label() {
915        let source = "[EVOLUTION] iMac - 0.2";
916        let result = parse(source);
917        assert_eq!(result.evolutions.len(), 1);
918        assert_eq!(
919            result.evolutions.first().unwrap(),
920            &Evolution {
921                component: "iMac".to_string(),
922                value: -0.2
923            }
924        );
925    }
926
927    #[test]
928    fn should_handle_decimal_evolution_values() {
929        let source = "[Evolution] Numerals + .75";
930        let result = parse(source);
931        assert_eq!(result.evolutions.len(), 1);
932        assert_eq!(
933            result.evolutions.first().unwrap(),
934            &Evolution {
935                component: "Numerals".to_string(),
936                value: 0.75
937            }
938        );
939    }
940
941    /* Multiple Entities */
942    #[test]
943    fn should_parse_multiple_entities() {
944        let source = "Water (0.9, 0.5)
945Bucket (0.8, 0.4) [x]
946Water -> Bucket";
947        let result = parse(source);
948        assert_eq!(result.components.len(), 2);
949        assert_eq!(
950            result.components.first().unwrap(),
951            &Component {
952                label: "Water".to_string(),
953                coordinates: (0.9, 0.5),
954                shape: Shape::Circle
955            }
956        );
957        assert_eq!(
958            result.components.get(1).unwrap(),
959            &Component {
960                label: "Bucket".to_string(),
961                coordinates: (0.8, 0.4),
962                shape: Shape::X
963            }
964        );
965        assert_eq!(result.dependencies.len(), 1);
966        assert_eq!(
967            result.dependencies.first().unwrap(),
968            &Dependency {
969                from: "Water".to_string(),
970                to: "Bucket".to_string(),
971                is_directed: true
972            }
973        );
974    }
975
976    #[test]
977    fn should_handle_unix_line_endings() {
978        let source = "Penguins (0.9, 0.5)\nI Use Arch BTW (0.8, 0.4) [x]";
979        let result = parse(source);
980        assert_eq!(result.components.len(), 2);
981        assert_eq!(
982            result.components.first().unwrap(),
983            &Component {
984                label: "Penguins".to_string(),
985                coordinates: (0.9, 0.5),
986                shape: Shape::Circle
987            }
988        );
989        assert_eq!(
990            result.components.get(1).unwrap(),
991            &Component {
992                label: "I Use Arch BTW".to_string(),
993                coordinates: (0.8, 0.4),
994                shape: Shape::X
995            }
996        );
997    }
998
999    #[test]
1000    fn should_handle_windows_line_endings() {
1001        let source = "Clippy (0.9, 0.5)\r\nSpace Cadet (0.8, 0.4) [x]";
1002        let result = parse(source);
1003        assert_eq!(result.components.len(), 2);
1004        assert_eq!(
1005            result.components.first().unwrap(),
1006            &Component {
1007                label: "Clippy".to_string(),
1008                coordinates: (0.9, 0.5),
1009                shape: Shape::Circle
1010            }
1011        );
1012        assert_eq!(
1013            result.components.get(1).unwrap(),
1014            &Component {
1015                label: "Space Cadet".to_string(),
1016                coordinates: (0.8, 0.4),
1017                shape: Shape::X
1018            }
1019        );
1020    }
1021
1022    #[test]
1023    fn should_handle_mac_line_endings() {
1024        let source = "SE/30 (0.9, 0.5)\rPerforma (0.8, 0.4) [x]";
1025        let result = parse(source);
1026        assert_eq!(result.components.len(), 2);
1027        assert_eq!(
1028            result.components.first().unwrap(),
1029            &Component {
1030                label: "SE/30".to_string(),
1031                coordinates: (0.9, 0.5),
1032                shape: Shape::Circle
1033            }
1034        );
1035        assert_eq!(
1036            result.components.get(1).unwrap(),
1037            &Component {
1038                label: "Performa".to_string(),
1039                coordinates: (0.8, 0.4),
1040                shape: Shape::X
1041            }
1042        );
1043    }
1044
1045    #[test]
1046    fn should_ignore_empty_lines() {
1047        let source = "The Void (0.9, 0.5)
1048
1049The Abyss (0.8, 0.4)";
1050        let result = parse(source);
1051        assert_eq!(result.components.len(), 2);
1052        assert_eq!(
1053            result.components.first().unwrap(),
1054            &Component {
1055                label: "The Void".to_string(),
1056                coordinates: (0.9, 0.5),
1057                shape: Shape::Circle
1058            }
1059        );
1060        assert_eq!(
1061            result.components.get(1).unwrap(),
1062            &Component {
1063                label: "The Abyss".to_string(),
1064                coordinates: (0.8, 0.4),
1065                shape: Shape::Circle
1066            }
1067        );
1068    }
1069
1070    #[test]
1071    fn should_handle_incomplete_notes() {
1072        let source = "[Note]";
1073        let result = parse(source);
1074        assert_eq!(result.components.len(), 0);
1075    }
1076
1077    #[test]
1078    fn should_handle_incomplete_groups() {
1079        let source = "[Group]";
1080        let result = parse(source);
1081        assert_eq!(result.components.len(), 0);
1082    }
1083
1084    #[test]
1085    fn should_handle_incomplete_nodes_with_no_coordinates() {
1086        let source = "I am a node? (";
1087        let result = parse(source);
1088        assert_eq!(result.components.len(), 0);
1089    }
1090
1091    #[test]
1092    fn should_handle_incomplete_nodes_with_incomplete_coordinates() {
1093        let source = "I am a node? (12";
1094        let result = parse(source);
1095        assert_eq!(result.components.len(), 0);
1096    }
1097
1098    #[test]
1099    fn should_handle_incomplete_nodes_with_missing_y_coordinates() {
1100        let source = "I am a node? (12,";
1101        let result = parse(source);
1102        assert_eq!(result.components.len(), 0);
1103    }
1104
1105    #[test]
1106    fn should_handle_incomplete_nodes_with_unclosed_coordinates() {
1107        let source = "I am a node? (12,21";
1108        let result = parse(source);
1109        assert_eq!(result.components.len(), 0);
1110    }
1111
1112    #[test]
1113    fn should_handle_incomplete_evolution_without_value() {
1114        let source = "[Evolution] I accidentally a +";
1115        let result = parse(source);
1116        assert_eq!(result.components.len(), 0);
1117    }
1118
1119    #[test]
1120    fn should_handle_incomplete_evolution_without_component() {
1121        let source = "[Evolution]+";
1122        let result = parse(source);
1123        assert_eq!(result.components.len(), 0);
1124    }
1125    #[test]
1126    fn should_handle_just_a_poarenthesis() {
1127        let source = "     (";
1128        let result = parse(source);
1129        assert_eq!(result.components.len(), 0);
1130    }
1131
1132    #[test]
1133    fn should_ignore_invalid_lines() {
1134        let source = "Hello (0.9, 0.5)
1135Oh No
1136It's OK (0.8, 0.4)
1137[ijole] NO IT ISN'T
1138A--
1139B->
1140(
1141Hello -> It's OK
1142[";
1143        let result = parse(source);
1144        assert_eq!(result.components.len(), 2);
1145        assert_eq!(
1146            result.components.first().unwrap(),
1147            &Component {
1148                label: "Hello".to_string(),
1149                coordinates: (0.9, 0.5),
1150                shape: Shape::Circle
1151            }
1152        );
1153        assert_eq!(
1154            result.components.get(1).unwrap(),
1155            &Component {
1156                label: "It's OK".to_string(),
1157                coordinates: (0.8, 0.4),
1158                shape: Shape::Circle
1159            }
1160        );
1161        assert_eq!(result.dependencies.len(), 1);
1162        assert_eq!(
1163            result.dependencies.first().unwrap(),
1164            &Dependency {
1165                from: "Hello".to_string(),
1166                to: "It's OK".to_string(),
1167                is_directed: true
1168            }
1169        );
1170    }
1171
1172    /* Edge Cases */
1173    #[test]
1174    fn should_handle_empty_input() {
1175        let source = "";
1176        let result = parse(source);
1177        assert_eq!(result.components.len(), 0);
1178    }
1179
1180    #[test]
1181    fn should_handle_only_whitespace() {
1182        let source = "   \n  \n  ";
1183        let result = parse(source);
1184        assert_eq!(result.components.len(), 0);
1185    }
1186
1187    #[test]
1188    fn only_line_breaks() {
1189        let source = "\n";
1190        let result = parse(source);
1191        assert_eq!(result.components.len(), 0);
1192    }
1193
1194    #[test]
1195    fn should_handle_only_invalid_lines() {
1196        let source = "invalid\nstill invalid";
1197        let result = parse(source);
1198        assert_eq!(result.components.len(), 0);
1199    }
1200
1201    #[test]
1202    fn should_handle_complex_component_labels() {
1203        let source = "My Sü'per Complex Label 123 (0.5, 0.5)";
1204        let result = parse(source);
1205        assert_eq!(result.components.len(), 1);
1206        assert_eq!(
1207            result.components.first().unwrap(),
1208            &Component {
1209                label: "My Sü'per Complex Label 123".to_string(),
1210                coordinates: (0.5, 0.5),
1211                shape: Shape::Circle
1212            }
1213        );
1214    }
1215
1216    /* Comprehensive Example */
1217    #[test]
1218    fn should_parse_a_complete_wardley_map() {
1219        let source = "[I] 0.25
1220[II] 0.5
1221[III] 0.75
1222[IV] 1.0
1223
1224Tea (0.9, 0.5) [Circle]
1225Cup (0.8, 0.4)
1226Kettle (0.7, 0.3) [Square]
1227Hot Water (0.6, 0.2)
1228
1229Tea -> Cup
1230Cup -> Kettle
1231Kettle -> Hot Water
1232
1233[Note] (0.5, 0.5) This is our tea supply chain
1234
1235[Group] Tea, Cup, Kettle
1236[Inertia] Kettle
1237[Evolution] Tea + 0.1";
1238
1239        let result = parse(source);
1240
1241        // 4 stages + 4 components + 3 dependencies + 1 note + 1 group + 1 inertia + 1 evolution
1242        assert_eq!(result.stages.len(), 4);
1243        assert_eq!(result.components.len(), 4);
1244        assert_eq!(result.dependencies.len(), 3);
1245        assert_eq!(result.notes.len(), 1);
1246        assert_eq!(result.groups.len(), 1);
1247        assert_eq!(result.inertias.len(), 1);
1248        assert_eq!(result.evolutions.len(), 1);
1249
1250        assert_eq!(
1251            result.stages.first().unwrap(),
1252            &StageData {
1253                stage: Stage::I,
1254                value: 0.25
1255            }
1256        );
1257        assert_eq!(
1258            result.stages.get(1).unwrap(),
1259            &StageData {
1260                stage: Stage::Ii,
1261                value: 0.5
1262            }
1263        );
1264        assert_eq!(
1265            result.stages.get(2).unwrap(),
1266            &StageData {
1267                stage: Stage::Iii,
1268                value: 0.75
1269            }
1270        );
1271        assert_eq!(
1272            result.stages.get(3).unwrap(),
1273            &StageData {
1274                stage: Stage::Iv,
1275                value: 1.0
1276            }
1277        );
1278        assert_eq!(
1279            result.components.first().unwrap(),
1280            &Component {
1281                label: "Tea".to_string(),
1282                coordinates: (0.9, 0.5),
1283                shape: Shape::Circle
1284            }
1285        );
1286        assert_eq!(
1287            result.components.get(1).unwrap(),
1288            &Component {
1289                label: "Cup".to_string(),
1290                coordinates: (0.8, 0.4),
1291                shape: Shape::Circle
1292            }
1293        );
1294        assert_eq!(
1295            result.components.get(2).unwrap(),
1296            &Component {
1297                label: "Kettle".to_string(),
1298                coordinates: (0.7, 0.3),
1299                shape: Shape::Square
1300            }
1301        );
1302        assert_eq!(
1303            result.components.get(3).unwrap(),
1304            &Component {
1305                label: "Hot Water".to_string(),
1306                coordinates: (0.6, 0.2),
1307                shape: Shape::Circle
1308            }
1309        );
1310        assert_eq!(
1311            result.dependencies.first().unwrap(),
1312            &Dependency {
1313                from: "Tea".to_string(),
1314                to: "Cup".to_string(),
1315                is_directed: true
1316            }
1317        );
1318        assert_eq!(
1319            result.dependencies.get(1).unwrap(),
1320            &Dependency {
1321                from: "Cup".to_string(),
1322                to: "Kettle".to_string(),
1323                is_directed: true
1324            }
1325        );
1326        assert_eq!(
1327            result.dependencies.get(2).unwrap(),
1328            &Dependency {
1329                from: "Kettle".to_string(),
1330                to: "Hot Water".to_string(),
1331                is_directed: true
1332            }
1333        );
1334        assert_eq!(
1335            result.notes.first().unwrap(),
1336            &Note {
1337                text: "This is our tea supply chain".to_string(),
1338                coordinates: (0.5, 0.5)
1339            }
1340        );
1341        assert_eq!(
1342            result.groups.first().unwrap().components,
1343            vec!["Tea".to_string(), "Cup".to_string(), "Kettle".to_string()]
1344        );
1345        assert_eq!(result.inertias.first().unwrap().component, "Kettle");
1346        assert_eq!(
1347            result.evolutions.first().unwrap(),
1348            &Evolution {
1349                component: "Tea".to_string(),
1350                value: 0.1
1351            }
1352        );
1353    }
1354}