Skip to main content

miwa/
model.rs

1//! Graph domain model: nodes, edges, and validity/brokenness findings.
2
3#[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    /// Normalized front matter `description` (Doc / Req only).
27    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
47/// `"doc-req:<from>:<to>"` / `"test-req:<from>:<to>"`. `Desc` edges have a
48/// different shape; use [`desc_edge_id`] for them.
49pub 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
58/// `"desc:doc:<id>"` for a Doc node, `"desc:req:<id>"` for a Req node. The
59/// node kind is part of the id because a Doc and a Req may share an `id`.
60/// Test nodes never have a `desc` edge.
61pub 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    /// The normalized `description`, or `""` when there is none.
72    pub fn description_text(&self) -> &str {
73        self.description.as_deref().unwrap_or("")
74    }
75}
76
77/// The `(from, to)` hashes recorded in the lock for `edge`. DocReq / TestReq
78/// use the hashes of both endpoint nodes (empty if the node is missing);
79/// Desc uses `(hash of the description, hash of the body)` of the from node.
80/// Every edge in `graph.edges` has its nodes, so the missing-node results
81/// are unreachable in practice.
82pub 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
104/// `id` is non-empty and contains only `[A-Za-z0-9._-]`.
105pub 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/// `target` is a req id or a link-target path.
166#[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    // [FR-023]
188    #[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    // [FR-023]
197    #[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    // [FR-006]
206    #[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    // [FR-006]
214    #[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    // [FR-023]
223    #[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    // [FR-087]
268    #[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    // [FR-087]
281    #[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    // [FR-087]
291    #[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    // [FR-087]
302    #[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}