Skip to main content

miwa/
reconcile.rs

1//! `reconcile`: record the current state of one or more edges into the lock.
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum ReconcileTarget {
5    All,
6    Edges(Vec<String>),
7}
8
9#[derive(Debug, thiserror::Error)]
10pub enum ReconcileError {
11    #[error("unknown edge: {0}")]
12    UnknownEdge(String),
13    #[error("edge {0} has structural problems")]
14    Blocked(String),
15    #[error("graph has broken references or invalid nodes")]
16    GraphNotClean,
17}
18
19pub fn reconcile(
20    graph: &crate::model::Graph,
21    lock: &crate::lock::Lock,
22    target: ReconcileTarget,
23    reason: Option<String>,
24    now: &str,
25) -> Result<crate::lock::Lock, ReconcileError> {
26    use crate::lock::{Lock, LockEntry};
27
28    match target {
29        ReconcileTarget::All => {
30            if !graph.invalid.is_empty() || !graph.broken.is_empty() {
31                return Err(ReconcileError::GraphNotClean);
32            }
33            let mut new_lock = Lock::default();
34            for (edge_id, edge) in &graph.edges {
35                let (from_hash, to_hash) = crate::model::edge_hashes(graph, edge);
36                let entry = match lock.edges.get(edge_id) {
37                    Some(existing) if existing.from == from_hash && existing.to == to_hash => {
38                        existing.clone()
39                    }
40                    _ => LockEntry {
41                        from: from_hash,
42                        to: to_hash,
43                        by: "human".to_string(),
44                        reason: reason.clone(),
45                        score: None,
46                        at: now.to_string(),
47                    },
48                };
49                new_lock.edges.insert(edge_id.clone(), entry);
50            }
51            Ok(new_lock)
52        }
53        ReconcileTarget::Edges(ids) => {
54            let mut new_lock = lock.clone();
55            for id in ids {
56                if let Some(edge) = graph.edges.get(&id) {
57                    let blocked = graph
58                        .invalid
59                        .iter()
60                        .any(|inv| inv.target == edge.from.id || inv.target == edge.to.id)
61                        || graph.broken.iter().any(|b| b.from == edge.from);
62                    if blocked {
63                        return Err(ReconcileError::Blocked(id));
64                    }
65                    let (from_hash, to_hash) = crate::model::edge_hashes(graph, edge);
66                    new_lock.edges.insert(
67                        id,
68                        LockEntry {
69                            from: from_hash,
70                            to: to_hash,
71                            by: "human".to_string(),
72                            reason: reason.clone(),
73                            score: None,
74                            at: now.to_string(),
75                        },
76                    );
77                } else if new_lock.edges.contains_key(&id) {
78                    // The edge only exists in the lock (it was removed from
79                    // the source). Deleting it is normally fine, but if its
80                    // endpoints are currently invalid/broken (e.g. the req it
81                    // pointed to now has a duplicate id, or the test file it
82                    // lived in now fails to parse), removing the record could
83                    // hide that problem, so reject it instead.
84                    if let Some((kind_prefix, from_id, to_id)) = parse_edge_id(&id) {
85                        let mut blocked = graph
86                            .invalid
87                            .iter()
88                            .any(|inv| inv.target == from_id || inv.target == to_id)
89                            || graph.broken.iter().any(|b| b.from.id == from_id);
90                        if !blocked && kind_prefix == "test-req" {
91                            blocked = test_source_has_parse_error(&graph.invalid, from_id);
92                        }
93                        if blocked {
94                            return Err(ReconcileError::Blocked(id));
95                        }
96                    }
97                    new_lock.edges.remove(&id);
98                } else {
99                    return Err(ReconcileError::UnknownEdge(id));
100                }
101            }
102            Ok(new_lock)
103        }
104    }
105}
106
107/// Splits an edge id (`"doc-req:<from>:<to>"` / `"test-req:<from>:<to>"` /
108/// `"desc:doc:<id>"` / `"desc:req:<id>"`) into `(kind_prefix, from_id, to_id)`.
109/// A `desc` edge yields `("desc", <id>, <id>)` (a self-loop; the node kind is
110/// only part of the edge id). For the other kinds, `to` (a req id) never contains `:`
111/// (doc/req ids are restricted to `[A-Za-z0-9._-]`), so it is always the
112/// last `:`-separated segment; `from` is everything between the kind prefix
113/// and that segment (and may itself contain `:` for Rust test ids).
114fn parse_edge_id(id: &str) -> Option<(&str, &str, &str)> {
115    if let Some(node_id) = id
116        .strip_prefix("desc:doc:")
117        .or_else(|| id.strip_prefix("desc:req:"))
118    {
119        return Some(("desc", node_id, node_id));
120    }
121    let (rest, to_id) = id.rsplit_once(':')?;
122    let (kind_prefix, from_id) = rest.split_once(':')?;
123    Some((kind_prefix, from_id, to_id))
124}
125
126/// Whether `invalid` contains a `ParseError` whose target (a file path)
127/// looks like the source file that test id `test_id` lives in. The "source
128/// prefix" is the part of the test id before its first `:` (e.g. Rust
129/// `tests/sample::t` -> `tests/sample`, Go `internal/auth:TestX` ->
130/// `internal/auth`, Go at the repo root `.:TestX` -> `.`). The match is
131/// deliberately conservative (a false positive only blocks a removal, it
132/// never silently drops information):
133/// - Rust / C# / Java / Kotlin / Dart / TypeScript / JavaScript: the target equals
134///   `<prefix>.rs` / `<prefix>.cs` / `<prefix>.java` / `<prefix>.kt` / `<prefix>.dart` /
135///   `<prefix>.<ext>` exactly, `<ext>` being one of
136///   `TYPESCRIPT_TEST_EXTENSIONS` (TS / JS prefixes keep `.test` / `.spec`).
137/// - Go (non-root): the target starts with `<prefix>/`.
138/// - Go (repo root, `prefix == "."`): the target contains no `/`.
139fn test_source_has_parse_error(invalid: &[crate::model::Invalid], test_id: &str) -> bool {
140    let prefix = test_id.split_once(':').map_or(test_id, |(p, _)| p);
141    invalid.iter().any(|inv| {
142        if inv.reason != crate::model::ReasonCode::ParseError {
143            return false;
144        }
145        let target = inv.target.as_str();
146        if target == format!("{prefix}.rs")
147            || target == format!("{prefix}.cs")
148            || target == format!("{prefix}.java")
149            || target == format!("{prefix}.kt")
150            || target == format!("{prefix}.dart")
151            || crate::extract::TYPESCRIPT_TEST_EXTENSIONS
152                .iter()
153                .any(|ext| target == format!("{prefix}.{ext}"))
154        {
155            return true;
156        }
157        if prefix == "." {
158            !target.contains('/')
159        } else {
160            target.starts_with(&format!("{prefix}/"))
161        }
162    })
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::lock::{Lock, LockEntry};
169    use crate::model::{
170        Edge, EdgeKind, Graph, Invalid, Node, NodeKey, NodeKind, ReasonCode, TargetKind,
171    };
172
173    fn node(kind: NodeKind, id: &str, hash: &str) -> Node {
174        Node {
175            key: NodeKey {
176                kind,
177                id: id.to_string(),
178            },
179            path: format!("{id}.md"),
180            content: String::new(),
181            hash: hash.to_string(),
182            description: None,
183        }
184    }
185
186    fn lock_entry(from: &str, to: &str, at: &str) -> LockEntry {
187        LockEntry {
188            from: from.to_string(),
189            to: to.to_string(),
190            by: "human".to_string(),
191            reason: None,
192            score: None,
193            at: at.to_string(),
194        }
195    }
196
197    /// A clean graph with a single doc-req edge, no broken/invalid entries.
198    fn clean_graph() -> Graph {
199        let mut graph = Graph::default();
200        let doc_key = NodeKey {
201            kind: NodeKind::Doc,
202            id: "DOC-A".to_string(),
203        };
204        let req_key = NodeKey {
205            kind: NodeKind::Req,
206            id: "FR-001".to_string(),
207        };
208        graph.nodes.insert(
209            doc_key.clone(),
210            node(NodeKind::Doc, "DOC-A", "sha256:doc-current"),
211        );
212        graph.nodes.insert(
213            req_key.clone(),
214            node(NodeKind::Req, "FR-001", "sha256:req-current"),
215        );
216        let edge_id = "doc-req:DOC-A:FR-001".to_string();
217        graph.edges.insert(
218            edge_id.clone(),
219            Edge {
220                id: edge_id,
221                kind: EdgeKind::DocReq,
222                from: doc_key,
223                to: req_key,
224            },
225        );
226        graph
227    }
228
229    // [FR-033]
230    #[test]
231    fn edgeを指定すると現在の両端のハッシュがロックに記録される() {
232        let graph = clean_graph();
233        let lock = Lock::default();
234        let result = reconcile(
235            &graph,
236            &lock,
237            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
238            None,
239            "2026-01-01T00:00:00Z",
240        )
241        .unwrap();
242        let entry = result
243            .edges
244            .get("doc-req:DOC-A:FR-001")
245            .expect("edge should be recorded");
246        assert_eq!(entry.from, "sha256:doc-current");
247        assert_eq!(entry.to, "sha256:req-current");
248        assert_eq!(entry.by, "human");
249    }
250
251    // [FR-034]
252    #[test]
253    fn ロックにのみ存在するエッジを指定するとロックから削除される() {
254        let graph = Graph::default(); // グラフには存在しない
255        let mut lock = Lock::default();
256        lock.edges.insert(
257            "doc-req:DOC-A:FR-001".to_string(),
258            lock_entry("sha256:old-doc", "sha256:old-req", "2025-01-01T00:00:00Z"),
259        );
260        let result = reconcile(
261            &graph,
262            &lock,
263            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
264            None,
265            "2026-01-01T00:00:00Z",
266        )
267        .unwrap();
268        assert!(!result.edges.contains_key("doc-req:DOC-A:FR-001"));
269    }
270
271    // [FR-035]
272    #[test]
273    fn ロックにのみ存在するエッジの終端がinvalidな場合はblockedとして拒否される() {
274        let graph = {
275            let mut graph = Graph::default(); // FR-001 は重複idによりグラフから消えている
276            graph.invalid.push(Invalid {
277                target_kind: TargetKind::Req,
278                target: "FR-001".to_string(),
279                reason: ReasonCode::DuplicateId,
280                detail: "duplicate id".to_string(),
281            });
282            graph
283        };
284        let mut lock = Lock::default();
285        lock.edges.insert(
286            "doc-req:DOC-A:FR-001".to_string(),
287            lock_entry("sha256:old-doc", "sha256:old-req", "2025-01-01T00:00:00Z"),
288        );
289        let before = lock.clone();
290
291        let result = reconcile(
292            &graph,
293            &lock,
294            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
295            None,
296            "2026-01-01T00:00:00Z",
297        );
298        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
299        assert_eq!(lock, before);
300    }
301
302    // [FR-035]
303    #[test]
304    fn ロックにのみ存在するrustのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
305        let mut graph = Graph::default(); // テストは構文エラーによりグラフから消えている
306        graph.invalid.push(Invalid {
307            target_kind: TargetKind::File,
308            target: "tests/sample.rs".to_string(),
309            reason: ReasonCode::ParseError,
310            detail: "parse error".to_string(),
311        });
312        let mut lock = Lock::default();
313        lock.edges.insert(
314            "test-req:tests/sample::t:FR-001".to_string(),
315            lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
316        );
317        let before = lock.clone();
318
319        let result = reconcile(
320            &graph,
321            &lock,
322            ReconcileTarget::Edges(vec!["test-req:tests/sample::t:FR-001".to_string()]),
323            None,
324            "2026-01-01T00:00:00Z",
325        );
326        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
327        assert_eq!(lock, before);
328    }
329
330    // [FR-035]
331    #[test]
332    fn ロックにのみ存在するgoのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
333        let mut graph = Graph::default(); // テストは構文エラーによりグラフから消えている
334        graph.invalid.push(Invalid {
335            target_kind: TargetKind::File,
336            target: "internal/auth/login_test.go".to_string(),
337            reason: ReasonCode::ParseError,
338            detail: "parse error".to_string(),
339        });
340        let mut lock = Lock::default();
341        lock.edges.insert(
342            "test-req:internal/auth:TestX:FR-001".to_string(),
343            lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
344        );
345        let before = lock.clone();
346
347        let result = reconcile(
348            &graph,
349            &lock,
350            ReconcileTarget::Edges(vec!["test-req:internal/auth:TestX:FR-001".to_string()]),
351            None,
352            "2026-01-01T00:00:00Z",
353        );
354        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
355        assert_eq!(lock, before);
356    }
357
358    // [FR-034]
359    #[test]
360    fn ロックにもグラフにも存在しないエッジを指定するとエラーになる() {
361        let graph = Graph::default();
362        let lock = Lock::default();
363        let result = reconcile(
364            &graph,
365            &lock,
366            ReconcileTarget::Edges(vec!["doc-req:NOWHERE:FR-999".to_string()]),
367            None,
368            "2026-01-01T00:00:00Z",
369        );
370        assert!(matches!(result, Err(ReconcileError::UnknownEdge(_))));
371    }
372
373    // [FR-035]
374    #[test]
375    fn 対象reqがinvalidに関わるエッジはreconcileを拒否される() {
376        let mut graph = clean_graph();
377        // FR-001 は id が重複しており invalid として報告されている
378        graph.invalid.push(Invalid {
379            target_kind: TargetKind::Req,
380            target: "FR-001".to_string(),
381            reason: ReasonCode::DuplicateId,
382            detail: "duplicate id".to_string(),
383        });
384        let lock = Lock::default();
385        let result = reconcile(
386            &graph,
387            &lock,
388            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
389            None,
390            "2026-01-01T00:00:00Z",
391        );
392        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
393        // ロックは変更されていないはず(今回は空のロックのまま)
394    }
395
396    // [FR-036]
397    #[test]
398    fn allは変更のないエッジを据え置き_新規を追加し_消えたエッジを削除する() {
399        let mut graph = clean_graph();
400        // 現在のグラフに存在する新規エッジ(reqにのみ紐づくtest-reqエッジ)
401        let test_key = NodeKey {
402            kind: NodeKind::Test,
403            id: "src/foo::bar".to_string(),
404        };
405        graph.nodes.insert(
406            test_key.clone(),
407            node(NodeKind::Test, "src/foo::bar", "sha256:test-current"),
408        );
409        let req_key = NodeKey {
410            kind: NodeKind::Req,
411            id: "FR-001".to_string(),
412        };
413        let new_edge_id = "test-req:src/foo::bar:FR-001".to_string();
414        graph.edges.insert(
415            new_edge_id.clone(),
416            Edge {
417                id: new_edge_id.clone(),
418                kind: EdgeKind::TestReq,
419                from: test_key,
420                to: req_key,
421            },
422        );
423
424        let mut lock = Lock::default();
425        // 既に合意済みで、ハッシュが変化していないエッジ(据え置かれるはず)
426        lock.edges.insert(
427            "doc-req:DOC-A:FR-001".to_string(),
428            lock_entry(
429                "sha256:doc-current",
430                "sha256:req-current",
431                "2020-01-01T00:00:00Z",
432            ),
433        );
434        // ソースからは既に消えたエッジ(削除されるはず)
435        lock.edges.insert(
436            "doc-req:DOC-STALE:FR-999".to_string(),
437            lock_entry(
438                "sha256:stale-doc",
439                "sha256:stale-req",
440                "2020-01-01T00:00:00Z",
441            ),
442        );
443
444        let result = reconcile(
445            &graph,
446            &lock,
447            ReconcileTarget::All,
448            None,
449            "2026-01-01T00:00:00Z",
450        )
451        .unwrap();
452
453        // 据え置き: 変更なしのエッジは元のエントリのまま(at が上書きされない)
454        let kept = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
455        assert_eq!(kept.at, "2020-01-01T00:00:00Z");
456
457        // 削除: ソースから消えたエッジはロックに残らない
458        assert!(!result.edges.contains_key("doc-req:DOC-STALE:FR-999"));
459
460        // 追加: 新規エッジが記録される
461        let added = result.edges.get("test-req:src/foo::bar:FR-001").unwrap();
462        assert_eq!(added.from, "sha256:test-current");
463        assert_eq!(added.to, "sha256:req-current");
464        assert_eq!(added.by, "human");
465    }
466
467    // [FR-036]
468    #[test]
469    fn 対象にbrokenまたはinvalidが1件でもあればallは全く変更せず失敗する() {
470        let mut graph = clean_graph();
471        graph.invalid.push(Invalid {
472            target_kind: TargetKind::Req,
473            target: "FR-999".to_string(),
474            reason: ReasonCode::MissingId,
475            detail: "missing id".to_string(),
476        });
477        let mut lock = Lock::default();
478        lock.edges.insert(
479            "doc-req:DOC-A:FR-001".to_string(),
480            lock_entry(
481                "sha256:doc-current",
482                "sha256:req-current",
483                "2020-01-01T00:00:00Z",
484            ),
485        );
486        let before = lock.clone();
487
488        let result = reconcile(
489            &graph,
490            &lock,
491            ReconcileTarget::All,
492            None,
493            "2026-01-01T00:00:00Z",
494        );
495        assert!(matches!(result, Err(ReconcileError::GraphNotClean)));
496        // 呼び出し元のロックは reconcile によって書き換えられていない(純粋関数なので当然だが明示する)
497        assert_eq!(lock, before);
498    }
499
500    // [FR-037]
501    #[test]
502    fn reasonを指定した場合はエントリに記録される() {
503        let graph = clean_graph();
504        let result = reconcile(
505            &graph,
506            &Lock::default(),
507            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
508            Some("最小長の記述が一致".to_string()),
509            "2026-01-01T00:00:00Z",
510        )
511        .unwrap();
512        let entry = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
513        assert_eq!(entry.by, "human");
514        assert_eq!(entry.reason, Some("最小長の記述が一致".to_string()));
515    }
516
517    // [FR-037]
518    #[test]
519    fn reasonを指定しない場合は記録されない() {
520        let graph = clean_graph();
521        let result = reconcile(
522            &graph,
523            &Lock::default(),
524            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
525            None,
526            "2026-01-01T00:00:00Z",
527        )
528        .unwrap();
529        let entry = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
530        assert_eq!(entry.by, "human");
531        assert_eq!(entry.reason, None);
532    }
533
534    fn assert_blocked_by_parse_error(file: &str, edge: &str) {
535        let mut graph = Graph::default();
536        graph.invalid.push(Invalid {
537            target_kind: TargetKind::File,
538            target: file.to_string(),
539            reason: ReasonCode::ParseError,
540            detail: "parse error".to_string(),
541        });
542        let mut lock = Lock::default();
543        lock.edges.insert(
544            edge.to_string(),
545            lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
546        );
547        let before = lock.clone();
548
549        let result = reconcile(
550            &graph,
551            &lock,
552            ReconcileTarget::Edges(vec![edge.to_string()]),
553            None,
554            "2026-01-01T00:00:00Z",
555        );
556        assert!(matches!(result, Err(ReconcileError::Blocked(_))), "{edge}");
557        assert_eq!(lock, before);
558    }
559
560    // [FR-035]
561    #[test]
562    fn ロックにのみ存在するcsharpのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
563        assert_blocked_by_parse_error(
564            "tests/AuthTests.cs",
565            "test-req:tests/AuthTests:AuthTests:T:FR-001",
566        );
567    }
568
569    // [FR-035]
570    #[test]
571    fn ロックにのみ存在するjavaのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
572        assert_blocked_by_parse_error(
573            "tests/AuthTests.java",
574            "test-req:tests/AuthTests:AuthTests:t:FR-001",
575        );
576    }
577
578    // [FR-035]
579    #[test]
580    fn ロックにのみ存在するkotlinのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
581        assert_blocked_by_parse_error(
582            "tests/AuthTest.kt",
583            "test-req:tests/AuthTest:AuthTest:一覧 取得:FR-001",
584        );
585    }
586
587    // [FR-035]
588    #[test]
589    fn ロックにのみ存在するtypescriptのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
590        assert_blocked_by_parse_error("src/a.test.ts", "test-req:src/a.test:d:t:FR-001");
591        assert_blocked_by_parse_error("src/a.spec.ts", "test-req:src/a.spec:d:t:FR-001");
592    }
593
594    // [FR-035]
595    #[test]
596    fn ロックにのみ存在するdartのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
597        assert_blocked_by_parse_error("test/a_test.dart", "test-req:test/a_test:g:t:FR-001");
598    }
599
600    // [FR-035]
601    #[test]
602    fn ロックにのみ存在するtsxのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
603        assert_blocked_by_parse_error("src/a.test.tsx", "test-req:src/a.test:d:t:FR-001");
604        assert_blocked_by_parse_error("src/a.spec.tsx", "test-req:src/a.spec::t:FR-001");
605    }
606
607    // [FR-035]
608    #[test]
609    fn ロックにのみ存在するjsのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
610        assert_blocked_by_parse_error("src/a.test.js", "test-req:src/a.test:d:t:FR-001");
611        assert_blocked_by_parse_error("src/a.test.mjs", "test-req:src/a.test:d:t:FR-001");
612        assert_blocked_by_parse_error("src/a.spec.cjs", "test-req:src/a.spec:d:t:FR-001");
613        assert_blocked_by_parse_error("src/a.spec.mts", "test-req:src/a.spec:d:t:FR-001");
614        assert_blocked_by_parse_error("src/a.test.cts", "test-req:src/a.test:d:t:FR-001");
615        assert_blocked_by_parse_error("src/a.spec.jsx", "test-req:src/a.spec:d:t:FR-001");
616    }
617
618    /// `clean_graph` plus `desc:doc:DOC-A` for the doc (with a description).
619    fn desc_graph() -> Graph {
620        let mut graph = clean_graph();
621        let key = NodeKey {
622            kind: NodeKind::Doc,
623            id: "DOC-A".to_string(),
624        };
625        graph.nodes.get_mut(&key).unwrap().description = Some("要約A".to_string());
626        graph.edges.insert(
627            "desc:doc:DOC-A".to_string(),
628            Edge {
629                id: "desc:doc:DOC-A".to_string(),
630                kind: EdgeKind::Desc,
631                from: key.clone(),
632                to: key,
633            },
634        );
635        graph
636    }
637
638    // [FR-033]
639    #[test]
640    fn descエッジを指定すると現在の要約と本文のハッシュが記録される() {
641        let graph = desc_graph();
642        let result = reconcile(
643            &graph,
644            &Lock::default(),
645            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
646            None,
647            "2026-01-01T00:00:00Z",
648        )
649        .unwrap();
650        let entry = result.edges.get("desc:doc:DOC-A").expect("recorded");
651        assert_eq!(entry.from, crate::hash::content_hash("要約A"));
652        assert_eq!(entry.to, "sha256:doc-current");
653        assert_eq!(entry.by, "human");
654        assert!(!result.edges.contains_key("doc-req:DOC-A:FR-001"));
655    }
656
657    // [FR-087]
658    #[test]
659    fn descエッジは要約が無ければ空文字列のハッシュを記録する() {
660        let mut graph = desc_graph();
661        let key = NodeKey {
662            kind: NodeKind::Doc,
663            id: "DOC-A".to_string(),
664        };
665        graph.nodes.get_mut(&key).unwrap().description = None;
666        let result = reconcile(
667            &graph,
668            &Lock::default(),
669            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
670            None,
671            "2026-01-01T00:00:00Z",
672        )
673        .unwrap();
674        assert_eq!(
675            result.edges["desc:doc:DOC-A"].from,
676            crate::hash::content_hash("")
677        );
678    }
679
680    // [FR-036]
681    #[test]
682    fn allはdescエッジも記録し変更のないdescは据え置く() {
683        let graph = desc_graph();
684        let mut lock = Lock::default();
685        lock.edges.insert(
686            "desc:doc:DOC-A".to_string(),
687            lock_entry(
688                &crate::hash::content_hash("要約A"),
689                "sha256:doc-current",
690                "2025-01-01T00:00:00Z",
691            ),
692        );
693        let before = lock.edges["desc:doc:DOC-A"].clone();
694        let result = reconcile(
695            &graph,
696            &lock,
697            ReconcileTarget::All,
698            None,
699            "2026-01-01T00:00:00Z",
700        )
701        .unwrap();
702        assert!(result.edges.contains_key("doc-req:DOC-A:FR-001"));
703        assert_eq!(result.edges["desc:doc:DOC-A"], before);
704    }
705
706    // [FR-036]
707    #[test]
708    fn allはロックにないdescエッジを新規に記録する() {
709        let result = reconcile(
710            &desc_graph(),
711            &Lock::default(),
712            ReconcileTarget::All,
713            None,
714            "2026-01-01T00:00:00Z",
715        )
716        .unwrap();
717        let entry = result.edges.get("desc:doc:DOC-A").expect("desc recorded");
718        assert_eq!(entry.from, crate::hash::content_hash("要約A"));
719        assert_eq!(entry.to, "sha256:doc-current");
720        assert_eq!(entry.at, "2026-01-01T00:00:00Z");
721    }
722
723    // [FR-034]
724    #[test]
725    fn ロックにのみ存在するdescエッジを指定するとロックから削除される() {
726        let mut lock = Lock::default();
727        lock.edges.insert(
728            "desc:doc:DOC-GONE".to_string(),
729            lock_entry("sha256:old", "sha256:old", "2025-01-01T00:00:00Z"),
730        );
731        let result = reconcile(
732            &Graph::default(),
733            &lock,
734            ReconcileTarget::Edges(vec!["desc:doc:DOC-GONE".to_string()]),
735            None,
736            "2026-01-01T00:00:00Z",
737        )
738        .unwrap();
739        assert!(!result.edges.contains_key("desc:doc:DOC-GONE"));
740    }
741
742    // [FR-035]
743    #[test]
744    fn ロックにのみ存在するdescエッジのノードがinvalidならblockedになる() {
745        let mut graph = Graph::default();
746        graph.invalid.push(Invalid {
747            target_kind: TargetKind::Doc,
748            target: "DOC-A".to_string(),
749            reason: ReasonCode::DuplicateId,
750            detail: "duplicate id".to_string(),
751        });
752        let mut lock = Lock::default();
753        lock.edges.insert(
754            "desc:doc:DOC-A".to_string(),
755            lock_entry("sha256:old", "sha256:old", "2025-01-01T00:00:00Z"),
756        );
757        let before = lock.clone();
758        let result = reconcile(
759            &graph,
760            &lock,
761            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
762            None,
763            "2026-01-01T00:00:00Z",
764        );
765        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
766        assert_eq!(lock, before);
767    }
768
769    // [FR-035]
770    #[test]
771    fn invalidなdocのdescエッジを指定するとblockedになる() {
772        let mut graph = desc_graph();
773        graph.invalid.push(Invalid {
774            target_kind: TargetKind::Doc,
775            target: "DOC-A".to_string(),
776            reason: ReasonCode::DuplicateId,
777            detail: "duplicate id".to_string(),
778        });
779        let result = reconcile(
780            &graph,
781            &Lock::default(),
782            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
783            None,
784            "2026-01-01T00:00:00Z",
785        );
786        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
787    }
788
789    // [FR-035]
790    #[test]
791    fn リンク切れを持つdocのdescエッジを指定するとblockedになる() {
792        let mut graph = desc_graph();
793        graph.broken.push(crate::model::Broken {
794            from: NodeKey {
795                kind: NodeKind::Doc,
796                id: "DOC-A".to_string(),
797            },
798            target: "../req/missing.md".to_string(),
799            reason: ReasonCode::MissingReq,
800            detail: "missing".to_string(),
801        });
802        let result = reconcile(
803            &graph,
804            &Lock::default(),
805            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
806            None,
807            "2026-01-01T00:00:00Z",
808        );
809        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
810    }
811
812    // [FR-023]
813    #[test]
814    fn parse_edge_idはdescエッジを種別とidに分解する() {
815        assert_eq!(
816            parse_edge_id("desc:doc:DOC-A"),
817            Some(("desc", "DOC-A", "DOC-A"))
818        );
819        assert_eq!(
820            parse_edge_id("doc-req:DOC-A:FR-001"),
821            Some(("doc-req", "DOC-A", "FR-001"))
822        );
823    }
824}