drft-cli 0.11.0

A structural integrity checker for linked file systems
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
//! Core graph data model and JGF (JSON Graph Format) serialization.
//!
//! drft's substrate is a *set of independent graphs* (the raw view); a
//! composition step merges them by path into one graph (the composed view).
//! Both views share the same node/edge shape and both serialize to valid JGF:
//!
//! - **Composed** — a single graph: `{"graph": {...}}` ([`GraphDocument`]).
//! - **Raw** — the unmerged set: `{"graphs": [...]}` ([`GraphSet`]), JGF's
//!   multi-graph form.
//!
//! A node or edge carries a JSON object of [`Metadata`]. The keys differ by
//! view:
//!
//! - In a **raw** per-graph fragment, keys are *bare* — whatever the builder
//!   emits (e.g. `{"type": "file", "hash": "b3:…"}`).
//! - In the **composed** graph, keys are *namespaced*: an `@<graph>` object per
//!   contributing graph, plus the reserved `_graphs` provenance list.
//!
//! `@` and `_` are reserved, compose-only sigils. A graph label must not contain
//! `@` (it builds the `@<label>` namespace) or start with `_` (reserved for keys
//! like `_graphs`); interior `_` is fine. [`validate_label`],
//! [`validate_raw_metadata`], and [`validate_composed_metadata`] enforce these
//! invariants.

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::{BTreeMap, BTreeSet};

/// The reserved provenance key stamped on composed nodes and edges.
pub const PROVENANCE_KEY: &str = "_graphs";

/// The `fs` namespace key on a composed node — the base graph that carries
/// content type and hash. The single place the `@fs` literal lives.
pub const FS_NAMESPACE: &str = "@fs";

/// The `@<label>` namespace key under which a graph's contribution nests in a
/// composed node or edge. The single place the `@` prefix rule lives;
/// [`FS_NAMESPACE`] is `namespace("fs")`.
pub fn namespace(label: &str) -> String {
    format!("@{label}")
}

/// A JSON object of metadata attached to a node or edge.
///
/// `serde_json::Map` (with the default feature set) is backed by a `BTreeMap`,
/// so key order is sorted and deterministic — important for golden tests and
/// reproducible output.
pub type Metadata = Map<String, Value>;

/// A node in a graph. Its identity is its key in [`Graph::nodes`] (a path);
/// the node body carries only metadata.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Node {
    #[serde(default, skip_serializing_if = "Map::is_empty")]
    pub metadata: Metadata,
}

impl Node {
    /// A node with the given metadata object.
    pub fn new(metadata: Metadata) -> Self {
        Self { metadata }
    }

    /// This composed node's current `fs` content hash, if it has one.
    pub fn fs_hash(&self) -> Option<&str> {
        self.metadata.get(FS_NAMESPACE)?.get("hash")?.as_str()
    }

    /// This composed node's `fs` type (`file`, `symlink`, `directory`), if any.
    pub fn fs_type(&self) -> Option<&str> {
        self.metadata.get(FS_NAMESPACE)?.get("type")?.as_str()
    }

    /// Whether this composed node is resolved — present with an `@fs` block.
    /// Resolution is namespace presence.
    pub fn is_resolved(&self) -> bool {
        self.metadata.contains_key(FS_NAMESPACE)
    }
}

/// A directed edge from `source` to `target` (both node-identity paths).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Edge {
    pub source: String,
    pub target: String,
    #[serde(default, skip_serializing_if = "Map::is_empty")]
    pub metadata: Metadata,
}

impl Edge {
    /// An edge from `source` to `target` with no metadata.
    pub fn new(source: impl Into<String>, target: impl Into<String>) -> Self {
        Self {
            source: source.into(),
            target: target.into(),
            metadata: Metadata::new(),
        }
    }

