Skip to main content

miwa/
graph.rs

1//! Build the doc/req/test dependency graph from a `Source`.
2
3pub fn build_graph(
4    source: &dyn crate::source::Source,
5    config: &crate::config::Config,
6) -> anyhow::Result<crate::model::Graph> {
7    use crate::model::{
8        desc_edge_id, edge_id, is_valid_id, Broken, Edge, EdgeKind, Invalid, Node, NodeKey,
9        NodeKind, ReasonCode, TargetKind,
10    };
11    use std::collections::{BTreeMap, BTreeSet};
12
13    let mut graph = crate::model::Graph::default();
14    let files: Vec<String> = source
15        .list_files()?
16        .into_iter()
17        .filter(|path| !crate::source::is_under_dir_named(path, &config.exclude))
18        .collect();
19    let file_set: std::collections::HashSet<&str> = files.iter().map(|s| s.as_str()).collect();
20
21    struct Candidate {
22        path: String,
23        kind: NodeKind,
24        id: Option<String>,
25        body: String,
26        description: Option<String>,
27    }
28    let mut candidates: Vec<Candidate> = Vec::new();
29    let mut texts: BTreeMap<String, crate::index::ParsedText> = BTreeMap::new();
30    let index_roots: Vec<String> = config
31        .index
32        .iter()
33        .map(|r| crate::config::normalize_dir(r))
34        .collect();
35
36    for path in &files {
37        if !path.ends_with(".md") {
38            continue;
39        }
40        if !(is_under_any(path, &config.docs) || is_under_any(path, &config.requirements)) {
41            continue;
42        }
43        let bytes = source.read(path)?;
44        let text = String::from_utf8_lossy(&bytes).into_owned();
45        let parsed = crate::frontmatter::parse(&text);
46        // Reserved names never become nodes (the index still reads their bodies).
47        if !matches!(path.rsplit('/').next(), Some("index.md" | "log.md")) {
48            match &parsed {
49                Ok(Some(fm)) => {
50                    let kind = match fm.kind.as_deref() {
51                        Some("BusinessNeed") => Some(NodeKind::Doc),
52                        Some("Requirement") => Some(NodeKind::Req),
53                        _ => None,
54                    };
55                    if let Some(kind) = kind {
56                        candidates.push(Candidate {
57                            path: path.clone(),
58                            kind,
59                            id: fm.id.clone(),
60                            body: fm.body.clone(),
61                            description: crate::index::normalize_description(fm),
62                        });
63                    }
64                }
65                Ok(None) => {}
66                Err(_e) => {
67                    graph.invalid.push(Invalid {
68                        target_kind: TargetKind::File,
69                        target: path.clone(),
70                        reason: ReasonCode::ParseError,
71                        detail: format!("invalid front matter in {path}"),
72                    });
73                }
74            }
75        }
76        // Bodies are only kept for files under an index root.
77        if index_roots.iter().any(|r| crate::config::is_under(path, r)) {
78            texts.insert(
79                path.clone(),
80                crate::index::ParsedText {
81                    text,
82                    front_matter: parsed,
83                },
84            );
85        }
86    }
87
88    let mut by_kind_id: BTreeMap<(NodeKind, String), Vec<usize>> = BTreeMap::new();
89    for (idx, c) in candidates.iter().enumerate() {
90        let target_kind = if c.kind == NodeKind::Doc {
91            TargetKind::Doc
92        } else {
93            TargetKind::Req
94        };
95        let id = match &c.id {
96            Some(id) if !id.is_empty() => id.clone(),
97            _ => {
98                graph.invalid.push(Invalid {
99                    target_kind,
100                    target: c.path.clone(),
101                    reason: ReasonCode::MissingId,
102                    detail: format!("{} has no id", c.path),
103                });
104                continue;
105            }
106        };
107        if !is_valid_id(&id) {
108            graph.invalid.push(Invalid {
109                target_kind,
110                target: id.clone(),
111                reason: ReasonCode::InvalidId,
112                detail: format!("{} has invalid id {id}", c.path),
113            });
114            continue;
115        }
116        by_kind_id.entry((c.kind, id)).or_default().push(idx);
117    }
118
119    let mut path_to_req_id: BTreeMap<String, String> = BTreeMap::new();
120
121    for ((kind, id), idxs) in by_kind_id {
122        if idxs.len() > 1 {
123            let target_kind = if kind == NodeKind::Doc {
124                TargetKind::Doc
125            } else {
126                TargetKind::Req
127            };
128            for &idx in &idxs {
129                graph.invalid.push(Invalid {
130                    target_kind,
131                    target: id.clone(),
132                    reason: ReasonCode::DuplicateId,
133                    detail: format!("duplicate id {id} at {}", candidates[idx].path),
134                });
135            }
136            continue;
137        }
138        let c = &candidates[idxs[0]];
139        let key = NodeKey {
140            kind,
141            id: id.clone(),
142        };
143        let hash = crate::hash::content_hash(&c.body);
144        graph.nodes.insert(
145            key.clone(),
146            Node {
147                key,
148                path: c.path.clone(),
149                content: c.body.clone(),
150                hash,
151                description: c.description.clone(),
152            },
153        );
154        if kind == NodeKind::Req {
155            path_to_req_id.insert(c.path.clone(), id.clone());
156        }
157    }
158
159    // `desc:doc:<id>` / `desc:req:<id>` self-loop edges for Doc / Req nodes under an index root.
160    let desc_edges: Vec<Edge> = graph
161        .nodes
162        .values()
163        .filter(|n| matches!(n.key.kind, NodeKind::Doc | NodeKind::Req))
164        .filter(|n| {
165            index_roots
166                .iter()
167                .any(|r| crate::config::is_under(&n.path, r))
168        })
169        .map(|n| Edge {
170            id: desc_edge_id(&n.key),
171            kind: EdgeKind::Desc,
172            from: n.key.clone(),
173            to: n.key.clone(),
174        })
175        .collect();
176    for e in desc_edges {
177        graph.edges.insert(e.id.clone(), e);
178    }
179    let doc_entries: Vec<(NodeKey, String, String)> = graph
180        .nodes
181        .values()
182        .filter(|n| n.key.kind == NodeKind::Doc)
183        .map(|n| (n.key.clone(), n.path.clone(), n.content.clone()))
184        .collect();
185    for (doc_key, doc_path, body) in doc_entries {
186        let links = crate::markdown::extract_links(&body);
187        for link in links {
188            let Some(resolved) = crate::markdown::resolve_link(&doc_path, &link) else {
189                continue;
190            };
191            if !is_under_any(&resolved, &config.requirements) {
192                continue;
193            }
194            if let Some(req_id) = path_to_req_id.get(&resolved) {
195                let id = edge_id(EdgeKind::DocReq, &doc_key.id, req_id);
196                let req_key = NodeKey {
197                    kind: NodeKind::Req,
198                    id: req_id.clone(),
199                };
200                graph.edges.insert(
201                    id.clone(),
202                    Edge {
203                        id,
204                        kind: EdgeKind::DocReq,
205                        from: doc_key.clone(),
206                        to: req_key,
207                    },
208                );
209            } else if file_set.contains(resolved.as_str()) {
210                graph.broken.push(Broken {
211                    from: doc_key.clone(),
212                    target: resolved.clone(),
213                    reason: ReasonCode::NotReqFile,
214                    detail: format!("{resolved} is not a requirement file"),
215                });
216            } else {
217                graph.broken.push(Broken {
218                    from: doc_key.clone(),
219                    target: resolved.clone(),
220                    reason: ReasonCode::MissingReq,
221                    detail: format!("{resolved} does not exist"),
222                });
223            }
224        }
225    }
226
227    let mut extracted_tests: Vec<(String, crate::extract::ExtractedTest)> = Vec::new();
228    for path in &files {
229        if !is_under_any(path, &config.tests) {
230            continue;
231        }
232        let Some(language) = test_language(path) else {
233            continue;
234        };
235        let bytes = source.read(path)?;
236        let text = String::from_utf8_lossy(&bytes).into_owned();
237        let result = match language {
238            TestLanguage::Go => crate::extract::extract_go_tests(path, &text),
239            TestLanguage::Rust => {
240                crate::extract::extract_rust_tests(path, &text, &config.rust.test_attributes)
241            }
242            TestLanguage::CSharp => crate::extract::extract_csharp_tests(path, &text),
243            TestLanguage::Java => crate::extract::extract_java_tests(path, &text),
244            TestLanguage::Kotlin => crate::extract::extract_kotlin_tests(path, &text),
245            TestLanguage::TypeScript => crate::extract::extract_typescript_tests(path, &text),
246            TestLanguage::Dart => crate::extract::extract_dart_tests(path, &text),
247        };
248        match result {
249            Ok(tests) => {
250                extracted_tests.extend(
251                    tests
252                        .into_iter()
253                        .filter(|t| !t.markers.is_empty())
254                        .map(|t| (path.clone(), t)),
255                );
256            }
257            Err(_e) => {
258                graph.invalid.push(Invalid {
259                    target_kind: TargetKind::File,
260                    target: path.clone(),
261                    reason: ReasonCode::ParseError,
262                    detail: format!("failed to parse {path}"),
263                });
264            }
265        }
266    }
267
268    // Group marked tests by identifier. Occurrences with several markers are
269    // invalid and excluded; the rest are merged into one test when they all
270    // point at the same req (FR-057).
271    let mut by_test_id: BTreeMap<String, Vec<(String, crate::extract::ExtractedTest)>> =
272        BTreeMap::new();
273    for (path, t) in extracted_tests {
274        if t.markers.len() > 1 {
275            graph.invalid.push(Invalid {
276                target_kind: TargetKind::Test,
277                target: t.id.clone(),
278                reason: ReasonCode::MultipleMarkers,
279                detail: format!("{} has multiple markers: {:?}", t.id, t.markers),
280            });
281            continue;
282        }
283        by_test_id.entry(t.id.clone()).or_default().push((path, t));
284    }
285
286    for (test_id, occurrences) in by_test_id {
287        let req_ids: std::collections::BTreeSet<&String> =
288            occurrences.iter().map(|(_, t)| &t.markers[0]).collect();
289        if req_ids.len() > 1 {
290            let paths: BTreeSet<&str> = occurrences.iter().map(|(p, _)| p.as_str()).collect();
291            let reqs: Vec<&str> = req_ids.iter().map(|r| r.as_str()).collect();
292            graph.invalid.push(Invalid {
293                target_kind: TargetKind::Test,
294                target: test_id.clone(),
295                reason: ReasonCode::DuplicateId,
296                detail: format!(
297                    "duplicate test id {test_id} points at different requirements ({}) at {}",
298                    reqs.join(", "),
299                    paths.into_iter().collect::<Vec<_>>().join(", ")
300                ),
301            });
302            continue;
303        }
304        let req_id = occurrences[0].1.markers[0].clone();
305        let path = occurrences[0].0.clone();
306        let content = occurrences
307            .iter()
308            .map(|(_, t)| t.content.as_str())
309            .collect::<Vec<_>>()
310            .join("\n");
311        let test_key = NodeKey {
312            kind: NodeKind::Test,
313            id: test_id.clone(),
314        };
315        let hash = crate::hash::content_hash(&content);
316        graph.nodes.insert(
317            test_key.clone(),
318            Node {
319                key: test_key.clone(),
320                path,
321                content,
322                hash,
323                description: None,
324            },
325        );
326        let req_key = NodeKey {
327            kind: NodeKind::Req,
328            id: req_id.clone(),
329        };
330        if graph.nodes.contains_key(&req_key) {
331            let id = edge_id(EdgeKind::TestReq, &test_id, &req_id);
332            graph.edges.insert(
333                id.clone(),
334                Edge {
335                    id,
336                    kind: EdgeKind::TestReq,
337                    from: test_key,
338                    to: req_key,
339                },
340            );
341        } else {
342            graph.broken.push(Broken {
343                from: test_key,
344                target: req_id.clone(),
345                reason: ReasonCode::MissingReq,
346                detail: format!("{req_id} does not exist"),
347            });
348        }
349    }
350
351    // Index checks only run when at least one index root is configured.
352    if !config.index.is_empty() {
353        graph.index_issues = crate::index::collect_issues(config, &files, &texts)
354            .into_iter()
355            .collect();
356    }
357
358    graph.invalid.sort();
359    graph.broken.sort();
360    Ok(graph)
361}
362
363fn is_under_any(path: &str, dirs: &[String]) -> bool {
364    dirs.iter()
365        .any(|d| crate::config::is_under(path, &crate::config::normalize_dir(d)))
366}
367
368#[derive(Debug, Clone, Copy)]
369enum TestLanguage {
370    Go,
371    Rust,
372    CSharp,
373    Java,
374    Kotlin,
375    TypeScript,
376    Dart,
377}
378
379/// Decides the extraction language from the file path; `None` if the file is
380/// not a test file of a supported language.
381fn test_language(path: &str) -> Option<TestLanguage> {
382    if path.ends_with("_test.go") {
383        Some(TestLanguage::Go)
384    } else if path.ends_with(".rs") {
385        Some(TestLanguage::Rust)
386    } else if path.ends_with(".cs") {
387        Some(TestLanguage::CSharp)
388    } else if path.ends_with(".java") {
389        Some(TestLanguage::Java)
390    } else if path.ends_with(".kt") {
391        Some(TestLanguage::Kotlin)
392    } else if crate::extract::is_typescript_test_file(path) {
393        Some(TestLanguage::TypeScript)
394    } else if crate::extract::is_dart_test_file(path) {
395        Some(TestLanguage::Dart)
396    } else {
397        None
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::config::{Config, JudgeConfig};
405    use crate::model::{NodeKind, ReasonCode, TargetKind};
406    use crate::source::Source;
407    use std::collections::BTreeMap;
408
409    struct FakeSource {
410        files: BTreeMap<String, Vec<u8>>,
411    }
412
413    impl FakeSource {
414        fn new(files: &[(&str, &str)]) -> FakeSource {
415            FakeSource {
416                files: files
417                    .iter()
418                    .map(|(p, c)| (p.to_string(), c.as_bytes().to_vec()))
419                    .collect(),
420            }
421        }
422    }
423
424    impl Source for FakeSource {
425        fn list_files(&self) -> anyhow::Result<Vec<String>> {
426            Ok(self.files.keys().cloned().collect())
427        }
428
429        fn read(&self, path: &str) -> anyhow::Result<Vec<u8>> {
430            self.files
431                .get(path)
432                .cloned()
433                .ok_or_else(|| anyhow::anyhow!("not found: {path}"))
434        }
435    }
436
437    fn config() -> Config {
438        Config {
439            docs: vec!["docs".to_string()],
440            requirements: vec!["req".to_string()],
441            tests: vec![".".to_string()],
442            diff_base: "main".to_string(),
443            judge: JudgeConfig {
444                threshold: 0.9,
445                max_body_bytes: 200_000,
446            },
447            rust: crate::config::RustConfig::default(),
448            exclude: vec![],
449            index: vec![],
450            site: None,
451        }
452    }
453
454    // [FR-005]
455    #[test]
456    fn typeフィールドのないmdファイルはノードとして扱われない() {
457        let source = FakeSource::new(&[
458            ("docs/a.md", "# no frontmatter\nplain text\n"),
459            (
460                "req/fr1.md",
461                "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
462            ),
463        ]);
464        let graph = build_graph(&source, &config()).unwrap();
465        assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Doc));
466        assert!(
467            graph.invalid.is_empty(),
468            "should be silently excluded, not invalid: {:?}",
469            graph.invalid
470        );
471    }
472
473    // [FR-005]
474    #[test]
475    fn typeの値がbusiness_need_requirement以外なら対象外になる() {
476        let source = FakeSource::new(&[
477            ("docs/b.md", "---\ntype: Note\nid: DOC-B\n---\n本文\n"),
478            (
479                "req/fr1.md",
480                "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
481            ),
482        ]);
483        let graph = build_graph(&source, &config()).unwrap();
484        assert!(!graph.nodes.values().any(|n| n.key.id == "DOC-B"));
485    }
486
487    // [FR-005]
488    #[test]
489    fn type_specificationの文書はdocノードにならない() {
490        let source = FakeSource::new(&[
491            (
492                "docs/old.md",
493                "---\ntype: Specification\nid: DOC-OLD\n---\n本文\n",
494            ),
495            (
496                "req/fr1.md",
497                "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
498            ),
499        ]);
500        let graph = build_graph(&source, &config()).unwrap();
501        assert!(!graph.nodes.values().any(|n| n.key.id == "DOC-OLD"));
502        assert!(
503            graph.invalid.is_empty(),
504            "should be silently excluded, not invalid: {:?}",
505            graph.invalid
506        );
507    }
508
509    // [FR-006]
510    #[test]
511    fn 不正な文字を含むidはinvalid_idとして報告される() {
512        let source = FakeSource::new(&[(
513            "req/bad.md",
514            "---\ntype: Requirement\nid: FR:001\n---\n本文\n",
515        )]);
516        let graph = build_graph(&source, &config()).unwrap();
517        assert!(graph
518            .invalid
519            .iter()
520            .any(|i| i.reason == ReasonCode::InvalidId && i.target_kind == TargetKind::Req));
521    }
522
523    // [FR-007]
524    #[test]
525    fn idが空のファイルはmissing_idとして報告される() {
526        let source = FakeSource::new(&[(
527            "req/empty-id.md",
528            "---\ntype: Requirement\nid: \n---\n本文\n",
529        )]);
530        let graph = build_graph(&source, &config()).unwrap();
531        assert!(graph
532            .invalid
533            .iter()
534            .any(|i| i.reason == ReasonCode::MissingId));
535    }
536
537    // [FR-007]
538    #[test]
539    fn 同じidを持つファイルが複数あるとduplicate_idとして報告される() {
540        let source = FakeSource::new(&[
541            (
542                "req/fr1.md",
543                "---\ntype: Requirement\nid: FR-001\n---\n本文A\n",
544            ),
545            (
546                "req/fr1-dup.md",
547                "---\ntype: Requirement\nid: FR-001\n---\n本文B\n",
548            ),
549        ]);
550        let graph = build_graph(&source, &config()).unwrap();
551        assert!(graph
552            .invalid
553            .iter()
554            .any(|i| i.reason == ReasonCode::DuplicateId && i.target == "FR-001"));
555    }
556
557    // [FR-010]
558    #[test]
559    fn requirements領域外を指すリンクはエッジにもbrokenにもならない() {
560        let source = FakeSource::new(&[
561            (
562                "docs/a.md",
563                "---\ntype: BusinessNeed\nid: DOC-A\n---\n[外部req](../outside/x.md)\n",
564            ),
565            (
566                "outside/x.md",
567                "---\ntype: Requirement\nid: FR-999\n---\n本文\n",
568            ),
569        ]);
570        let graph = build_graph(&source, &config()).unwrap();
571        assert!(graph
572            .edges
573            .values()
574            .all(|e| e.kind != crate::model::EdgeKind::DocReq));
575        assert!(
576            graph.broken.is_empty(),
577            "should not be reported as broken: {:?}",
578            graph.broken
579        );
580    }
581
582    // [FR-011]
583    #[test]
584    fn 存在しないreqファイルを指すリンクはbrokenになる() {
585        let source = FakeSource::new(&[(
586            "docs/a.md",
587            "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/missing.md)\n",
588        )]);
589        let graph = build_graph(&source, &config()).unwrap();
590        assert!(graph
591            .broken
592            .iter()
593            .any(|b| b.from.kind == NodeKind::Doc && b.reason == ReasonCode::MissingReq));
594    }
595
596    // [FR-011]
597    #[test]
598    fn requirement型でないファイルを指すリンクはnot_req_fileとしてbrokenになる() {
599        let source = FakeSource::new(&[
600            (
601                "docs/a.md",
602                "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/not-a-req.md)\n",
603            ),
604            (
605                "req/not-a-req.md",
606                "---\ntype: BusinessNeed\nid: DOC-FAKE\n---\n本文\n",
607            ),
608        ]);
609        let graph = build_graph(&source, &config()).unwrap();
610        assert!(graph
611            .broken
612            .iter()
613            .any(|b| b.from.kind == NodeKind::Doc && b.reason == ReasonCode::NotReqFile));
614    }
615
616    // [FR-011]
617    #[test]
618    fn 存在しないreq_idを指すtestマーカーはbrokenになる() {
619        let source = FakeSource::new(&[(
620            "src/foo.rs",
621            "// [FR-999]\n#[test]\nfn does_something() {}\n",
622        )]);
623        let graph = build_graph(&source, &config()).unwrap();
624        assert!(graph
625            .broken
626            .iter()
627            .any(|b| b.from.kind == NodeKind::Test && b.reason == ReasonCode::MissingReq));
628    }
629
630    // [FR-014]
631    #[test]
632    fn go_1つのテストに2件以上のマーカーがあるとinvalidになる() {
633        let source = FakeSource::new(&[(
634            "main_test.go",
635            "package main\n\n// [FR-001]\n// [FR-002]\nfunc TestBoth(t *testing.T) {}\n",
636        )]);
637        let graph = build_graph(&source, &config()).unwrap();
638        assert!(graph
639            .invalid
640            .iter()
641            .any(|i| i.reason == ReasonCode::MultipleMarkers));
642    }
643
644    // [FR-014]
645    #[test]
646    fn 構文エラーのrustテストファイルはparse_errorとしてinvalidになる() {
647        let source = FakeSource::new(&[("broken.rs", "fn totally #%^& invalid syntax {\n")]);
648        let graph = build_graph(&source, &config()).unwrap();
649        assert!(graph
650            .invalid
651            .iter()
652            .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.rs")));
653    }
654
655    // [FR-014]
656    #[test]
657    fn rust_1つのテストに2件以上のマーカーがあるとinvalidになる() {
658        let source = FakeSource::new(&[(
659            "src/foo.rs",
660            "// [FR-001]\n// [FR-002]\n#[test]\nfn does_both() {}\n",
661        )]);
662        let graph = build_graph(&source, &config()).unwrap();
663        assert!(graph
664            .invalid
665            .iter()
666            .any(|i| i.reason == ReasonCode::MultipleMarkers));
667    }
668
669    // [FR-014]
670    #[test]
671    fn 構文エラーのgoテストファイルはparse_errorとしてinvalidになる() {
672        let source = FakeSource::new(&[("broken_test.go", "package main\n\nfunc TestBroken( {\n")]);
673        let graph = build_graph(&source, &config()).unwrap();
674        assert!(graph
675            .invalid
676            .iter()
677            .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken_test.go")));
678    }
679
680    // [FR-018]
681    #[test]
682    fn configのディレクトリ表記がドットスラッシュや末尾スラッシュ付きでも拾われる() {
683        let source = FakeSource::new(&[
684            (
685                "docs/spec.md",
686                "---\ntype: BusinessNeed\nid: DOC-A\n---\n[リンク](../req/fr1.md)\n",
687            ),
688            (
689                "req/fr1.md",
690                "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
691            ),
692        ]);
693        let config = Config {
694            docs: vec!["./docs".to_string()],
695            requirements: vec!["req/".to_string()],
696            tests: vec![".".to_string()],
697            diff_base: "main".to_string(),
698            judge: JudgeConfig {
699                threshold: 0.9,
700                max_body_bytes: 200_000,
701            },
702            rust: crate::config::RustConfig::default(),
703            exclude: vec![],
704            index: vec![],
705            site: None,
706        };
707        let graph = build_graph(&source, &config).unwrap();
708        assert!(graph
709            .nodes
710            .keys()
711            .any(|k| k.kind == NodeKind::Doc && k.id == "DOC-A"));
712        assert!(graph
713            .nodes
714            .keys()
715            .any(|k| k.kind == NodeKind::Req && k.id == "FR-001"));
716        assert!(graph.edges.contains_key("doc-req:DOC-A:FR-001"));
717    }
718
719    const REQ1: (&str, &str) = (
720        "req/fr1.md",
721        "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
722    );
723
724    fn has_test_node(graph: &crate::model::Graph, id: &str) -> bool {
725        graph
726            .nodes
727            .keys()
728            .any(|k| k.kind == NodeKind::Test && k.id == id)
729    }
730
731    // [FR-049]
732    #[test]
733    fn csharpファイルからtestノードとエッジが作られる() {
734        let source = FakeSource::new(&[
735            REQ1,
736            (
737                "tests/AuthTests.cs",
738                "public class AuthTests\n{\n    // [FR-001]\n    [Fact]\n    public void T() { }\n}\n",
739            ),
740        ]);
741        let graph = build_graph(&source, &config()).unwrap();
742        assert!(has_test_node(&graph, "tests/AuthTests:AuthTests:T"));
743        assert!(graph
744            .edges
745            .contains_key("test-req:tests/AuthTests:AuthTests:T:FR-001"));
746    }
747
748    // [FR-015]
749    #[test]
750    fn configのrust_test_attributesが抽出に反映される() {
751        let src = "// [FR-001]\n#[rstest]\nfn t() {}\n";
752        let source = FakeSource::new(&[REQ1, ("src/a.rs", src)]);
753
754        let graph = build_graph(&source, &config()).unwrap();
755        assert!(!has_test_node(&graph, "src/a::t"));
756
757        let mut cfg = config();
758        cfg.rust.test_attributes = vec!["rstest".to_string()];
759        let graph = build_graph(&source, &cfg).unwrap();
760        assert!(has_test_node(&graph, "src/a::t"));
761        assert!(graph.edges.contains_key("test-req:src/a::t:FR-001"));
762    }
763
764    // [FR-053]
765    #[test]
766    fn typescriptのtest_tsとspec_tsxからtestノードが作られる() {
767        let source = FakeSource::new(&[
768            REQ1,
769            ("src/a.test.ts", "// [FR-001]\nit(\"works\", () => {});\n"),
770            (
771                "src/b.spec.tsx",
772                "// [FR-001]\nit(\"renders\", () => { const x = <div />; });\n",
773            ),
774        ]);
775        let graph = build_graph(&source, &config()).unwrap();
776        assert!(has_test_node(&graph, "src/a.test::works"));
777        assert!(has_test_node(&graph, "src/b.spec::renders"));
778    }
779
780    // [FR-122]
781    #[test]
782    fn dartのtest_dartからtestノードが作られ通常のdartファイルは走査されない() {
783        let source = FakeSource::new(&[
784            REQ1,
785            (
786                "src/a_test.dart",
787                "void main() {
788  group('g', () {
789    // [FR-001]
790    test('works', () {});
791  });
792}
793",
794            ),
795            (
796                "src/helper.dart",
797                "void main() {
798  // [FR-001]
799  test('skipped', () {});
800}
801",
802            ),
803        ]);
804        let graph = build_graph(&source, &config()).unwrap();
805        assert!(has_test_node(&graph, "src/a_test:g:works"));
806        assert!(!has_test_node(&graph, "src/helper::skipped"));
807        assert!(graph
808            .edges
809            .contains_key("test-req:src/a_test:g:works:FR-001"));
810    }
811
812    // [FR-053]
813    #[test]
814    fn typescriptのtestやspecを含まない通常のtsファイルは走査されない() {
815        let source = FakeSource::new(&[
816            REQ1,
817            ("src/a.ts", "// [FR-001]\nit(\"works\", () => {});\n"),
818            ("src/b.tsx", "// [FR-001]\nit(\"works\", () => {});\n"),
819        ]);
820        let graph = build_graph(&source, &config()).unwrap();
821        assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
822        assert!(graph.invalid.is_empty());
823    }
824
825    // [FR-053]
826    #[test]
827    fn javascriptのtest_jsとspec_mjsからtestノードが作られる() {
828        let source = FakeSource::new(&[
829            REQ1,
830            ("src/a.test.js", "// [FR-001]\nit(\"works\", () => {});\n"),
831            ("src/b.spec.mjs", "// [FR-001]\nit(\"loads\", () => {});\n"),
832        ]);
833        let graph = build_graph(&source, &config()).unwrap();
834        assert!(has_test_node(&graph, "src/a.test::works"));
835        assert!(has_test_node(&graph, "src/b.spec::loads"));
836    }
837
838    // [FR-053]
839    #[test]
840    fn testやspecを含まない通常のjsファイルは走査されない() {
841        let source = FakeSource::new(&[
842            REQ1,
843            ("src/a.js", "// [FR-001]\nit(\"works\", () => {});\n"),
844        ]);
845        let graph = build_graph(&source, &config()).unwrap();
846        assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
847        assert!(graph.invalid.is_empty());
848    }
849
850    // [FR-014]
851    #[test]
852    fn 構文エラーのjsテストファイルはparse_errorとしてinvalidになる() {
853        let source = FakeSource::new(&[("src/broken.test.js", "it(\"t\", () => {\n")]);
854        let graph = build_graph(&source, &config()).unwrap();
855        assert!(graph
856            .invalid
857            .iter()
858            .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.test.js")));
859    }
860
861    const REQ2: (&str, &str) = (
862        "req/fr2.md",
863        "---\ntype: Requirement\nid: FR-002\n---\n本文2\n",
864    );
865
866    fn test_node<'a>(graph: &'a crate::model::Graph, id: &str) -> &'a crate::model::Node {
867        graph
868            .nodes
869            .values()
870            .find(|n| n.key.kind == NodeKind::Test && n.key.id == id)
871            .unwrap_or_else(|| panic!("test node {id} not found"))
872    }
873
874    fn cs_overloads(first: &str, second: &str) -> String {
875        format!(
876            "public class AuthTests\n{{\n    // [FR-001]\n    [Fact]\n    public void T() {{ {first} }}\n\n    // [FR-001]\n    [Fact]\n    public void T(int x) {{ {second} }}\n}}\n"
877        )
878    }
879
880    // [FR-057]
881    #[test]
882    fn csharpのオーバーロードは1つのtestにまとめられcontentが出現順に連結される() {
883        let source = FakeSource::new(&[
884            REQ1,
885            ("tests/AuthTests.cs", &cs_overloads("one();", "two();")),
886        ]);
887        let graph = build_graph(&source, &config()).unwrap();
888        assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
889        let tests: Vec<_> = graph
890            .nodes
891            .keys()
892            .filter(|k| k.kind == NodeKind::Test)
893            .collect();
894        assert_eq!(tests.len(), 1, "{tests:?}");
895        let edges: Vec<_> = graph
896            .edges
897            .values()
898            .filter(|e| e.kind == crate::model::EdgeKind::TestReq)
899            .collect();
900        assert_eq!(edges.len(), 1, "{edges:?}");
901        let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
902        let a = node.content.find("one();").expect("first occurrence");
903        let b = node.content.find("two();").expect("second occurrence");
904        assert!(a < b, "{:?}", node.content);
905        let between = &node.content[a..b];
906        assert!(between.contains('\n'), "{:?}", node.content);
907        assert!(node.content.contains("public void T()"));
908        assert!(node.content.contains("public void T(int x)"));
909    }
910
911    // [FR-057]
912    #[test]
913    fn goの別ファイルにある同名testはファイルパス順に連結される() {
914        let source = FakeSource::new(&[
915            REQ1,
916            (
917                "pkg/b_test.go",
918                "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) { second_body() }\n",
919            ),
920            (
921                "pkg/a_test.go",
922                "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) { first_body() }\n",
923            ),
924        ]);
925        let graph = build_graph(&source, &config()).unwrap();
926        assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
927        let node = test_node(&graph, "pkg:TestX");
928        let a = node.content.find("first_body").expect("a_test.go");
929        let b = node.content.find("second_body").expect("b_test.go");
930        assert!(a < b, "{:?}", node.content);
931    }
932
933    // [FR-057]
934    #[test]
935    fn rustのcfg分岐による同名testは1つのtestにまとめられる() {
936        let source = FakeSource::new(&[
937            REQ1,
938            (
939                "src/lib.rs",
940                "#[cfg(unix)]\n// [FR-001]\n#[test]\nfn same() { unix_body(); }\n\n#[cfg(windows)]\n// [FR-001]\n#[test]\nfn same() { windows_body(); }\n",
941            ),
942        ]);
943        let graph = build_graph(&source, &config()).unwrap();
944        assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
945        let node = test_node(&graph, "src/lib::same");
946        let a = node.content.find("unix_body").expect("unix");
947        let b = node.content.find("windows_body").expect("windows");
948        assert!(a < b, "{:?}", node.content);
949    }
950
951    // [FR-057]
952    #[test]
953    fn typescriptの同名testは1つのtestにまとめられる() {
954        let source = FakeSource::new(&[
955            REQ1,
956            (
957                "src/a.test.ts",
958                "// [FR-001]\nit(\"same\", () => { first(); });\n\n// [FR-001]\nit(`same`, () => { second(); });\n",
959            ),
960        ]);
961        let graph = build_graph(&source, &config()).unwrap();
962        assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
963        let node = test_node(&graph, "src/a.test::same");
964        assert!(node.content.contains("first();"));
965        assert!(node.content.contains("second();"));
966    }
967
968    // [FR-057]
969    #[test]
970    fn まとめたtestは出現の本体が変わるとhashが変わる() {
971        let hash_of = |first: &str, second: &str| {
972            let source =
973                FakeSource::new(&[REQ1, ("tests/AuthTests.cs", &cs_overloads(first, second))]);
974            let graph = build_graph(&source, &config()).unwrap();
975            test_node(&graph, "tests/AuthTests:AuthTests:T")
976                .hash
977                .clone()
978        };
979        let base = hash_of("one();", "two();");
980        assert_ne!(base, hash_of("one();", "changed();"));
981        assert_ne!(base, hash_of("changed();", "two();"));
982    }
983
984    // [FR-057]
985    #[test]
986    fn まとめたtestは出現の順序を入れ替えるとhashが変わる() {
987        let hash_of = |first: &str, second: &str| {
988            let src = format!(
989                "public class AuthTests\n{{\n    // [FR-001]\n    [Fact]\n    public void T({first}) {{ }}\n\n    // [FR-001]\n    [Fact]\n    public void T({second}) {{ }}\n}}\n"
990            );
991            let source = FakeSource::new(&[REQ1, ("tests/AuthTests.cs", &src)]);
992            let graph = build_graph(&source, &config()).unwrap();
993            test_node(&graph, "tests/AuthTests:AuthTests:T")
994                .hash
995                .clone()
996        };
997        assert_ne!(hash_of("int x", "string s"), hash_of("string s", "int x"));
998    }
999
1000    // [FR-057]
1001    #[test]
1002    fn 異なるreqを指す重複はduplicate_idとなりノードもエッジも作られない() {
1003        let source = FakeSource::new(&[
1004            REQ1,
1005            REQ2,
1006            (
1007                "pkg/a_test.go",
1008                "package pkg\n\n// [FR-001]\nfunc TestX(t *testing.T) {}\n",
1009            ),
1010            (
1011                "pkg/b_test.go",
1012                "package pkg\n\n// [FR-002]\nfunc TestX(t *testing.T) {}\n",
1013            ),
1014        ]);
1015        let graph = build_graph(&source, &config()).unwrap();
1016        let dups: Vec<_> = graph
1017            .invalid
1018            .iter()
1019            .filter(|i| i.reason == ReasonCode::DuplicateId)
1020            .collect();
1021        assert_eq!(dups.len(), 1, "{:?}", graph.invalid);
1022        assert_eq!(dups[0].target_kind, TargetKind::Test);
1023        assert_eq!(dups[0].target, "pkg:TestX");
1024        assert!(dups[0].detail.contains("pkg/a_test.go"));
1025        assert!(dups[0].detail.contains("pkg/b_test.go"));
1026        assert!(dups[0].detail.contains("FR-001"));
1027        assert!(dups[0].detail.contains("FR-002"));
1028        assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1029        assert!(graph
1030            .edges
1031            .values()
1032            .all(|e| e.kind != crate::model::EdgeKind::TestReq));
1033        let findings = crate::check::check(&graph, &crate::lock::Lock::default());
1034        for req in ["FR-001", "FR-002"] {
1035            assert!(
1036                findings
1037                    .iter()
1038                    .any(|f| f.kind == crate::check::FindingKind::NoTest && f.target == req),
1039                "{req}: {findings:?}"
1040            );
1041        }
1042    }
1043
1044    // [FR-057]
1045    #[test]
1046    fn 同一ファイル内で異なるreqを指す重複は識別子ごとに1件でファイル名は1回だけ出る() {
1047        let source = FakeSource::new(&[
1048            REQ1,
1049            REQ2,
1050            (
1051                "tests/AuthTests.cs",
1052                "public class AuthTests\n{\n    // [FR-001]\n    [Fact]\n    public void T() { }\n\n    // [FR-002]\n    [Fact]\n    public void T(int x) { }\n}\n",
1053            ),
1054        ]);
1055        let graph = build_graph(&source, &config()).unwrap();
1056        let dups: Vec<_> = graph
1057            .invalid
1058            .iter()
1059            .filter(|i| i.reason == ReasonCode::DuplicateId)
1060            .collect();
1061        assert_eq!(dups.len(), 1, "{:?}", graph.invalid);
1062        assert_eq!(dups[0].detail.matches("tests/AuthTests.cs").count(), 1);
1063        assert!(dups[0].detail.contains("FR-001"));
1064        assert!(dups[0].detail.contains("FR-002"));
1065        assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1066    }
1067
1068    // [FR-057]
1069    #[test]
1070    fn 重複の1つが複数マーカーならその出現はmultiple_markersで残りだけがまとめられる() {
1071        let source = FakeSource::new(&[
1072            REQ1,
1073            REQ2,
1074            (
1075                "tests/AuthTests.cs",
1076                "public class AuthTests\n{\n    // [FR-001]\n    // [FR-002]\n    [Fact]\n    public void T() { multi(); }\n\n    // [FR-001]\n    [Fact]\n    public void T(int x) { single_a(); }\n\n    // [FR-001]\n    [Fact]\n    public void T(string s) { single_b(); }\n}\n",
1077            ),
1078        ]);
1079        let graph = build_graph(&source, &config()).unwrap();
1080        assert!(
1081            graph
1082                .invalid
1083                .iter()
1084                .any(|i| i.reason == ReasonCode::MultipleMarkers),
1085            "{:?}",
1086            graph.invalid
1087        );
1088        assert!(!graph
1089            .invalid
1090            .iter()
1091            .any(|i| i.reason == ReasonCode::DuplicateId));
1092        let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1093        assert!(node.content.contains("single_a();"));
1094        assert!(node.content.contains("single_b();"));
1095        assert!(!node.content.contains("multi();"));
1096    }
1097
1098    // [FR-057]
1099    #[test]
1100    fn 重複の1つが複数マーカーで残りが1件なら通常のtestとして扱われる() {
1101        let source = FakeSource::new(&[
1102            REQ1,
1103            REQ2,
1104            (
1105                "tests/AuthTests.cs",
1106                "public class AuthTests\n{\n    // [FR-001]\n    // [FR-002]\n    [Fact]\n    public void T() { multi(); }\n\n    // [FR-001]\n    [Fact]\n    public void T(int x) { single_a(); }\n}\n",
1107            ),
1108        ]);
1109        let graph = build_graph(&source, &config()).unwrap();
1110        assert!(graph
1111            .invalid
1112            .iter()
1113            .any(|i| i.reason == ReasonCode::MultipleMarkers));
1114        assert!(!graph
1115            .invalid
1116            .iter()
1117            .any(|i| i.reason == ReasonCode::DuplicateId));
1118        let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1119        assert!(node.content.contains("single_a();"));
1120        assert!(!node.content.contains("multi();"));
1121    }
1122
1123    // [FR-057]
1124    #[test]
1125    fn マーカーのない同名出現はまとめられない() {
1126        let source = FakeSource::new(&[
1127            REQ1,
1128            (
1129                "tests/AuthTests.cs",
1130                "public class AuthTests\n{\n    // [FR-001]\n    [Fact]\n    public void T() { marked_body(); }\n\n    [Fact]\n    public void T(int x) { unmarked_body(); }\n}\n",
1131            ),
1132        ]);
1133        let graph = build_graph(&source, &config()).unwrap();
1134        assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1135        let node = test_node(&graph, "tests/AuthTests:AuthTests:T");
1136        assert!(node.content.contains("marked_body();"));
1137        assert!(!node.content.contains("unmarked_body();"));
1138    }
1139
1140    // [FR-014]
1141    #[test]
1142    fn 構文エラーのcsharpテストファイルはparse_errorとしてinvalidになる() {
1143        let source = FakeSource::new(&[(
1144            "tests/Broken.cs",
1145            "public class A {\n    [Fact]\n    public void T( {\n}\n",
1146        )]);
1147        let graph = build_graph(&source, &config()).unwrap();
1148        assert!(graph
1149            .invalid
1150            .iter()
1151            .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.cs")));
1152    }
1153
1154    // [FR-059]
1155    #[test]
1156    fn javaファイルからtestノードとエッジが作られる() {
1157        let source = FakeSource::new(&[
1158            REQ1,
1159            (
1160                "tests/AuthTests.java",
1161                "public class AuthTests {\n    // [FR-001]\n    @Test\n    void t() { }\n}\n",
1162            ),
1163        ]);
1164        let graph = build_graph(&source, &config()).unwrap();
1165        assert!(has_test_node(&graph, "tests/AuthTests:AuthTests:t"));
1166        assert!(graph
1167            .edges
1168            .contains_key("test-req:tests/AuthTests:AuthTests:t:FR-001"));
1169    }
1170
1171    // [FR-059]
1172    #[test]
1173    fn kotlinファイルからtestノードとエッジが作られる() {
1174        let source = FakeSource::new(&[
1175            REQ1,
1176            (
1177                "tests/AuthTest.kt",
1178                "class AuthTest {\n    // [FR-001]\n    @Test\n    fun `一覧 取得`() { }\n}\n",
1179            ),
1180        ]);
1181        let graph = build_graph(&source, &config()).unwrap();
1182        assert!(has_test_node(&graph, "tests/AuthTest:AuthTest:一覧 取得"));
1183        assert!(graph
1184            .edges
1185            .contains_key("test-req:tests/AuthTest:AuthTest:一覧 取得:FR-001"));
1186    }
1187
1188    // [FR-059]
1189    #[test]
1190    fn kotlinスクリプトのktsファイルは対象外になる() {
1191        let source = FakeSource::new(&[
1192            REQ1,
1193            (
1194                "tests/build.kts",
1195                "class AuthTest {\n    // [FR-001]\n    @Test\n    fun t() { }\n}\n",
1196            ),
1197        ]);
1198        let graph = build_graph(&source, &config()).unwrap();
1199        assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1200        assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1201    }
1202
1203    // [FR-057]
1204    #[test]
1205    fn javaのオーバーロードは1つのtestにまとめられる() {
1206        let src = "public class AuthTests {\n    // [FR-001]\n    @Test\n    void t() { one(); }\n\n    // [FR-001]\n    @Test\n    void t(int x) { two(); }\n}\n";
1207        let source = FakeSource::new(&[REQ1, ("tests/AuthTests.java", src)]);
1208        let graph = build_graph(&source, &config()).unwrap();
1209        assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1210        let tests: Vec<_> = graph
1211            .nodes
1212            .keys()
1213            .filter(|k| k.kind == NodeKind::Test)
1214            .collect();
1215        assert_eq!(tests.len(), 1, "{tests:?}");
1216        let edges: Vec<_> = graph
1217            .edges
1218            .values()
1219            .filter(|e| e.kind == crate::model::EdgeKind::TestReq)
1220            .collect();
1221        assert_eq!(edges.len(), 1, "{edges:?}");
1222        let node = test_node(&graph, "tests/AuthTests:AuthTests:t");
1223        assert!(node.content.contains("one();"));
1224        assert!(node.content.contains("two();"));
1225    }
1226
1227    // [FR-014]
1228    #[test]
1229    fn 構文エラーのkotlinテストファイルはparse_errorとしてinvalidになる() {
1230        let source = FakeSource::new(&[("tests/Broken.kt", "class A { fun x() {} }\n")]);
1231        let graph = build_graph(&source, &config()).unwrap();
1232        assert!(graph
1233            .invalid
1234            .iter()
1235            .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.kt")));
1236    }
1237
1238    // [FR-014]
1239    #[test]
1240    fn 構文エラーのjavaテストファイルはparse_errorとしてinvalidになる() {
1241        let source = FakeSource::new(&[(
1242            "tests/Broken.java",
1243            "public class A {\n    @Test\n    void t( {\n}\n",
1244        )]);
1245        let graph = build_graph(&source, &config()).unwrap();
1246        assert!(graph
1247            .invalid
1248            .iter()
1249            .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("Broken.java")));
1250    }
1251
1252    // [FR-014]
1253    #[test]
1254    fn 構文エラーのtypescriptテストファイルはparse_errorとしてinvalidになる() {
1255        let source = FakeSource::new(&[("src/broken.test.ts", "it(\"a\", () => {\n")]);
1256        let graph = build_graph(&source, &config()).unwrap();
1257        assert!(graph
1258            .invalid
1259            .iter()
1260            .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken.test.ts")));
1261    }
1262
1263    // [FR-014]
1264    #[test]
1265    fn 構文エラーのdartテストファイルはparse_errorとしてinvalidになる() {
1266        let source = FakeSource::new(&[(
1267            "src/broken_test.dart",
1268            "void main() {
1269  test('a', () {
1270",
1271        )]);
1272        let graph = build_graph(&source, &config()).unwrap();
1273        assert!(graph
1274            .invalid
1275            .iter()
1276            .any(|i| i.reason == ReasonCode::ParseError && i.target.contains("broken_test.dart")));
1277    }
1278
1279    // [FR-014]
1280    #[test]
1281    fn dart_1つのテストに2件以上のマーカーがあるとinvalidになりエッジを作らない() {
1282        let source = FakeSource::new(&[
1283            REQ1,
1284            (
1285                "tests/a_test.dart",
1286                "void main() {\n  // [FR-001]\n  // [FR-002]\n  test('t', () {});\n}\n",
1287            ),
1288        ]);
1289        let graph = build_graph(&source, &config()).unwrap();
1290        assert!(graph
1291            .invalid
1292            .iter()
1293            .any(|i| i.reason == ReasonCode::MultipleMarkers));
1294        assert!(!graph.edges.keys().any(|k| k.starts_with("test-req:")));
1295    }
1296
1297    // [FR-014]
1298    #[test]
1299    fn csharp_1つのテストに2件以上のマーカーがあるとinvalidになる() {
1300        let source = FakeSource::new(&[(
1301            "tests/AuthTests.cs",
1302            "public class AuthTests\n{\n    // [FR-001]\n    // [FR-002]\n    [Fact]\n    public void T() { }\n}\n",
1303        )]);
1304        let graph = build_graph(&source, &config()).unwrap();
1305        assert!(graph
1306            .invalid
1307            .iter()
1308            .any(|i| i.reason == ReasonCode::MultipleMarkers));
1309    }
1310
1311    // [FR-014]
1312    #[test]
1313    fn typescript_1つのテストに2件以上のマーカーがあるとinvalidになる() {
1314        let source = FakeSource::new(&[(
1315            "src/a.test.ts",
1316            "// [FR-001]\n// [FR-002]\nit(\"a\", () => {});\n",
1317        )]);
1318        let graph = build_graph(&source, &config()).unwrap();
1319        assert!(graph
1320            .invalid
1321            .iter()
1322            .any(|i| i.reason == ReasonCode::MultipleMarkers));
1323    }
1324
1325    // [FR-018]
1326    #[test]
1327    fn testsのディレクトリ指定はcsharpとtypescriptのファイルにも適用される() {
1328        let source = FakeSource::new(&[
1329            REQ1,
1330            (
1331                "other/AuthTests.cs",
1332                "public class AuthTests\n{\n    // [FR-001]\n    [Fact]\n    public void T() { }\n}\n",
1333            ),
1334            ("other/a.test.ts", "// [FR-001]\nit(\"a\", () => {});\n"),
1335        ]);
1336        let mut cfg = config();
1337        cfg.tests = vec!["tests".to_string()];
1338        let graph = build_graph(&source, &cfg).unwrap();
1339        assert!(!graph.nodes.keys().any(|k| k.kind == NodeKind::Test));
1340    }
1341
1342    fn exclude_config(names: &[&str]) -> Config {
1343        let mut cfg = config();
1344        cfg.exclude = names.iter().map(|s| s.to_string()).collect();
1345        cfg
1346    }
1347
1348    const MARKED_JS: &str = "// [FR-001]\nit(\"works\", () => {});\n";
1349
1350    // [FR-065]
1351    #[test]
1352    fn excludeに指定した名前のディレクトリ配下のtestはノードにならない() {
1353        let source = FakeSource::new(&[
1354            REQ1,
1355            ("build/a.test.js", MARKED_JS),
1356            ("src/a.test.js", MARKED_JS),
1357        ]);
1358        let graph = build_graph(&source, &exclude_config(&["build"])).unwrap();
1359        assert!(!has_test_node(&graph, "build/a.test::works"));
1360        assert!(has_test_node(&graph, "src/a.test::works"));
1361    }
1362
1363    // [FR-065]
1364    #[test]
1365    fn excludeが空なら全ディレクトリのtestがノードになる() {
1366        let source = FakeSource::new(&[
1367            REQ1,
1368            ("build/a.test.js", MARKED_JS),
1369            ("src/a.test.js", MARKED_JS),
1370        ]);
1371        let graph = build_graph(&source, &exclude_config(&[])).unwrap();
1372        assert!(has_test_node(&graph, "build/a.test::works"));
1373        assert!(has_test_node(&graph, "src/a.test::works"));
1374    }
1375
1376    // [FR-065]
1377    #[test]
1378    fn excludeはネストした深さのディレクトリにも適用される() {
1379        let source = FakeSource::new(&[
1380            REQ1,
1381            ("pkg/build/b.test.js", MARKED_JS),
1382            ("pkg/src/b.test.js", MARKED_JS),
1383        ]);
1384        let graph = build_graph(&source, &exclude_config(&["build"])).unwrap();
1385        assert!(!has_test_node(&graph, "pkg/build/b.test::works"));
1386        assert!(has_test_node(&graph, "pkg/src/b.test::works"));
1387    }
1388
1389    // [FR-065]
1390    #[test]
1391    fn excludeに既定の除外名と重複する名前を指定しても問題ない() {
1392        let source = FakeSource::new(&[
1393            REQ1,
1394            ("dist/a.test.js", MARKED_JS),
1395            ("src/a.test.js", MARKED_JS),
1396        ]);
1397        let graph = build_graph(&source, &exclude_config(&["dist"])).unwrap();
1398        assert!(!has_test_node(&graph, "dist/a.test::works"));
1399        assert!(has_test_node(&graph, "src/a.test::works"));
1400    }
1401
1402    // [FR-065]
1403    #[test]
1404    fn excludeは大文字小文字を区別する() {
1405        let source = FakeSource::new(&[REQ1, ("build/a.test.js", MARKED_JS)]);
1406        let graph = build_graph(&source, &exclude_config(&["Build"])).unwrap();
1407        assert!(has_test_node(&graph, "build/a.test::works"));
1408    }
1409
1410    // [FR-065]
1411    #[test]
1412    fn exclude_nextのようなドット始まりの名前も除外できる() {
1413        let source = FakeSource::new(&[
1414            REQ1,
1415            (".next/a.test.js", MARKED_JS),
1416            ("src/a.test.js", MARKED_JS),
1417        ]);
1418        let graph = build_graph(&source, &exclude_config(&[".next"])).unwrap();
1419        assert!(!has_test_node(&graph, ".next/a.test::works"));
1420        assert!(has_test_node(&graph, "src/a.test::works"));
1421    }
1422
1423    // [FR-067]
1424    #[test]
1425    fn 予約名のindex_mdとlog_mdはdocsでもrequirementsでもノードにならない() {
1426        let source = FakeSource::new(&[
1427            (
1428                "docs/index.md",
1429                "---\ntype: BusinessNeed\nid: DOC-X\n---\n[r](../req/fr1.md)\n",
1430            ),
1431            ("docs/log.md", "---\ntype: BusinessNeed\nid: DOC-L\n---\n"),
1432            ("docs/sub/index.md", "---\ntype: BusinessNeed\n---\n"),
1433            ("req/index.md", "---\ntype: Requirement\nid: FR-001\n---\n"),
1434            ("req/log.md", "---\ntype: Requirement\n---\n"),
1435            (
1436                "req/fr1.md",
1437                "---\ntype: Requirement\nid: FR-001\n---\n本文\n",
1438            ),
1439        ]);
1440        let graph = build_graph(&source, &config()).unwrap();
1441        assert!(
1442            graph.invalid.is_empty(),
1443            "reserved names must not be invalid: {:?}",
1444            graph.invalid
1445        );
1446        assert!(!graph.nodes.values().any(|n| n.key.kind == NodeKind::Doc));
1447        let reqs: Vec<&str> = graph
1448            .nodes
1449            .values()
1450            .filter(|n| n.key.kind == NodeKind::Req)
1451            .map(|n| n.path.as_str())
1452            .collect();
1453        assert_eq!(reqs, ["req/fr1.md"]);
1454        assert!(graph.edges.is_empty(), "{:?}", graph.edges);
1455    }
1456
1457    // [FR-066]
1458    #[test]
1459    fn index未設定なら索引のfindingを一切出さない() {
1460        let source = FakeSource::new(&[
1461            ("docs/a.md", "---\ntype: BusinessNeed\nid: DOC-A\n---\n"),
1462            ("docs/untyped.md", "plain\n"),
1463        ]);
1464        let graph = build_graph(&source, &config()).unwrap();
1465        assert!(graph.index_issues.is_empty(), "{:?}", graph.index_issues);
1466    }
1467
1468    // [FR-067]
1469    #[test]
1470    fn index設定があっても索引のtype_business_needはノードにならない() {
1471        let source = FakeSource::new(&[
1472            (
1473                "docs/index.md",
1474                "---\ntype: BusinessNeed\nid: DOC-X\n---\n- [A](a.md) — D\n",
1475            ),
1476            (
1477                "docs/a.md",
1478                "---\ntype: BusinessNeed\nid: DOC-A\ntitle: A\ndescription: D\n---\n",
1479            ),
1480        ]);
1481        let mut cfg = config();
1482        cfg.index = vec!["docs".to_string()];
1483        let graph = build_graph(&source, &cfg).unwrap();
1484        assert!(graph.invalid.is_empty(), "{:?}", graph.invalid);
1485        let docs: Vec<&str> = graph
1486            .nodes
1487            .values()
1488            .filter(|n| n.key.kind == NodeKind::Doc)
1489            .map(|n| n.path.as_str())
1490            .collect();
1491        assert_eq!(docs, ["docs/a.md"]);
1492        assert!(graph.index_issues.is_empty(), "{:?}", graph.index_issues);
1493    }
1494
1495    // [FR-073]
1496    #[test]
1497    fn index設定があればbuild_graphが索引のfindingを集める() {
1498        let source = FakeSource::new(&[
1499            (
1500                "docs/a.md",
1501                "---\ntype: BusinessNeed\nid: DOC-A\ntitle: A\ndescription: D\n---\n",
1502            ),
1503            ("docs/untyped.md", "plain\n"),
1504        ]);
1505        let mut cfg = config();
1506        cfg.index = vec!["docs".to_string()];
1507        let graph = build_graph(&source, &cfg).unwrap();
1508        assert!(graph
1509            .index_issues
1510            .iter()
1511            .any(|i| i.reason == ReasonCode::MissingIndex && i.target == "docs"));
1512        assert!(graph
1513            .index_issues
1514            .iter()
1515            .any(|i| i.reason == ReasonCode::UntypedFile && i.target == "docs/untyped.md"));
1516    }
1517
1518    fn indexed_config(roots: &[&str]) -> Config {
1519        let mut cfg = config();
1520        cfg.index = roots.iter().map(|r| r.to_string()).collect();
1521        cfg
1522    }
1523
1524    fn desc_edges(graph: &crate::model::Graph) -> Vec<&crate::model::Edge> {
1525        graph
1526            .edges
1527            .values()
1528            .filter(|e| e.kind == crate::model::EdgeKind::Desc)
1529            .collect()
1530    }
1531
1532    const DOC_A: &str = "---\ntype: BusinessNeed\nid: DOC-A\ndescription: 要約A\n---\n本文A\n";
1533    const REQ_1: &str = "---\ntype: Requirement\nid: FR-001\ndescription: 要約1\n---\n本文1\n";
1534
1535    // [FR-086]
1536    #[test]
1537    fn index配下のdocとreqにdescエッジが1本ずつできる() {
1538        let source = FakeSource::new(&[("docs/a.md", DOC_A), ("docs/r.md", REQ_1)]);
1539        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1540        let doc = graph.edges.get("desc:doc:DOC-A").expect("desc:doc:DOC-A");
1541        assert_eq!(doc.kind, crate::model::EdgeKind::Desc);
1542        assert_eq!(doc.from, doc.to);
1543        assert_eq!(doc.from.kind, NodeKind::Doc);
1544        assert_eq!(doc.from.id, "DOC-A");
1545        let req = graph.edges.get("desc:req:FR-001").expect("desc:req:FR-001");
1546        assert_eq!(req.kind, crate::model::EdgeKind::Desc);
1547        assert_eq!(req.from, req.to);
1548        assert_eq!(req.from.kind, NodeKind::Req);
1549        assert_eq!(desc_edges(&graph).len(), 2, "{:?}", graph.edges);
1550    }
1551
1552    // [FR-086]
1553    #[test]
1554    fn descエッジはdescriptionが無いノードにもできる() {
1555        let source = FakeSource::new(&[(
1556            "docs/a.md",
1557            "---\ntype: BusinessNeed\nid: DOC-A\n---\n本文A\n",
1558        )]);
1559        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1560        assert!(
1561            graph.edges.contains_key("desc:doc:DOC-A"),
1562            "{:?}",
1563            graph.edges
1564        );
1565    }
1566
1567    // [FR-086]
1568    #[test]
1569    fn descエッジは空や改行を含むdescriptionのノードにもできる() {
1570        let source = FakeSource::new(&[
1571            (
1572                "docs/a.md",
1573                "---\ntype: BusinessNeed\nid: DOC-A\ndescription: \"\"\n---\n本文\n",
1574            ),
1575            (
1576                "docs/b.md",
1577                "---\ntype: BusinessNeed\nid: DOC-B\ndescription: |\n  一行目\n  二行目\n---\n本文\n",
1578            ),
1579        ]);
1580        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1581        assert!(
1582            graph.edges.contains_key("desc:doc:DOC-A"),
1583            "{:?}",
1584            graph.edges
1585        );
1586        assert!(
1587            graph.edges.contains_key("desc:doc:DOC-B"),
1588            "{:?}",
1589            graph.edges
1590        );
1591    }
1592
1593    // [FR-086]
1594    #[test]
1595    fn indexが空ならdescエッジは1本もできない() {
1596        let source = FakeSource::new(&[("docs/a.md", DOC_A), ("docs/r.md", REQ_1)]);
1597        let graph = build_graph(&source, &config()).unwrap();
1598        assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1599    }
1600
1601    // [FR-086]
1602    #[test]
1603    fn 索引の根の外にあるreqにはdescエッジができない() {
1604        let source = FakeSource::new(&[("docs/a.md", DOC_A), ("req/fr1.md", REQ_1)]);
1605        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1606        assert!(graph.edges.contains_key("desc:doc:DOC-A"));
1607        assert!(
1608            !graph.edges.contains_key("desc:req:FR-001"),
1609            "{:?}",
1610            graph.edges
1611        );
1612    }
1613
1614    // [FR-086]
1615    #[test]
1616    fn 索引の根の配下にあるreqにはdescエッジができる() {
1617        let source = FakeSource::new(&[("docs/a.md", DOC_A), ("req/fr1.md", REQ_1)]);
1618        let graph = build_graph(&source, &indexed_config(&["docs", "req"])).unwrap();
1619        assert!(
1620            graph.edges.contains_key("desc:req:FR-001"),
1621            "{:?}",
1622            graph.edges
1623        );
1624    }
1625
1626    // [FR-086]
1627    #[test]
1628    fn 無効なidのノードにはdescエッジができない() {
1629        let source = FakeSource::new(&[
1630            (
1631                "docs/bad.md",
1632                "---\ntype: BusinessNeed\nid: BAD ID\ndescription: D\n---\n本文\n",
1633            ),
1634            (
1635                "docs/noid.md",
1636                "---\ntype: BusinessNeed\ndescription: D\n---\n本文\n",
1637            ),
1638        ]);
1639        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1640        assert!(!graph.invalid.is_empty());
1641        assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1642    }
1643
1644    // [FR-086]
1645    #[test]
1646    fn guideなどmiwaのノードでない文書にはdescエッジができない() {
1647        let source = FakeSource::new(&[(
1648            "docs/g.md",
1649            "---\ntype: Guide\nid: GUIDE-1\ndescription: D\n---\n本文\n",
1650        )]);
1651        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1652        assert!(desc_edges(&graph).is_empty(), "{:?}", graph.edges);
1653    }
1654
1655    // [FR-086]
1656    #[test]
1657    fn descエッジはorphanとno_testの判定に影響しない() {
1658        let source = FakeSource::new(&[("docs/r.md", REQ_1), ("docs/a.md", DOC_A)]);
1659        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1660        let findings = crate::check::check(&graph, &crate::lock::Lock::default());
1661        assert!(findings
1662            .iter()
1663            .any(|f| f.kind == crate::check::FindingKind::Orphan
1664                && f.reason == ReasonCode::ReqWithoutDoc
1665                && f.target == "FR-001"));
1666        assert!(findings
1667            .iter()
1668            .any(|f| f.kind == crate::check::FindingKind::NoTest && f.target == "FR-001"));
1669        assert!(findings
1670            .iter()
1671            .any(|f| f.kind == crate::check::FindingKind::Orphan
1672                && f.reason == ReasonCode::DocWithoutReq
1673                && f.target == "DOC-A"));
1674    }
1675
1676    // [FR-086]
1677    #[test]
1678    fn ロックにdescエッジがありノードが無ければremovedになる() {
1679        let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1680        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1681        let mut lock = crate::lock::Lock::default();
1682        lock.edges.insert(
1683            "desc:doc:DOC-GONE".to_string(),
1684            crate::lock::LockEntry {
1685                from: "sha256:x".to_string(),
1686                to: "sha256:y".to_string(),
1687                by: "human".to_string(),
1688                reason: None,
1689                score: None,
1690                at: "2026-01-01T00:00:00Z".to_string(),
1691            },
1692        );
1693        let findings = crate::check::check(&graph, &lock);
1694        assert!(findings
1695            .iter()
1696            .any(|f| f.kind == crate::check::FindingKind::Removed
1697                && f.target == "desc:doc:DOC-GONE"
1698                && f.reason == ReasonCode::RemovedEdge));
1699    }
1700
1701    // [FR-086]
1702    #[test]
1703    fn indexの根から外れるとロックのdescエッジはremovedになる() {
1704        let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1705        let with_index = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1706        let mut lock = crate::lock::Lock::default();
1707        let (from, to) =
1708            crate::model::edge_hashes(&with_index, &with_index.edges["desc:doc:DOC-A"]);
1709        lock.edges.insert(
1710            "desc:doc:DOC-A".to_string(),
1711            crate::lock::LockEntry {
1712                from,
1713                to,
1714                by: "human".to_string(),
1715                reason: None,
1716                score: None,
1717                at: "2026-01-01T00:00:00Z".to_string(),
1718            },
1719        );
1720        let without_index = build_graph(&source, &config()).unwrap();
1721        let findings = crate::check::check(&without_index, &lock);
1722        assert!(findings
1723            .iter()
1724            .any(|f| f.kind == crate::check::FindingKind::Removed && f.target == "desc:doc:DOC-A"));
1725    }
1726
1727    fn desc_hashes(graph: &crate::model::Graph, id: &str) -> (String, String) {
1728        crate::model::edge_hashes(graph, &graph.edges[id])
1729    }
1730
1731    // [FR-087]
1732    #[test]
1733    fn descエッジのfromは要約_toは本文のハッシュになる() {
1734        let source = FakeSource::new(&[("docs/a.md", DOC_A)]);
1735        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1736        assert_eq!(
1737            desc_hashes(&graph, "desc:doc:DOC-A"),
1738            (
1739                crate::hash::content_hash("要約A"),
1740                crate::hash::content_hash("本文A\n")
1741            )
1742        );
1743    }
1744
1745    // [FR-087]
1746    #[test]
1747    fn descエッジのfromは前後の空白を除いた要約のハッシュになる() {
1748        let source = FakeSource::new(&[(
1749            "docs/a.md",
1750            "---\ntype: BusinessNeed\nid: DOC-A\ndescription: \"  要約A  \"\n---\n本文A\n",
1751        )]);
1752        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1753        assert_eq!(
1754            desc_hashes(&graph, "desc:doc:DOC-A").0,
1755            crate::hash::content_hash("要約A")
1756        );
1757    }
1758
1759    // [FR-087]
1760    #[test]
1761    fn descエッジのfromは要約が無い_空_改行を含むときは空文字列のハッシュになる() {
1762        let empty = crate::hash::content_hash("");
1763        for fm in [
1764            "type: BusinessNeed\nid: DOC-A",
1765            "type: BusinessNeed\nid: DOC-A\ndescription: \"\"",
1766            "type: BusinessNeed\nid: DOC-A\ndescription: |\n  一行目\n  二行目",
1767        ] {
1768            let source = FakeSource::new(&[("docs/a.md", &format!("---\n{fm}\n---\n本文\n"))]);
1769            let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1770            assert_eq!(desc_hashes(&graph, "desc:doc:DOC-A").0, empty, "{fm}");
1771        }
1772    }
1773
1774    // [FR-087]
1775    #[test]
1776    fn descエッジのfromは折り畳みスカラーの要約を1行に畳んだハッシュになる() {
1777        let source = FakeSource::new(&[(
1778            "docs/a.md",
1779            "---\ntype: BusinessNeed\nid: DOC-A\ndescription: >\n  一行目\n  二行目\n---\n本文\n",
1780        )]);
1781        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1782        assert_eq!(
1783            desc_hashes(&graph, "desc:doc:DOC-A").0,
1784            crate::hash::content_hash("一行目 二行目")
1785        );
1786    }
1787
1788    // [FR-086]
1789    #[test]
1790    fn 同じidのdocとreqは別々のdescエッジになる() {
1791        let source = FakeSource::new(&[
1792            (
1793                "docs/a.md",
1794                "---\ntype: BusinessNeed\nid: X-1\ndescription: 要約D\n---\n本文D\n",
1795            ),
1796            (
1797                "docs/r.md",
1798                "---\ntype: Requirement\nid: X-1\ndescription: 要約R\n---\n本文R\n",
1799            ),
1800        ]);
1801        let graph = build_graph(&source, &indexed_config(&["docs"])).unwrap();
1802        assert!(
1803            graph.edges.contains_key("desc:doc:X-1"),
1804            "{:?}",
1805            graph.edges
1806        );
1807        assert!(
1808            graph.edges.contains_key("desc:req:X-1"),
1809            "{:?}",
1810            graph.edges
1811        );
1812        assert_eq!(desc_edges(&graph).len(), 2, "{:?}", graph.edges);
1813        assert_eq!(
1814            desc_hashes(&graph, "desc:doc:X-1").0,
1815            crate::hash::content_hash("要約D")
1816        );
1817        assert_eq!(
1818            desc_hashes(&graph, "desc:req:X-1").0,
1819            crate::hash::content_hash("要約R")
1820        );
1821    }
1822
1823    // [FR-087]
1824    #[test]
1825    fn 要約だけを変えてもdoc_reqとtest_reqのハッシュは変わらずfindingも出ない() {
1826        let build = |description: &str| {
1827            let source = FakeSource::new(&[
1828                (
1829                    "docs/a.md",
1830                    &format!(
1831                        "---\ntype: BusinessNeed\nid: DOC-A\ndescription: {description}\n---\n[r](../req/fr1.md)\n"
1832                    ),
1833                ),
1834                (
1835                    "req/fr1.md",
1836                    "---\ntype: Requirement\nid: FR-001\n---\n本文1\n",
1837                ),
1838                ("tests/t.rs", "// [FR-001]\n#[test]\nfn t() {}\n"),
1839            ]);
1840            build_graph(&source, &indexed_config(&["docs"])).unwrap()
1841        };
1842        let old = build("旧要約");
1843        let new = build("新要約");
1844        let test_edge = old
1845            .edges
1846            .keys()
1847            .find(|k| k.starts_with("test-req:"))
1848            .cloned()
1849            .expect("test-req edge");
1850        for id in ["doc-req:DOC-A:FR-001", test_edge.as_str()] {
1851            assert_eq!(desc_hashes(&old, id), desc_hashes(&new, id), "{id}");
1852        }
1853        assert_ne!(
1854            desc_hashes(&old, "desc:doc:DOC-A").0,
1855            desc_hashes(&new, "desc:doc:DOC-A").0
1856        );
1857        let mut lock = crate::lock::Lock::default();
1858        for (id, edge) in &old.edges {
1859            let (from, to) = crate::model::edge_hashes(&old, edge);
1860            lock.edges.insert(
1861                id.clone(),
1862                crate::lock::LockEntry {
1863                    from,
1864                    to,
1865                    by: "human".to_string(),
1866                    reason: None,
1867                    score: None,
1868                    at: "2026-01-01T00:00:00Z".to_string(),
1869                },
1870            );
1871        }
1872        let drifts: Vec<String> = crate::check::check(&new, &lock)
1873            .into_iter()
1874            .filter(|f| f.kind == crate::check::FindingKind::Drift)
1875            .map(|f| f.target)
1876            .collect();
1877        assert_eq!(drifts, vec!["desc:doc:DOC-A".to_string()]);
1878    }
1879}