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 / TypeScript / JavaScript: the target equals
134///   `<prefix>.rs` / `<prefix>.cs` / `<prefix>.java` / `<prefix>.kt` /
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            || crate::extract::TYPESCRIPT_TEST_EXTENSIONS
151                .iter()
152                .any(|ext| target == format!("{prefix}.{ext}"))
153        {
154            return true;
155        }
156        if prefix == "." {
157            !target.contains('/')
158        } else {
159            target.starts_with(&format!("{prefix}/"))
160        }
161    })
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::lock::{Lock, LockEntry};
168    use crate::model::{
169        Edge, EdgeKind, Graph, Invalid, Node, NodeKey, NodeKind, ReasonCode, TargetKind,
170    };
171
172    fn node(kind: NodeKind, id: &str, hash: &str) -> Node {
173        Node {
174            key: NodeKey {
175                kind,
176                id: id.to_string(),
177            },
178            path: format!("{id}.md"),
179            content: String::new(),
180            hash: hash.to_string(),
181            description: None,
182        }
183    }
184
185    fn lock_entry(from: &str, to: &str, at: &str) -> LockEntry {
186        LockEntry {
187            from: from.to_string(),
188            to: to.to_string(),
189            by: "human".to_string(),
190            reason: None,
191            score: None,
192            at: at.to_string(),
193        }
194    }
195
196    /// A clean graph with a single doc-req edge, no broken/invalid entries.
197    fn clean_graph() -> Graph {
198        let mut graph = Graph::default();
199        let doc_key = NodeKey {
200            kind: NodeKind::Doc,
201            id: "DOC-A".to_string(),
202        };
203        let req_key = NodeKey {
204            kind: NodeKind::Req,
205            id: "FR-001".to_string(),
206        };
207        graph.nodes.insert(
208            doc_key.clone(),
209            node(NodeKind::Doc, "DOC-A", "sha256:doc-current"),
210        );
211        graph.nodes.insert(
212            req_key.clone(),
213            node(NodeKind::Req, "FR-001", "sha256:req-current"),
214        );
215        let edge_id = "doc-req:DOC-A:FR-001".to_string();
216        graph.edges.insert(
217            edge_id.clone(),
218            Edge {
219                id: edge_id,
220                kind: EdgeKind::DocReq,
221                from: doc_key,
222                to: req_key,
223            },
224        );
225        graph
226    }
227
228    // [FR-033]
229    #[test]
230    fn edgeを指定すると現在の両端のハッシュがロックに記録される() {
231        let graph = clean_graph();
232        let lock = Lock::default();
233        let result = reconcile(
234            &graph,
235            &lock,
236            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
237            None,
238            "2026-01-01T00:00:00Z",
239        )
240        .unwrap();
241        let entry = result
242            .edges
243            .get("doc-req:DOC-A:FR-001")
244            .expect("edge should be recorded");
245        assert_eq!(entry.from, "sha256:doc-current");
246        assert_eq!(entry.to, "sha256:req-current");
247        assert_eq!(entry.by, "human");
248    }
249
250    // [FR-034]
251    #[test]
252    fn ロックにのみ存在するエッジを指定するとロックから削除される() {
253        let graph = Graph::default(); // グラフには存在しない
254        let mut lock = Lock::default();
255        lock.edges.insert(
256            "doc-req:DOC-A:FR-001".to_string(),
257            lock_entry("sha256:old-doc", "sha256:old-req", "2025-01-01T00:00:00Z"),
258        );
259        let result = reconcile(
260            &graph,
261            &lock,
262            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
263            None,
264            "2026-01-01T00:00:00Z",
265        )
266        .unwrap();
267        assert!(!result.edges.contains_key("doc-req:DOC-A:FR-001"));
268    }
269
270    // [FR-035]
271    #[test]
272    fn ロックにのみ存在するエッジの終端がinvalidな場合はblockedとして拒否される() {
273        let graph = {
274            let mut graph = Graph::default(); // FR-001 は重複idによりグラフから消えている
275            graph.invalid.push(Invalid {
276                target_kind: TargetKind::Req,
277                target: "FR-001".to_string(),
278                reason: ReasonCode::DuplicateId,
279                detail: "duplicate id".to_string(),
280            });
281            graph
282        };
283        let mut lock = Lock::default();
284        lock.edges.insert(
285            "doc-req:DOC-A:FR-001".to_string(),
286            lock_entry("sha256:old-doc", "sha256:old-req", "2025-01-01T00:00:00Z"),
287        );
288        let before = lock.clone();
289
290        let result = reconcile(
291            &graph,
292            &lock,
293            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
294            None,
295            "2026-01-01T00:00:00Z",
296        );
297        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
298        assert_eq!(lock, before);
299    }
300
301    // [FR-035]
302    #[test]
303    fn ロックにのみ存在するrustのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
304        let mut graph = Graph::default(); // テストは構文エラーによりグラフから消えている
305        graph.invalid.push(Invalid {
306            target_kind: TargetKind::File,
307            target: "tests/sample.rs".to_string(),
308            reason: ReasonCode::ParseError,
309            detail: "parse error".to_string(),
310        });
311        let mut lock = Lock::default();
312        lock.edges.insert(
313            "test-req:tests/sample::t:FR-001".to_string(),
314            lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
315        );
316        let before = lock.clone();
317
318        let result = reconcile(
319            &graph,
320            &lock,
321            ReconcileTarget::Edges(vec!["test-req:tests/sample::t:FR-001".to_string()]),
322            None,
323            "2026-01-01T00:00:00Z",
324        );
325        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
326        assert_eq!(lock, before);
327    }
328
329    // [FR-035]
330    #[test]
331    fn ロックにのみ存在するgoのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
332        let mut graph = Graph::default(); // テストは構文エラーによりグラフから消えている
333        graph.invalid.push(Invalid {
334            target_kind: TargetKind::File,
335            target: "internal/auth/login_test.go".to_string(),
336            reason: ReasonCode::ParseError,
337            detail: "parse error".to_string(),
338        });
339        let mut lock = Lock::default();
340        lock.edges.insert(
341            "test-req:internal/auth:TestX:FR-001".to_string(),
342            lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
343        );
344        let before = lock.clone();
345
346        let result = reconcile(
347            &graph,
348            &lock,
349            ReconcileTarget::Edges(vec!["test-req:internal/auth:TestX:FR-001".to_string()]),
350            None,
351            "2026-01-01T00:00:00Z",
352        );
353        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
354        assert_eq!(lock, before);
355    }
356
357    // [FR-034]
358    #[test]
359    fn ロックにもグラフにも存在しないエッジを指定するとエラーになる() {
360        let graph = Graph::default();
361        let lock = Lock::default();
362        let result = reconcile(
363            &graph,
364            &lock,
365            ReconcileTarget::Edges(vec!["doc-req:NOWHERE:FR-999".to_string()]),
366            None,
367            "2026-01-01T00:00:00Z",
368        );
369        assert!(matches!(result, Err(ReconcileError::UnknownEdge(_))));
370    }
371
372    // [FR-035]
373    #[test]
374    fn 対象reqがinvalidに関わるエッジはreconcileを拒否される() {
375        let mut graph = clean_graph();
376        // FR-001 は id が重複しており invalid として報告されている
377        graph.invalid.push(Invalid {
378            target_kind: TargetKind::Req,
379            target: "FR-001".to_string(),
380            reason: ReasonCode::DuplicateId,
381            detail: "duplicate id".to_string(),
382        });
383        let lock = Lock::default();
384        let result = reconcile(
385            &graph,
386            &lock,
387            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
388            None,
389            "2026-01-01T00:00:00Z",
390        );
391        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
392        // ロックは変更されていないはず(今回は空のロックのまま)
393    }
394
395    // [FR-036]
396    #[test]
397    fn allは変更のないエッジを据え置き_新規を追加し_消えたエッジを削除する() {
398        let mut graph = clean_graph();
399        // 現在のグラフに存在する新規エッジ(reqにのみ紐づくtest-reqエッジ)
400        let test_key = NodeKey {
401            kind: NodeKind::Test,
402            id: "src/foo::bar".to_string(),
403        };
404        graph.nodes.insert(
405            test_key.clone(),
406            node(NodeKind::Test, "src/foo::bar", "sha256:test-current"),
407        );
408        let req_key = NodeKey {
409            kind: NodeKind::Req,
410            id: "FR-001".to_string(),
411        };
412        let new_edge_id = "test-req:src/foo::bar:FR-001".to_string();
413        graph.edges.insert(
414            new_edge_id.clone(),
415            Edge {
416                id: new_edge_id.clone(),
417                kind: EdgeKind::TestReq,
418                from: test_key,
419                to: req_key,
420            },
421        );
422
423        let mut lock = Lock::default();
424        // 既に合意済みで、ハッシュが変化していないエッジ(据え置かれるはず)
425        lock.edges.insert(
426            "doc-req:DOC-A:FR-001".to_string(),
427            lock_entry(
428                "sha256:doc-current",
429                "sha256:req-current",
430                "2020-01-01T00:00:00Z",
431            ),
432        );
433        // ソースからは既に消えたエッジ(削除されるはず)
434        lock.edges.insert(
435            "doc-req:DOC-STALE:FR-999".to_string(),
436            lock_entry(
437                "sha256:stale-doc",
438                "sha256:stale-req",
439                "2020-01-01T00:00:00Z",
440            ),
441        );
442
443        let result = reconcile(
444            &graph,
445            &lock,
446            ReconcileTarget::All,
447            None,
448            "2026-01-01T00:00:00Z",
449        )
450        .unwrap();
451
452        // 据え置き: 変更なしのエッジは元のエントリのまま(at が上書きされない)
453        let kept = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
454        assert_eq!(kept.at, "2020-01-01T00:00:00Z");
455
456        // 削除: ソースから消えたエッジはロックに残らない
457        assert!(!result.edges.contains_key("doc-req:DOC-STALE:FR-999"));
458
459        // 追加: 新規エッジが記録される
460        let added = result.edges.get("test-req:src/foo::bar:FR-001").unwrap();
461        assert_eq!(added.from, "sha256:test-current");
462        assert_eq!(added.to, "sha256:req-current");
463        assert_eq!(added.by, "human");
464    }
465
466    // [FR-036]
467    #[test]
468    fn 対象にbrokenまたはinvalidが1件でもあればallは全く変更せず失敗する() {
469        let mut graph = clean_graph();
470        graph.invalid.push(Invalid {
471            target_kind: TargetKind::Req,
472            target: "FR-999".to_string(),
473            reason: ReasonCode::MissingId,
474            detail: "missing id".to_string(),
475        });
476        let mut lock = Lock::default();
477        lock.edges.insert(
478            "doc-req:DOC-A:FR-001".to_string(),
479            lock_entry(
480                "sha256:doc-current",
481                "sha256:req-current",
482                "2020-01-01T00:00:00Z",
483            ),
484        );
485        let before = lock.clone();
486
487        let result = reconcile(
488            &graph,
489            &lock,
490            ReconcileTarget::All,
491            None,
492            "2026-01-01T00:00:00Z",
493        );
494        assert!(matches!(result, Err(ReconcileError::GraphNotClean)));
495        // 呼び出し元のロックは reconcile によって書き換えられていない(純粋関数なので当然だが明示する)
496        assert_eq!(lock, before);
497    }
498
499    // [FR-037]
500    #[test]
501    fn reasonを指定した場合はエントリに記録される() {
502        let graph = clean_graph();
503        let result = reconcile(
504            &graph,
505            &Lock::default(),
506            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
507            Some("最小長の記述が一致".to_string()),
508            "2026-01-01T00:00:00Z",
509        )
510        .unwrap();
511        let entry = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
512        assert_eq!(entry.by, "human");
513        assert_eq!(entry.reason, Some("最小長の記述が一致".to_string()));
514    }
515
516    // [FR-037]
517    #[test]
518    fn reasonを指定しない場合は記録されない() {
519        let graph = clean_graph();
520        let result = reconcile(
521            &graph,
522            &Lock::default(),
523            ReconcileTarget::Edges(vec!["doc-req:DOC-A:FR-001".to_string()]),
524            None,
525            "2026-01-01T00:00:00Z",
526        )
527        .unwrap();
528        let entry = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
529        assert_eq!(entry.by, "human");
530        assert_eq!(entry.reason, None);
531    }
532
533    fn assert_blocked_by_parse_error(file: &str, edge: &str) {
534        let mut graph = Graph::default();
535        graph.invalid.push(Invalid {
536            target_kind: TargetKind::File,
537            target: file.to_string(),
538            reason: ReasonCode::ParseError,
539            detail: "parse error".to_string(),
540        });
541        let mut lock = Lock::default();
542        lock.edges.insert(
543            edge.to_string(),
544            lock_entry("sha256:old-test", "sha256:old-req", "2025-01-01T00:00:00Z"),
545        );
546        let before = lock.clone();
547
548        let result = reconcile(
549            &graph,
550            &lock,
551            ReconcileTarget::Edges(vec![edge.to_string()]),
552            None,
553            "2026-01-01T00:00:00Z",
554        );
555        assert!(matches!(result, Err(ReconcileError::Blocked(_))), "{edge}");
556        assert_eq!(lock, before);
557    }
558
559    // [FR-035]
560    #[test]
561    fn ロックにのみ存在するcsharpのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
562        assert_blocked_by_parse_error(
563            "tests/AuthTests.cs",
564            "test-req:tests/AuthTests:AuthTests:T:FR-001",
565        );
566    }
567
568    // [FR-035]
569    #[test]
570    fn ロックにのみ存在するjavaのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
571        assert_blocked_by_parse_error(
572            "tests/AuthTests.java",
573            "test-req:tests/AuthTests:AuthTests:t:FR-001",
574        );
575    }
576
577    // [FR-035]
578    #[test]
579    fn ロックにのみ存在するkotlinのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
580        assert_blocked_by_parse_error(
581            "tests/AuthTest.kt",
582            "test-req:tests/AuthTest:AuthTest:一覧 取得:FR-001",
583        );
584    }
585
586    // [FR-035]
587    #[test]
588    fn ロックにのみ存在するtypescriptのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
589        assert_blocked_by_parse_error("src/a.test.ts", "test-req:src/a.test:d:t:FR-001");
590        assert_blocked_by_parse_error("src/a.spec.ts", "test-req:src/a.spec:d:t:FR-001");
591    }
592
593    // [FR-035]
594    #[test]
595    fn ロックにのみ存在するtsxのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
596        assert_blocked_by_parse_error("src/a.test.tsx", "test-req:src/a.test:d:t:FR-001");
597        assert_blocked_by_parse_error("src/a.spec.tsx", "test-req:src/a.spec::t:FR-001");
598    }
599
600    // [FR-035]
601    #[test]
602    fn ロックにのみ存在するjsのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
603        assert_blocked_by_parse_error("src/a.test.js", "test-req:src/a.test:d:t:FR-001");
604        assert_blocked_by_parse_error("src/a.test.mjs", "test-req:src/a.test:d:t:FR-001");
605        assert_blocked_by_parse_error("src/a.spec.cjs", "test-req:src/a.spec:d:t:FR-001");
606        assert_blocked_by_parse_error("src/a.spec.mts", "test-req:src/a.spec:d:t:FR-001");
607        assert_blocked_by_parse_error("src/a.test.cts", "test-req:src/a.test:d:t:FR-001");
608        assert_blocked_by_parse_error("src/a.spec.jsx", "test-req:src/a.spec:d:t:FR-001");
609    }
610
611    /// `clean_graph` plus `desc:doc:DOC-A` for the doc (with a description).
612    fn desc_graph() -> Graph {
613        let mut graph = clean_graph();
614        let key = NodeKey {
615            kind: NodeKind::Doc,
616            id: "DOC-A".to_string(),
617        };
618        graph.nodes.get_mut(&key).unwrap().description = Some("要約A".to_string());
619        graph.edges.insert(
620            "desc:doc:DOC-A".to_string(),
621            Edge {
622                id: "desc:doc:DOC-A".to_string(),
623                kind: EdgeKind::Desc,
624                from: key.clone(),
625                to: key,
626            },
627        );
628        graph
629    }
630
631    // [FR-033]
632    #[test]
633    fn descエッジを指定すると現在の要約と本文のハッシュが記録される() {
634        let graph = desc_graph();
635        let result = reconcile(
636            &graph,
637            &Lock::default(),
638            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
639            None,
640            "2026-01-01T00:00:00Z",
641        )
642        .unwrap();
643        let entry = result.edges.get("desc:doc:DOC-A").expect("recorded");
644        assert_eq!(entry.from, crate::hash::content_hash("要約A"));
645        assert_eq!(entry.to, "sha256:doc-current");
646        assert_eq!(entry.by, "human");
647        assert!(!result.edges.contains_key("doc-req:DOC-A:FR-001"));
648    }
649
650    // [FR-087]
651    #[test]
652    fn descエッジは要約が無ければ空文字列のハッシュを記録する() {
653        let mut graph = desc_graph();
654        let key = NodeKey {
655            kind: NodeKind::Doc,
656            id: "DOC-A".to_string(),
657        };
658        graph.nodes.get_mut(&key).unwrap().description = None;
659        let result = reconcile(
660            &graph,
661            &Lock::default(),
662            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
663            None,
664            "2026-01-01T00:00:00Z",
665        )
666        .unwrap();
667        assert_eq!(
668            result.edges["desc:doc:DOC-A"].from,
669            crate::hash::content_hash("")
670        );
671    }
672
673    // [FR-036]
674    #[test]
675    fn allはdescエッジも記録し変更のないdescは据え置く() {
676        let graph = desc_graph();
677        let mut lock = Lock::default();
678        lock.edges.insert(
679            "desc:doc:DOC-A".to_string(),
680            lock_entry(
681                &crate::hash::content_hash("要約A"),
682                "sha256:doc-current",
683                "2025-01-01T00:00:00Z",
684            ),
685        );
686        let before = lock.edges["desc:doc:DOC-A"].clone();
687        let result = reconcile(
688            &graph,
689            &lock,
690            ReconcileTarget::All,
691            None,
692            "2026-01-01T00:00:00Z",
693        )
694        .unwrap();
695        assert!(result.edges.contains_key("doc-req:DOC-A:FR-001"));
696        assert_eq!(result.edges["desc:doc:DOC-A"], before);
697    }
698
699    // [FR-036]
700    #[test]
701    fn allはロックにないdescエッジを新規に記録する() {
702        let result = reconcile(
703            &desc_graph(),
704            &Lock::default(),
705            ReconcileTarget::All,
706            None,
707            "2026-01-01T00:00:00Z",
708        )
709        .unwrap();
710        let entry = result.edges.get("desc:doc:DOC-A").expect("desc recorded");
711        assert_eq!(entry.from, crate::hash::content_hash("要約A"));
712        assert_eq!(entry.to, "sha256:doc-current");
713        assert_eq!(entry.at, "2026-01-01T00:00:00Z");
714    }
715
716    // [FR-034]
717    #[test]
718    fn ロックにのみ存在するdescエッジを指定するとロックから削除される() {
719        let mut lock = Lock::default();
720        lock.edges.insert(
721            "desc:doc:DOC-GONE".to_string(),
722            lock_entry("sha256:old", "sha256:old", "2025-01-01T00:00:00Z"),
723        );
724        let result = reconcile(
725            &Graph::default(),
726            &lock,
727            ReconcileTarget::Edges(vec!["desc:doc:DOC-GONE".to_string()]),
728            None,
729            "2026-01-01T00:00:00Z",
730        )
731        .unwrap();
732        assert!(!result.edges.contains_key("desc:doc:DOC-GONE"));
733    }
734
735    // [FR-035]
736    #[test]
737    fn ロックにのみ存在するdescエッジのノードがinvalidならblockedになる() {
738        let mut graph = Graph::default();
739        graph.invalid.push(Invalid {
740            target_kind: TargetKind::Doc,
741            target: "DOC-A".to_string(),
742            reason: ReasonCode::DuplicateId,
743            detail: "duplicate id".to_string(),
744        });
745        let mut lock = Lock::default();
746        lock.edges.insert(
747            "desc:doc:DOC-A".to_string(),
748            lock_entry("sha256:old", "sha256:old", "2025-01-01T00:00:00Z"),
749        );
750        let before = lock.clone();
751        let result = reconcile(
752            &graph,
753            &lock,
754            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
755            None,
756            "2026-01-01T00:00:00Z",
757        );
758        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
759        assert_eq!(lock, before);
760    }
761
762    // [FR-035]
763    #[test]
764    fn invalidなdocのdescエッジを指定するとblockedになる() {
765        let mut graph = desc_graph();
766        graph.invalid.push(Invalid {
767            target_kind: TargetKind::Doc,
768            target: "DOC-A".to_string(),
769            reason: ReasonCode::DuplicateId,
770            detail: "duplicate id".to_string(),
771        });
772        let result = reconcile(
773            &graph,
774            &Lock::default(),
775            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
776            None,
777            "2026-01-01T00:00:00Z",
778        );
779        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
780    }
781
782    // [FR-035]
783    #[test]
784    fn リンク切れを持つdocのdescエッジを指定するとblockedになる() {
785        let mut graph = desc_graph();
786        graph.broken.push(crate::model::Broken {
787            from: NodeKey {
788                kind: NodeKind::Doc,
789                id: "DOC-A".to_string(),
790            },
791            target: "../req/missing.md".to_string(),
792            reason: ReasonCode::MissingReq,
793            detail: "missing".to_string(),
794        });
795        let result = reconcile(
796            &graph,
797            &Lock::default(),
798            ReconcileTarget::Edges(vec!["desc:doc:DOC-A".to_string()]),
799            None,
800            "2026-01-01T00:00:00Z",
801        );
802        assert!(matches!(result, Err(ReconcileError::Blocked(_))));
803    }
804
805    // [FR-023]
806    #[test]
807    fn parse_edge_idはdescエッジを種別とidに分解する() {
808        assert_eq!(
809            parse_edge_id("desc:doc:DOC-A"),
810            Some(("desc", "DOC-A", "DOC-A"))
811        );
812        assert_eq!(
813            parse_edge_id("doc-req:DOC-A:FR-001"),
814            Some(("doc-req", "DOC-A", "FR-001"))
815        );
816    }
817}