    /// An edge from `source` to `target` carrying the given metadata.
    pub fn with_metadata(
        source: impl Into<String>,
        target: impl Into<String>,
        metadata: Metadata,
    ) -> Self {
        Self {
            source: source.into(),
            target: target.into(),
            metadata,
        }
    }

    /// The source line(s) where this edge's link appears, unioned across parser
    /// namespaces (`@markdown`, `@frontmatter`, …) and sorted/deduped. Empty when
    /// no parser recorded a line. The link lives in `source`, so these are
    /// positions within `source`.
    pub fn lines(&self) -> Vec<usize> {
        let mut lines = BTreeSet::new();
        for (key, value) in &self.metadata {
            if !key.starts_with('@') {
                continue;
            }
            if let Some(arr) = value.get("lines").and_then(Value::as_array) {
                for n in arr.iter().filter_map(Value::as_u64) {
                    lines.insert(n as usize);
                }
            }
        }
        lines.into_iter().collect()
    }

    /// The literal link text(s) that produced this edge, across contributing
    /// graphs. Present only where resolution moved the path, so an edge whose
    /// target is exactly what the author typed reports nothing. Two graphs can
    /// disagree — one doc may write `./x.md` where another writes `x.md`.
    pub fn raw_links(&self) -> Vec<&str> {
        let mut raws = BTreeSet::new();
        for (key, value) in &self.metadata {
            if !key.starts_with('@') {
                continue;
            }
            if let Some(raw) = value.get("raw").and_then(Value::as_str) {
                raws.insert(raw);
            }
        }
        raws.into_iter().collect()
    }
}

/// A single JGF graph. Used for both a raw per-graph fragment (with `label`
/// set to the graph name) and the composed graph (with `label` absent).
///
/// Nodes are keyed by path in a `BTreeMap` for deterministic, sorted output.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Graph {
    /// The graph name, present in a raw fragment, absent in the composed graph.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    pub directed: bool,
    #[serde(default)]
    pub nodes: BTreeMap<String, Node>,
    #[serde(default)]
    pub edges: Vec<Edge>,
}

impl Graph {
    /// An empty composed (unlabeled) directed graph.
    pub fn composed() -> Self {
        Self {
            label: None,
            directed: true,
            nodes: BTreeMap::new(),
            edges: Vec::new(),
        }
    }

    /// An empty labeled directed graph (a raw per-graph fragment).
    pub fn labeled(label: impl Into<String>) -> Self {
        Self {
            label: Some(label.into()),
            directed: true,
            nodes: BTreeMap::new(),
            edges: Vec::new(),
        }
    }

    /// Insert or replace the node at `path`.
    pub fn set_node(&mut self, path: impl Into<String>, node: Node) {
        self.nodes.insert(path.into(), node);
    }

    /// Append an edge.
    pub fn add_edge(&mut self, edge: Edge) {
        self.edges.push(edge);
    }

    /// Sort edges by `(source, target)` for deterministic output.
    pub fn sort_edges(&mut self) {
        self.edges.sort_by(|a, b| {
            a.source
                .cmp(&b.source)
                .then_with(|| a.target.cmp(&b.target))
        });
    }

    /// Wrap this graph as a composed JGF document (`{"graph": {...}}`).
    pub fn into_document(self) -> GraphDocument {
        GraphDocument { graph: self }
    }
}

/// JGF single-graph document: `{"graph": {...}}`. The composed view drft emits
/// by default.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GraphDocument {
    pub graph: Graph,
}

/// JGF multi-graph document: `{"graphs": [...]}`. The raw view drft emits under
/// `--raw` — the unmerged set of per-graph fragments.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GraphSet {
    pub graphs: Vec<Graph>,
}

impl GraphSet {
    /// A set from the given graphs.
    pub fn new(graphs: Vec<Graph>) -> Self {
        Self { graphs }
    }
}

