1#[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 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
107fn 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
126fn 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 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 #[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 #[test]
253 fn ロックにのみ存在するエッジを指定するとロックから削除される() {
254 let graph = Graph::default(); 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 #[test]
273 fn ロックにのみ存在するエッジの終端がinvalidな場合はblockedとして拒否される() {
274 let graph = {
275 let mut graph = Graph::default(); 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 #[test]
304 fn ロックにのみ存在するrustのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
305 let mut graph = Graph::default(); 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 #[test]
332 fn ロックにのみ存在するgoのtest_reqエッジは所在ファイルが構文エラーだとblockedになる() {
333 let mut graph = Graph::default(); 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 #[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 #[test]
375 fn 対象reqがinvalidに関わるエッジはreconcileを拒否される() {
376 let mut graph = clean_graph();
377 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 }
395
396 #[test]
398 fn allは変更のないエッジを据え置き_新規を追加し_消えたエッジを削除する() {
399 let mut graph = clean_graph();
400 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 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 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 let kept = result.edges.get("doc-req:DOC-A:FR-001").unwrap();
455 assert_eq!(kept.at, "2020-01-01T00:00:00Z");
456
457 assert!(!result.edges.contains_key("doc-req:DOC-STALE:FR-999"));
459
460 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 #[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 assert_eq!(lock, before);
498 }
499
500 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}