1#[derive(
4 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
5)]
6pub enum NodeKind {
7 Doc,
8 Req,
9 Test,
10}
11
12#[derive(
13 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
14)]
15pub struct NodeKey {
16 pub kind: NodeKind,
17 pub id: String,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Node {
22 pub key: NodeKey,
23 pub path: String,
24 pub content: String,
25 pub hash: String,
26 pub description: Option<String>,
28}
29
30#[derive(
31 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
32)]
33pub enum EdgeKind {
34 DocReq,
35 TestReq,
36 Desc,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Edge {
41 pub id: String,
42 pub kind: EdgeKind,
43 pub from: NodeKey,
44 pub to: NodeKey,
45}
46
47pub fn edge_id(kind: EdgeKind, from_id: &str, to_id: &str) -> String {
50 let prefix = match kind {
51 EdgeKind::DocReq => "doc-req",
52 EdgeKind::TestReq => "test-req",
53 EdgeKind::Desc => unreachable!("use desc_edge_id"),
54 };
55 format!("{prefix}:{from_id}:{to_id}")
56}
57
58pub fn desc_edge_id(key: &NodeKey) -> String {
62 let prefix = match key.kind {
63 NodeKind::Doc => "desc:doc",
64 NodeKind::Req => "desc:req",
65 NodeKind::Test => unreachable!("test nodes have no desc edge"),
66 };
67 format!("{prefix}:{}", key.id)
68}
69
70impl Node {
71 pub fn description_text(&self) -> &str {
73 self.description.as_deref().unwrap_or("")
74 }
75}
76
77pub fn edge_hashes(graph: &Graph, edge: &Edge) -> (String, String) {
83 match edge.kind {
84 EdgeKind::DocReq | EdgeKind::TestReq => {
85 let h = |k: &NodeKey| {
86 graph
87 .nodes
88 .get(k)
89 .map(|n| n.hash.clone())
90 .unwrap_or_default()
91 };
92 (h(&edge.from), h(&edge.to))
93 }
94 EdgeKind::Desc => match graph.nodes.get(&edge.from) {
95 Some(node) => (
96 crate::hash::content_hash(node.description_text()),
97 node.hash.clone(),
98 ),
99 None => (String::new(), String::new()),
100 },
101 }
102}
103
104pub fn is_valid_id(id: &str) -> bool {
106 !id.is_empty()
107 && id
108 .chars()
109 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
110}
111
112#[derive(
113 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
114)]
115pub enum TargetKind {
116 Doc,
117 Req,
118 Test,
119 Edge,
120 File,
121 Index,
122}
123
124#[derive(
125 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
126)]
127pub enum ReasonCode {
128 DocChanged,
129 ReqChanged,
130 TestChanged,
131 BothChanged,
132 NewEdge,
133 RemovedEdge,
134 MissingReq,
135 NotReqFile,
136 ReqWithoutDoc,
137 DocWithoutReq,
138 ReqWithoutTest,
139 DuplicateId,
140 MissingId,
141 InvalidId,
142 MultipleMarkers,
143 ParseError,
144 MissingIndex,
145 NotListed,
146 UntypedFile,
147 BrokenEntry,
148 TitleMismatch,
149 DescriptionMismatch,
150 MissingDescription,
151 MissingDirectoryDescription,
152 UnlinkedIndex,
153 DescriptionChanged,
154 BodyChanged,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
158pub struct Invalid {
159 pub target_kind: TargetKind,
160 pub target: String,
161 pub reason: ReasonCode,
162 pub detail: String,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
167pub struct Broken {
168 pub from: NodeKey,
169 pub target: String,
170 pub reason: ReasonCode,
171 pub detail: String,
172}
173
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct Graph {
176 pub nodes: std::collections::BTreeMap<NodeKey, Node>,
177 pub edges: std::collections::BTreeMap<String, Edge>,
178 pub invalid: Vec<Invalid>,
179 pub broken: Vec<Broken>,
180 pub index_issues: Vec<crate::index::IndexIssue>,
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
189 fn edge_idはdoc_reqの接頭辞と両端のidを持つ() {
190 assert_eq!(
191 edge_id(EdgeKind::DocReq, "DOC-AUTH", "FR-001"),
192 "doc-req:DOC-AUTH:FR-001"
193 );
194 }
195
196 #[test]
198 fn edge_idはtest_reqの接頭辞と両端のidを持つ() {
199 assert_eq!(
200 edge_id(EdgeKind::TestReq, "internal/auth:TestLogin", "FR-001"),
201 "test-req:internal/auth:TestLogin:FR-001"
202 );
203 }
204
205 #[test]
207 fn is_valid_idは英数字とドット_アンダースコア_ハイフンのみ許可する() {
208 assert!(is_valid_id("FR-001"));
209 assert!(is_valid_id("DOC_AUTH.v2"));
210 assert!(is_valid_id("a"));
211 }
212
213 #[test]
215 fn is_valid_idはコロンや空白_日本語_空文字を拒否する() {
216 assert!(!is_valid_id("FR:001"));
217 assert!(!is_valid_id("FR 001"));
218 assert!(!is_valid_id("要件001"));
219 assert!(!is_valid_id(""));
220 }
221
222 #[test]
224 fn desc_edge_idはノード種別の接頭辞とノードのidを持つ() {
225 let key = |kind, id: &str| NodeKey {
226 kind,
227 id: id.to_string(),
228 };
229 assert_eq!(desc_edge_id(&key(NodeKind::Doc, "DOC-A")), "desc:doc:DOC-A");
230 assert_eq!(
231 desc_edge_id(&key(NodeKind::Req, "FR-001")),
232 "desc:req:FR-001"
233 );
234 assert_ne!(
235 desc_edge_id(&key(NodeKind::Doc, "X-1")),
236 desc_edge_id(&key(NodeKind::Req, "X-1"))
237 );
238 }
239
240 fn desc_node(description: Option<&str>, hash: &str) -> Node {
241 Node {
242 key: NodeKey {
243 kind: NodeKind::Doc,
244 id: "DOC-A".to_string(),
245 },
246 path: "docs/a.md".to_string(),
247 content: "本文".to_string(),
248 hash: hash.to_string(),
249 description: description.map(str::to_string),
250 }
251 }
252
253 fn desc_graph(description: Option<&str>) -> (Graph, Edge) {
254 let node = desc_node(description, "sha256:body");
255 let edge = Edge {
256 id: "desc:doc:DOC-A".to_string(),
257 kind: EdgeKind::Desc,
258 from: node.key.clone(),
259 to: node.key.clone(),
260 };
261 let mut graph = Graph::default();
262 graph.nodes.insert(node.key.clone(), node);
263 graph.edges.insert(edge.id.clone(), edge.clone());
264 (graph, edge)
265 }
266
267 #[test]
269 fn edge_hashesのdescは要約のハッシュと本文のハッシュを返す() {
270 let (graph, edge) = desc_graph(Some("要約です"));
271 assert_eq!(
272 edge_hashes(&graph, &edge),
273 (
274 crate::hash::content_hash("要約です"),
275 "sha256:body".to_string()
276 )
277 );
278 }
279
280 #[test]
282 fn edge_hashesのdescは要約が無ければ空文字列のハッシュをfromにする() {
283 let (graph, edge) = desc_graph(None);
284 assert_eq!(
285 edge_hashes(&graph, &edge),
286 (crate::hash::content_hash(""), "sha256:body".to_string())
287 );
288 }
289
290 #[test]
292 fn edge_hashesのdescは要約を変えるとfromだけが変わる() {
293 let (g1, e1) = desc_graph(Some("旧"));
294 let (g2, e2) = desc_graph(Some("新"));
295 let (f1, t1) = edge_hashes(&g1, &e1);
296 let (f2, t2) = edge_hashes(&g2, &e2);
297 assert_ne!(f1, f2);
298 assert_eq!(t1, t2);
299 }
300
301 #[test]
303 fn edge_hashesのdoc_reqは要約を変えても変わらない() {
304 let build = |description: Option<&str>| {
305 let mut graph = Graph::default();
306 let doc = desc_node(description, "sha256:doc");
307 let req = Node {
308 key: NodeKey {
309 kind: NodeKind::Req,
310 id: "FR-001".to_string(),
311 },
312 path: "req/fr1.md".to_string(),
313 content: String::new(),
314 hash: "sha256:req".to_string(),
315 description: None,
316 };
317 let edge = Edge {
318 id: "doc-req:DOC-A:FR-001".to_string(),
319 kind: EdgeKind::DocReq,
320 from: doc.key.clone(),
321 to: req.key.clone(),
322 };
323 graph.nodes.insert(doc.key.clone(), doc);
324 graph.nodes.insert(req.key.clone(), req);
325 (graph, edge)
326 };
327 let (g1, e1) = build(Some("旧"));
328 let (g2, e2) = build(Some("新"));
329 assert_eq!(edge_hashes(&g1, &e1), edge_hashes(&g2, &e2));
330 assert_eq!(
331 edge_hashes(&g1, &e1),
332 ("sha256:doc".to_string(), "sha256:req".to_string())
333 );
334 }
335}