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