1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
use crate::ErrorList;
use crate::FullContext;
use crate::Output;
use crate::Position;
use crate::Warning;
use crate::WarningKind;
use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// The content of a special passage with the name StoryData that contains a
/// JSON blob with various information about the story.
///
/// # JSON fields
/// Fields as defined by the Twee 3 specification:
/// * ifid - Rquired. String. Interactive Fiction IDentifier v4 UUID
/// * format - String. Maps to <tw-storydata format>.
/// * format-version - String. Maps to <tw-storydata format-version>.
/// * start - String. Maps to <tw-passagedata name> of the node whose pid matches <tw-storydata startnode>.
/// * tag-colors - Object of tag(string):color(string) pairs. Pairs map to <tw-tag> nodes as <tw-tag name>:<tw-tag color>.
/// * zoom - Decimal. Maps to <tw-storydata zoom>.
///
/// # Parse Errors
/// None
///
/// # Parse Warnings
/// * [`JsonError`] - Error encountered while parsing the JSON content
#[derive(Debug, Serialize, Deserialize)]
pub struct StoryData {
    /// Interactive Fiction IDentifier v4 UUID
    pub ifid: String,

    /// The story format
    pub format: Option<String>,

    /// The version of the story format
    #[serde(rename = "format-version")]
    pub format_version: Option<String>,

    /// The starting passage
    pub start: Option<String>,

    /// Map of tag name to color name for coloring tags
    #[serde(rename = "tag-colors")]
    pub tag_colors: Option<HashMap<String, String>>,

    /// Zoom level for editing in Twine
    pub zoom: Option<f32>,
}

impl StoryData {
    /// Parses a `StoryData` out of the given context
    pub fn parse(context: FullContext) -> Output<Result<Option<Self>, ErrorList>> {
        let mut warnings = Vec::new();
        let res: serde_json::Result<StoryData> = serde_json::from_str(context.get_contents());

        let story_data = if res.is_ok() {
            Some(res.ok().unwrap())
        } else {
            let err = res.err().unwrap();
            // Get the error part of error string generated by serde
            let err_string = format!("{}", err).split(" at ").next().unwrap().to_string();
            warnings.push(Warning::new(
                WarningKind::JsonError(err_string),
                Some(context.subcontext(
                    Position::rel(err.line(), err.column())
                        ..=Position::rel(err.line(), err.column()),
                )),
            ));
            None
        };
        Output::new(Ok(story_data)).with_warnings(warnings)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_example() {
        let input = r#"{
	"ifid": "D674C58C-DEFA-4F70-B7A2-27742230C0FC",
	"format": "SugarCube",
	"format-version": "2.28.2",
	"start": "My Starting Passage",
	"tag-colors": {
		"bar": "green",
		"foo": "red",
		"qaz": "blue"
	},
	"zoom": 0.25
}
"#
        .to_string();
        let out = StoryData::parse(FullContext::from(None, input));
        assert!(!out.has_warnings());
        let (res, _) = out.take();
        assert!(res.is_ok());
        let data = res.ok().unwrap();
        let expected = if let Some(story_data) = data {
            assert_eq!(story_data.ifid, "D674C58C-DEFA-4F70-B7A2-27742230C0FC");
            assert_eq!(story_data.format, Some("SugarCube".to_string()));
            assert_eq!(story_data.format_version, Some("2.28.2".to_string()));
            assert_eq!(story_data.start, Some("My Starting Passage".to_string()));
            assert_eq!(story_data.zoom, Some(0.25));

            let expected = if let Some(tag_colors) = story_data.tag_colors {
                assert_eq!(tag_colors["bar"], "green");
                assert_eq!(tag_colors["foo"], "red");
                assert_eq!(tag_colors["qaz"], "blue");

                true
            } else {
                false
            };
            assert!(expected);

            true
        } else {
            false
        };
        assert!(expected);
    }

    #[test]
    fn test_malformed() {
        let input = r#"{
	"ifid": "D674C58C-DEFA-4F70-B7A2-27742230C0FC",
	"format": "SugarCube",
	"format-version": "2.28.2",
	"start": "My Starting Passage",
	"tag-colors": {
"#
        .to_string();
        let out = StoryData::parse(FullContext::from(None, input));
        assert!(out.has_warnings());
        let (res, warnings) = out.take();
        assert!(res.is_ok());
        let data = res.ok().unwrap();
        assert!(data.is_none());
        assert_eq!(warnings.len(), 1);
        assert!(
            if let WarningKind::JsonError(_) = &warnings[0].kind {
                true
            } else {
                false
            }
        );
    }
}