1pub 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 #[serde(default)]
36 pub index: Vec<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub site: Option<SiteConfig>,
40}
41
42#[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
59fn 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 pub fn title(&self) -> Option<&str> {
92 self.title.as_deref().map(str::trim)
93 }
94
95 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 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 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 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
280fn 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
298pub(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
308pub(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
318pub(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 #[test]
356 fn thresholdが0から1の範囲内であれば妥当() {
357 assert!(valid_config().validate().is_ok());
358 }
359
360 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
488 fn excludeを省略したyamlは空になる() {
489 let config = Config::parse(BASE_YAML).unwrap();
490 assert!(config.exclude.is_empty());
491 }
492
493 #[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 #[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 #[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 #[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 #[test]
557 fn indexを省略すると空の一覧として扱われる() {
558 let config = Config::parse(BASE_YAML).unwrap();
559 assert!(config.index.is_empty());
560 }
561
562 #[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 #[test]
574 fn indexの要素が空なら設定エラーになる() {
575 assert!(with_index(&[""], "").is_err());
576 assert!(with_index(&[" "], "").is_err());
577 }
578
579 #[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 #[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 #[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 #[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 #[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 #[test]
626 fn indexに親ディレクトリや空セグメントがあると設定エラーになる() {
627 assert!(with_index(&["docs/../docs"], "").is_err());
628 assert!(with_index(&["docs//a"], "").is_err());
629 }
630
631 #[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 #[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 #[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 #[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}