/// An invariant violation in graph labels or metadata keys.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ValidationError {
    #[error("graph label must not be empty")]
    EmptyLabel,
    #[error("graph label '{0}' must not contain '@' or start with '_'")]
    SigilInLabel(String),
    #[error("raw metadata key '{0}' must be bare (no leading '@' or '_')")]
    SigilInRawKey(String),
    #[error(
        "composed metadata key '{0}' is invalid: expected an '@<graph>' namespace or '_graphs'"
    )]
    InvalidComposedKey(String),
    #[error("composed metadata namespace '@{0}' must name a bare graph (no '@' or '_')")]
    InvalidNamespace(String),
}

/// Validate that a graph label is non-empty and free of the reserved sigils: no
/// `@` anywhere (it builds the `@<label>` namespace) and no leading `_`
/// (reserved for keys like `_graphs`). Interior `_` is allowed.
pub fn validate_label(label: &str) -> Result<(), ValidationError> {
    if label.is_empty() {
        return Err(ValidationError::EmptyLabel);
    }
    if label.contains('@') || label.starts_with('_') {
        return Err(ValidationError::SigilInLabel(label.to_string()));
    }
    Ok(())
}

/// Validate that a raw fragment's metadata keys are all bare — no key may begin
/// with the reserved `@` or `_` sigils. Builders emit bare keys; the sigils are
/// introduced only at compose.
pub fn validate_raw_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
    for key in metadata.keys() {
        if key.starts_with('@') || key.starts_with('_') {
            return Err(ValidationError::SigilInRawKey(key.clone()));
        }
    }
    Ok(())
}

