Skip to main content

miwa/
config.rs

1//! `.miwa/config.yaml` and `.miwa/lock.json` paths, and project configuration.
2
3pub const CONFIG_PATH: &str = ".miwa/config.yaml";
4pub const LOCK_PATH: &str = ".miwa/lock.json";
5
6#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
7pub struct JudgeConfig {
8    pub threshold: f64,
9    #[serde(default = "default_max_body_bytes")]
10    pub max_body_bytes: usize,
11}
12
13fn default_max_body_bytes() -> usize {
14    crate::judge::DEFAULT_MAX_BODY_BYTES
15}
16
17#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
18pub struct RustConfig {
19    #[serde(default)]
20    pub test_attributes: Vec<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
24pub struct Config {
25    pub docs: Vec<String>,
26    pub requirements: Vec<String>,
27    pub tests: Vec<String>,
28    pub diff_base: String,
29    pub judge: JudgeConfig,
30    #[serde(default)]
31    pub rust: RustConfig,
32    #[serde(default)]
33    pub exclude: Vec<String>,
34    /// Index roots (directories whose `index.md` lists the documents below).
35    #[serde(default)]
36    pub index: Vec<String>,
37    /// Settings for `miwa site`.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub site: Option<SiteConfig>,
40}
41
42/// `site` section of `.miwa/config.yaml`.
43#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
44pub struct SiteConfig {
45    #[serde(
46        default,
47        deserialize_with = "deserialize_site_title",
48        skip_serializing_if = "Option::is_none"
49    )]
50    pub title: Option<String>,
51    #[serde(
52        default,
53        deserialize_with = "deserialize_site_description",
54        skip_serializing_if = "Option::is_none"
55    )]
56    pub description: Option<String>,
57}
58
59/// `null` and an absent key are "unspecified"; anything but a string is an
60/// error (YAML numbers and booleans must not be coerced to strings).
61fn deserialize_site_string<'de, D>(d: D, field: &str) -> Result<Option<String>, D::Error>
62where
63    D: serde::Deserializer<'de>,
64{
65    use serde::Deserialize;
66    match Option::<serde_yaml_ng::Value>::deserialize(d)? {
67        None => Ok(None),
68        Some(serde_yaml_ng::Value::String(s)) => Ok(Some(s)),
69        Some(_) => Err(serde::de::Error::custom(format!(
70            "site.{field} must be a string"
71        ))),
72    }
73}
74
75fn deserialize_site_title<'de, D>(d: D) -> Result<Option<String>, D::Error>
76where
77    D: serde::Deserializer<'de>,
78{
79    deserialize_site_string(d, "title")
80}
81
82fn deserialize_site_description<'de, D>(d: D) -> Result<Option<String>, D::Error>
83where
84    D: serde::Deserializer<'de>,
85{
86    deserialize_site_string(d, "description")
87}
88
89impl SiteConfig {
90    /// `title` with surrounding whitespace removed.
91    pub fn title(&self) -> Option<&str> {
92        self.title.as_deref().map(str::trim)
93    }
94
95    /// `description` with surrounding whitespace removed.
96    pub fn description(&self) -> Option<&str> {
97        self.description.as_deref().map(str::trim)
98    }
99
100    fn validate(&self) -> anyhow::Result<()> {
101        for (field, value) in [("title", &self.title), ("description", &self.description)] {
102            let Some(v) = value else { continue };
103            let v = v.trim();
104            if v.is_empty() {
105                anyhow::bail!("site.{field} must not be empty");
106            }
107            if v.contains('\n') || v.contains('\r') {
108                anyhow::bail!("site.{field} must be a single line");
109            }
110        }
111        Ok(())
112    }
113}
114
115impl Config {
116    /// docs=["docs"], requirements=["req"], tests=["."], diff_base="main"
117    pub fn default_config() -> Config {
118        Config {
119            docs: vec!["docs".to_string()],
120            requirements: vec!["req".to_string()],
121            tests: vec![".".to_string()],
122            diff_base: "main".to_string(),
123            judge: JudgeConfig {
124                threshold: 0.9,
125                max_body_bytes: crate::judge::DEFAULT_MAX_BODY_BYTES,
126            },
127            rust: RustConfig::default(),
128            exclude: vec![],
129            index: vec!["docs".to_string()],
130            site: None,
131        }
132    }
133
134    /// Parse `yaml` into a `Config` and validate it.
135    pub fn parse(yaml: &str) -> anyhow::Result<Config> {
136        let config: Config = serde_yaml_ng::from_str(yaml)?;
137        config.validate()?;
138        Ok(config)
139    }
140
141    pub fn to_yaml(&self) -> String {
142        serde_yaml_ng::to_string(self).expect("Config serializes to yaml")
143    }
144
145    /// `index` rules: each entry must be non-empty after normalization, equal to
146    /// or under a `docs` / `requirements` entry, not contain an excluded
147    /// directory name (`exclude` or the default exclusions) as a segment, and no
148    /// two entries may be equal or nested in each other.
149    ///
150    /// `threshold` must be finite and within `0..=1`; each `Vec` must be
151    /// non-empty; `diff_base` must be non-empty; each `rust.test_attributes`
152    /// entry must be a non-empty `[A-Za-z0-9_]+` string; each `exclude` entry must
153    /// be a bare directory name (no whitespace, separators, globs, `.`/`..`); and no
154    /// `docs`/`requirements`/`tests` entry may lie under an excluded directory
155    /// (`exclude` or the default exclusions).
156    pub fn validate(&self) -> anyhow::Result<()> {
157        if !self.judge.threshold.is_finite() || !(0.0..=1.0).contains(&self.judge.threshold) {
158            anyhow::bail!(
159                "judge.threshold must be within 0..=1, got {}",
160                self.judge.threshold
161            );
162        }
163        if self.judge.max_body_bytes < 1 {
164            anyhow::bail!(
165                "judge.max_body_bytes must be >= 1, got {}",
166                self.judge.max_body_bytes
167            );
168        }
169        if self.docs.is_empty() {
170            anyhow::bail!("docs must not be empty");
171        }
172        if self.requirements.is_empty() {
173            anyhow::bail!("requirements must not be empty");
174        }
175        if self.tests.is_empty() {
176            anyhow::bail!("tests must not be empty");
177        }
178        if self.diff_base.is_empty() {
179            anyhow::bail!("diff_base must not be empty");
180        }
181        for attr in &self.rust.test_attributes {
182            if attr.is_empty() || !attr.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
183                anyhow::bail!(
184                    "rust.test_attributes entries must be the last path segment only (e.g. `rstest`, not `rstest::rstest`): got `{attr}`"
185                );
186            }
187        }
188        for e in &self.exclude {
189            if e.trim().is_empty()
190                || e.chars()
191                    .any(|c| c.is_whitespace() || matches!(c, '/' | '\\' | '*' | '?' | '['))
192                || e == "."
193                || e == ".."
194            {
195                anyhow::bail!(
196                    "exclude entries must be a directory name only (no paths or globs): got `{e}`"
197                );
198            }
199        }
200        for (field, entries) in [
201            ("docs", &self.docs),
202            ("requirements", &self.requirements),
203            ("tests", &self.tests),
204        ] {
205            for p in entries {
206                check_path_segments(field, p)?;
207                for seg in p
208                    .split('/')
209                    .map(str::trim)
210                    .filter(|s| !s.is_empty() && *s != ".")
211                {
212                    let hit = self
213                        .exclude
214                        .iter()
215                        .map(|s| s.as_str())
216                        .chain(crate::source::DEFAULT_EXCLUDED_DIRS)
217                        .find(|name| *name == seg);
218                    if let Some(name) = hit {
219                        anyhow::bail!(
220                            "{field} entry `{p}` is under an excluded directory `{name}`"
221                        );
222                    }
223                }
224            }
225        }
226        self.validate_index()?;
227        if let Some(site) = &self.site {
228            site.validate()?;
229        }
230        Ok(())
231    }
232
233    fn validate_index(&self) -> anyhow::Result<()> {
234        let normalized: Vec<String> = self.index.iter().map(|e| normalize_dir(e)).collect();
235        for (raw, n) in self.index.iter().zip(&normalized) {
236            if raw.trim().is_empty() {
237                anyhow::bail!("index entries must not be empty");
238            }
239            if n == "." {
240                anyhow::bail!("index entry `{raw}` must not be the whole repository (`.`)");
241            }
242            check_path_segments("index", raw)?;
243            let in_scope = self
244                .docs
245                .iter()
246                .chain(self.requirements.iter())
247                .any(|d| is_under(n, &normalize_dir(d)));
248            if !in_scope {
249                anyhow::bail!(
250                    "index entry `{raw}` must be equal to or under a docs / requirements entry"
251                );
252            }
253            for seg in n.split('/').filter(|s| !s.is_empty() && *s != ".") {
254                let hit = self
255                    .exclude
256                    .iter()
257                    .map(|s| s.as_str())
258                    .chain(crate::source::DEFAULT_EXCLUDED_DIRS)
259                    .find(|name| *name == seg);
260                if let Some(name) = hit {
261                    anyhow::bail!("index entry `{raw}` is under an excluded directory `{name}`");
262                }
263            }
264        }
265        for (i, a) in normalized.iter().enumerate() {
266            for (j, b) in normalized.iter().enumerate() {
267                if i != j && is_under(a, b) {
268                    anyhow::bail!(
269                        "index entries `{}` and `{}` must not be equal or nested",
270                        self.index[i],
271                        self.index[j]
272                    );
273                }
274            }
275        }
276        Ok(())
277    }
278}
279
280/// Rejects configured directory entries containing `..` or empty (`//`)
281/// segments after normalization. `.` (the whole repo) is not checked here.
282fn check_path_segments(field: &str, raw: &str) -> anyhow::Result<()> {
283    let n = normalize_dir(raw);
284    if n == "." {
285        return Ok(());
286    }
287    for seg in n.split('/') {
288        if seg.is_empty() {
289            anyhow::bail!("{field} entry `{raw}` must not contain empty path segments");
290        }
291        if seg == ".." {
292            anyhow::bail!("{field} entry `{raw}` must not contain `..`");
293        }
294    }
295    Ok(())
296}
297
298/// Joins a normalized directory `root` and a relative path (`.` is the repo
299/// root, so the result has no `./` prefix).
300pub(crate) fn join_under(root: &str, rel: &str) -> String {
301    if root == "." || root.is_empty() {
302        rel.to_string()
303    } else {
304        format!("{root}/{rel}")
305    }
306}
307
308/// Whether `path` is `dir` or lies under it. `dir` must be normalized by
309/// [`normalize_dir`]; `.` (the whole repo) contains everything.
310pub(crate) fn is_under(path: &str, dir: &str) -> bool {
311    dir == "."
312        || path == dir
313        || path
314            .strip_prefix(dir)
315            .is_some_and(|rest| rest.starts_with('/'))
316}
317
318/// Normalizes a configured directory entry: strips a leading `./`, strips a
319/// trailing `/`, and maps `.` / `./` / `` (empty) to `.` (the whole repo).
320pub(crate) fn normalize_dir(d: &str) -> String {
321    let mut s = d.trim();
322    while let Some(rest) = s.strip_prefix("./") {
323        s = rest;
324    }
325    let s = s.trim_end_matches('/');
326    if s.is_empty() {
327        ".".to_string()
328    } else {
329        s.to_string()
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn valid_config() -> Config {
338        Config {
339            docs: vec!["docs".to_string()],
340            requirements: vec!["req".to_string()],
341            tests: vec![".".to_string()],
342            diff_base: "main".to_string(),
343            judge: JudgeConfig {
344                threshold: 0.9,
345                max_body_bytes: 200_000,
346            },
347            rust: RustConfig::default(),
348            exclude: vec![],
349            index: vec![],
350            site: None,
351        }
352    }
353
354    // [FR-020]
355    #[test]
356    fn thresholdが0から1の範囲内であれば妥当() {
357        assert!(valid_config().validate().is_ok());
358    }
359
360    // [FR-020]
361    #[test]
362    fn thresholdが範囲外なら設定エラーになる() {
363        let mut config = valid_config();
364        config.judge.threshold = 1.5;
365        assert!(config.validate().is_err());
366
367        let mut config = valid_config();
368        config.judge.threshold = -0.1;
369        assert!(config.validate().is_err());
370    }
371
372    // [FR-020]
373    #[test]
374    fn docs_requirements_testsのいずれかが空なら設定エラーになる() {
375        let mut config = valid_config();
376        config.docs = vec![];
377        assert!(config.validate().is_err());
378
379        let mut config = valid_config();
380        config.requirements = vec![];
381        assert!(config.validate().is_err());
382
383        let mut config = valid_config();
384        config.tests = vec![];
385        assert!(config.validate().is_err());
386    }
387
388    // [FR-019]
389    #[test]
390    fn 既定値のthresholdは0_9で妥当な設定である() {
391        let config = Config::default_config();
392        assert_eq!(config.judge.threshold, 0.9);
393        assert!(config.validate().is_ok());
394    }
395
396    // [FR-020]
397    #[test]
398    fn diff_baseが空文字列なら設定エラーになる() {
399        let mut config = valid_config();
400        config.diff_base = String::new();
401        assert!(config.validate().is_err());
402    }
403
404    // [FR-019]
405    #[test]
406    fn 既定値のmax_body_bytesは200000で妥当な設定である() {
407        let config = Config::default_config();
408        assert_eq!(config.judge.max_body_bytes, 200_000);
409        assert!(config.validate().is_ok());
410    }
411
412    // [FR-020]
413    #[test]
414    fn max_body_bytesが0なら設定エラーになる() {
415        let mut config = valid_config();
416        config.judge.max_body_bytes = 0;
417        assert!(config.validate().is_err());
418        config.judge.max_body_bytes = 1;
419        assert!(config.validate().is_ok());
420    }
421
422    // [FR-019]
423    #[test]
424    fn max_body_bytesを省略したyamlは既定値で読める() {
425        let yaml = "docs:\n  - docs\nrequirements:\n  - req\ntests:\n  - .\ndiff_base: main\njudge:\n  threshold: 0.9\n";
426        let config = Config::parse(yaml).unwrap();
427        assert_eq!(config.judge.max_body_bytes, 200_000);
428    }
429
430    // [FR-020]
431    #[test]
432    fn max_body_bytesが整数以外のyamlはparseエラーになる() {
433        let base = "docs:\n  - docs\nrequirements:\n  - req\ntests:\n  - .\ndiff_base: main\njudge:\n  threshold: 0.9\n";
434        for bad in ["1.5", "\"abc\"", "0", "-1"] {
435            let yaml = format!("{base}  max_body_bytes: {bad}\n");
436            assert!(Config::parse(&yaml).is_err(), "{bad}");
437        }
438        let yaml = format!("{base}  max_body_bytes: 1\n");
439        assert_eq!(Config::parse(&yaml).unwrap().judge.max_body_bytes, 1);
440    }
441
442    const BASE_YAML: &str = "docs:\n  - docs\nrequirements:\n  - req\ntests:\n  - .\ndiff_base: main\njudge:\n  threshold: 0.9\n";
443
444    // [FR-058]
445    #[test]
446    fn rustキーを省略_null_空マップのいずれでもtest_attributesは空になる() {
447        for extra in ["", "rust:\n", "rust: {}\n", "rust:\n  test_attributes:\n"] {
448            let yaml = format!("{BASE_YAML}{extra}");
449            let config = Config::parse(&yaml).unwrap_or_else(|e| panic!("{extra:?}: {e}"));
450            assert!(config.rust.test_attributes.is_empty(), "{extra:?}");
451        }
452    }
453
454    // [FR-058]
455    #[test]
456    fn rustのtest_attributesを指定するとそのまま読める() {
457        let yaml = format!("{BASE_YAML}rust:\n  test_attributes: [\"rstest\", \"test_case_2\"]\n");
458        let config = Config::parse(&yaml).unwrap();
459        assert_eq!(
460            config.rust.test_attributes,
461            vec!["rstest".to_string(), "test_case_2".to_string()]
462        );
463    }
464
465    // [FR-058]
466    #[test]
467    fn rustのtest_attributesにパス区切りや空文字があるとparseエラーになる() {
468        for bad in ["\"rs::test\"", "\"\"", "\"my-attr\"", "\"a b\""] {
469            let yaml = format!("{BASE_YAML}rust:\n  test_attributes: [{bad}]\n");
470            let err = Config::parse(&yaml).expect_err(bad);
471            assert!(err.to_string().contains("segment"), "{bad}: {err}");
472        }
473    }
474
475    // [FR-019]
476    #[test]
477    fn 既定のconfigのyamlはrustのtest_attributesを空配列で含む() {
478        let yaml = Config::default_config().to_yaml();
479        let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml).unwrap();
480        let attrs = value["rust"]["test_attributes"]
481            .as_sequence()
482            .expect("sequence");
483        assert!(attrs.is_empty());
484    }
485
486    // [FR-065]
487    #[test]
488    fn excludeを省略したyamlは空になる() {
489        let config = Config::parse(BASE_YAML).unwrap();
490        assert!(config.exclude.is_empty());
491    }
492
493    // [FR-065]
494    #[test]
495    fn excludeを指定するとそのまま読める() {
496        let yaml = format!("{BASE_YAML}exclude: [\".next\", \"build\"]\n");
497        let config = Config::parse(&yaml).unwrap();
498        assert_eq!(
499            config.exclude,
500            vec![".next".to_string(), "build".to_string()]
501        );
502    }
503
504    // [FR-065]
505    #[test]
506    fn excludeにディレクトリ名として不正な値があるとparseエラーになる() {
507        for bad in [
508            "\" \"",
509            "\"a b\"",
510            "\"a/b\"",
511            "\"a\\\\b\"",
512            "\".\"",
513            "\"..\"",
514            "\"*.js\"",
515            "\"a?\"",
516            "\"a[b]\"",
517        ] {
518            let yaml = format!("{BASE_YAML}exclude: [{bad}]\n");
519            let err = Config::parse(&yaml).expect_err(bad);
520            assert!(err.to_string().contains("directory name"), "{bad}: {err}");
521        }
522    }
523
524    // [FR-065]
525    #[test]
526    fn docsやrequirementsやtestsのパスが除外名を含むと設定エラーになる() {
527        let yaml = "docs:\n  - docs\nrequirements:\n  - req\ntests:\n  - build/tests\ndiff_base: main\njudge:\n  threshold: 0.9\nexclude: [build]\n";
528        let err = Config::parse(yaml).unwrap_err();
529        assert!(err.to_string().contains("excluded directory"), "{err}");
530
531        let yaml = "docs:\n  - dist\nrequirements:\n  - req\ntests:\n  - .\ndiff_base: main\njudge:\n  threshold: 0.9\n";
532        let err = Config::parse(yaml).unwrap_err();
533        assert!(err.to_string().contains("excluded directory"), "{err}");
534
535        let yaml = "docs:\n  - docs\nrequirements:\n  - req\ntests:\n  - \" dist\"\ndiff_base: main\njudge:\n  threshold: 0.9\n";
536        let err = Config::parse(yaml).unwrap_err();
537        assert!(err.to_string().contains("excluded directory"), "{err}");
538    }
539
540    // [FR-019]
541    #[test]
542    fn 既定のconfigのyamlはexcludeを空配列で含む() {
543        let yaml = Config::default_config().to_yaml();
544        let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&yaml).unwrap();
545        let exclude = value["exclude"].as_sequence().expect("sequence");
546        assert!(exclude.is_empty());
547    }
548
549    fn with_index(index: &[&str], extra: &str) -> anyhow::Result<Config> {
550        let list: String = index.iter().map(|e| format!("  - \"{e}\"\n")).collect();
551        let yaml = format!("{BASE_YAML}{extra}index:\n{list}");
552        Config::parse(&yaml)
553    }
554
555    // [FR-066]
556    #[test]
557    fn indexを省略すると空の一覧として扱われる() {
558        let config = Config::parse(BASE_YAML).unwrap();
559        assert!(config.index.is_empty());
560    }
561
562    // [FR-066]
563    #[test]
564    fn indexはdocsかrequirements配下なら正規化されて受理される() {
565        assert!(with_index(&["docs"], "").is_ok());
566        assert!(with_index(&["req"], "").is_ok());
567        assert!(with_index(&["./docs/guide/"], "").is_ok());
568        assert!(with_index(&["docs", "req/sub"], "").is_ok());
569        assert!(with_index(&["docs/a", "docs/b"], "").is_ok());
570    }
571
572    // [FR-066]
573    #[test]
574    fn indexの要素が空なら設定エラーになる() {
575        assert!(with_index(&[""], "").is_err());
576        assert!(with_index(&["  "], "").is_err());
577    }
578
579    // [FR-066]
580    #[test]
581    fn indexがdocsでもrequirementsでもない場所なら設定エラーになる() {
582        assert!(with_index(&["other"], "").is_err());
583        assert!(with_index(&["docsx"], "").is_err());
584        assert!(with_index(&["."], "").is_err());
585        assert!(with_index(&["src/docs"], "").is_err());
586    }
587
588    // [FR-066]
589    #[test]
590    fn indexが除外名のセグメントを含むと設定エラーになる() {
591        assert!(with_index(&["docs/node_modules"], "").is_err());
592        assert!(with_index(&["docs/vendor/x"], "exclude: [vendor]\n").is_err());
593        assert!(with_index(&["docs/vendor"], "").is_ok());
594    }
595
596    // [FR-066]
597    #[test]
598    fn index同士が同一または包含関係なら設定エラーになる() {
599        assert!(with_index(&["docs", "docs"], "").is_err());
600        assert!(with_index(&["docs", "docs/a"], "").is_err());
601        assert!(with_index(&["docs/a", "docs"], "").is_err());
602        assert!(with_index(&["docs/", "./docs"], "").is_err());
603    }
604
605    // [FR-019]
606    #[test]
607    fn 既定のconfigのindexはdocsを1件含む() {
608        let config = Config::default_config();
609        assert_eq!(config.index, vec!["docs".to_string()]);
610        assert!(config.validate().is_ok());
611    }
612
613    // [FR-066]
614    #[test]
615    fn indexが正規化後にリポジトリ全体_ドットになると設定エラーになる() {
616        for e in [".", "./", "././"] {
617            let yaml = format!(
618                "docs:\n  - .\nrequirements:\n  - req\ntests:\n  - .\ndiff_base: main\njudge:\n  threshold: 0.9\nindex:\n  - \"{e}\"\n"
619            );
620            assert!(Config::parse(&yaml).is_err(), "{e}");
621        }
622    }
623
624    // [FR-066]
625    #[test]
626    fn indexに親ディレクトリや空セグメントがあると設定エラーになる() {
627        assert!(with_index(&["docs/../docs"], "").is_err());
628        assert!(with_index(&["docs//a"], "").is_err());
629    }
630
631    // [FR-066]
632    #[test]
633    fn docsとrequirementsとtestsに親ディレクトリや空セグメントがあると設定エラーになる() {
634        for field in ["docs", "requirements", "tests"] {
635            for bad in ["a/../b", "a//b"] {
636                let mut yaml = String::new();
637                for f in ["docs", "requirements", "tests"] {
638                    let v = if f == field { bad } else { "ok" };
639                    yaml.push_str(&format!("{f}:\n  - \"{v}\"\n"));
640                }
641                yaml.push_str("diff_base: main\njudge:\n  threshold: 0.9\n");
642                assert!(Config::parse(&yaml).is_err(), "{field}: {bad}");
643            }
644        }
645    }
646
647    fn site_yaml(site: &str) -> String {
648        format!("{BASE_YAML}site:\n{site}")
649    }
650
651    // [FR-094]
652    #[test]
653    fn siteの文字列でない値はフィールド名つきの設定エラーになる() {
654        for field in ["title", "description"] {
655            for bad in ["1", "true", "[a]", "{a: b}"] {
656                let err = Config::parse(&site_yaml(&format!("  {field}: {bad}\n")))
657                    .unwrap_err()
658                    .to_string();
659                assert!(
660                    err.contains(&format!("site.{field}")),
661                    "{field} {bad}: {err}"
662                );
663            }
664        }
665    }
666
667    // [FR-094]
668    #[test]
669    fn siteのnullと空の節は未指定になる() {
670        for site in ["  title: null\n  description: ~\n", "  {}\n"] {
671            let c = Config::parse(&site_yaml(site)).unwrap();
672            let s = c.site.unwrap();
673            assert_eq!(s.title(), None, "{site}");
674            assert_eq!(s.description(), None, "{site}");
675        }
676        assert!(Config::parse(&site_yaml("  null\n"))
677            .unwrap()
678            .site
679            .is_none());
680    }
681
682    // [FR-094]
683    #[test]
684    fn siteの値は前後の空白が除かれ往復しても変わらない() {
685        let c = Config::parse(&site_yaml(
686            "  title: \"  題名  \"\n  description: \" 要約 \"\n",
687        ))
688        .unwrap();
689        let s = c.site.as_ref().unwrap();
690        assert_eq!(s.title(), Some("題名"));
691        assert_eq!(s.description(), Some("要約"));
692        let again = Config::parse(&c.to_yaml()).unwrap();
693        let s2 = again.site.as_ref().unwrap();
694        assert_eq!(s2.title(), Some("題名"));
695        assert_eq!(s2.description(), Some("要約"));
696    }
697}