/// Validate that a composed metadata object's top-level keys are each either an
/// `@<graph>` namespace (naming a bare graph) or the reserved `_graphs` key.
pub fn validate_composed_metadata(metadata: &Metadata) -> Result<(), ValidationError> {
    for key in metadata.keys() {
        if key == PROVENANCE_KEY {
            continue;
        }
        match key.strip_prefix('@') {
            Some(name) => validate_label(name)
                .map_err(|_| ValidationError::InvalidNamespace(name.to_string()))?,
            None => return Err(ValidationError::InvalidComposedKey(key.clone())),
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn meta(value: Value) -> Metadata {
        value.as_object().unwrap().clone()
    }

    #[test]
    fn edge_lines_unions_namespaces_sorted() {
        let mut m = Metadata::new();
        m.insert("@markdown".into(), json!({ "lines": [5, 2] }));
        m.insert("@frontmatter".into(), json!({ "lines": [2, 9] }));
        m.insert("_graphs".into(), json!(["@markdown", "@frontmatter"]));
        let edge = Edge::with_metadata("a.md", "b.md", m);
        assert_eq!(edge.lines(), vec![2, 5, 9], "unioned, sorted, deduped");
        assert!(Edge::new("a.md", "b.md").lines().is_empty());
    }

    #[test]
    fn composed_document_round_trips() {
        let mut graph = Graph::composed();
        graph.set_node(
            "src/graph.rs",
            Node::new(meta(json!({
                "@fs": { "type": "file", "hash": "b3:444" },
                "_graphs": ["@fs"]
            }))),
        );
        graph.set_node(
            "docs/architecture.md",
            Node::new(meta(json!({
                "@fs": { "type": "file", "hash": "b3:222" },
                "@frontmatter": { "title": "Architecture", "status": "draft" },
                "_graphs": ["@fs", "@frontmatter"]
            }))),
        );
        graph.add_edge(Edge::with_metadata(
            "docs/architecture.md",
            "src/graph.rs",
            meta(json!({ "_graphs": ["@markdown", "@frontmatter"] })),
        ));

        let doc = graph.into_document();
        let json = serde_json::to_value(&doc).unwrap();

        // Composed envelope: top-level "graph", no "label" inside.
        assert!(json.get("graph").is_some());
        assert!(json["graph"].get("label").is_none());
        assert_eq!(json["graph"]["directed"], json!(true));

        let back: GraphDocument = serde_json::from_value(json).unwrap();
        assert_eq!(doc, back);
    }

    #[test]
    fn raw_set_round_trips() {
        let mut fs = Graph::labeled("fs");
        fs.set_node(
            "src/graph.rs",
            Node::new(meta(json!({ "type": "file", "hash": "b3:444" }))),
        );

        let mut markdown = Graph::labeled("markdown");
        markdown.add_edge(Edge::new("docs/architecture.md", "src/graph.rs"));

        let set = GraphSet::new(vec![fs, markdown]);
        let json = serde_json::to_value(&set).unwrap();

        // Raw envelope: top-level "graphs" array, each fragment labeled.
        let graphs = json["graphs"].as_array().unwrap();
        assert_eq!(graphs.len(), 2);
        assert_eq!(graphs[0]["label"], json!("fs"));
        assert_eq!(graphs[1]["label"], json!("markdown"));

        let back: GraphSet = serde_json::from_value(json).unwrap();
        assert_eq!(set, back);
    }

    #[test]
    fn empty_metadata_is_omitted() {
        let mut graph = Graph::composed();
        graph.set_node("a.md", Node::default());
        graph.add_edge(Edge::new("a.md", "b.md"));
        let json = serde_json::to_value(graph.into_document()).unwrap();
        assert!(json["graph"]["nodes"]["a.md"].get("metadata").is_none());
        assert!(json["graph"]["edges"][0].get("metadata").is_none());
    }

    #[test]
    fn node_keys_are_sorted() {
        let mut graph = Graph::composed();
        graph.set_node("z.md", Node::default());
        graph.set_node("a.md", Node::default());
        graph.set_node("m.md", Node::default());
        let json = serde_json::to_string(&graph.into_document()).unwrap();
        let a = json.find("a.md").unwrap();
        let m = json.find("m.md").unwrap();
        let z = json.find("z.md").unwrap();
        assert!(a < m && m < z, "node keys should serialize in sorted order");
    }

    #[test]
    fn validate_label_accepts_bare() {
        assert!(validate_label("fs").is_ok());
        assert!(validate_label("markdown").is_ok());
        assert!(validate_label("frontmatter").is_ok());
    }

    #[test]
    fn validate_label_rejects_sigils_and_empty() {
        assert_eq!(validate_label(""), Err(ValidationError::EmptyLabel));
        assert!(matches!(
            validate_label("@fs"),
            Err(ValidationError::SigilInLabel(_))
        ));
        assert!(matches!(
            validate_label("_internal"),
            Err(ValidationError::SigilInLabel(_))
        ));
        // Interior underscore is allowed.
        assert!(validate_label("design_docs").is_ok());
    }

    #[test]
    fn validate_raw_metadata_rejects_sigil_keys() {
        assert!(validate_raw_metadata(&meta(json!({ "type": "file" }))).is_ok());
        assert!(matches!(
            validate_raw_metadata(&meta(json!({ "@fs": {} }))),
            Err(ValidationError::SigilInRawKey(_))
        ));
        assert!(matches!(
            validate_raw_metadata(&meta(json!({ "_graphs": [] }))),
            Err(ValidationError::SigilInRawKey(_))
        ));
    }

    #[test]
    fn validate_composed_metadata_accepts_namespaces_and_provenance() {
        assert!(
            validate_composed_metadata(&meta(json!({
                "@fs": { "type": "file" },
                "_graphs": ["@fs"]
            })))
            .is_ok()
        );
    }

    #[test]
    fn validate_composed_metadata_rejects_bare_and_bad_namespace() {
        assert!(matches!(
            validate_composed_metadata(&meta(json!({ "type": "file" }))),
            Err(ValidationError::InvalidComposedKey(_))
        ));
        assert!(matches!(
            validate_composed_metadata(&meta(json!({ "@_internal": {} }))),
            Err(ValidationError::InvalidNamespace(_))
        ));
    }
}