spec_spine_types/unit.rs
1//! The authority-unit grammar.
2//!
3//! A spec declares the units it owns via a `unit:` on a typed edge. The grammar
4//! resolves six granularities: [`Unit::File`], [`Unit::Section`],
5//! [`Unit::Symbol`], [`Unit::Directory`], [`Unit::Crate`], and [`Unit::Module`]
6//! (ported from OAP `spec-types::LogicalUnit`). All six are implemented:
7//! `file`/`section`/`symbol` shipped first, and `directory`/`crate`/`module`
8//! landed in spec 017 (originally reserved in `docs/design/00-architecture.md`
9//! ยง2.2 Q5). They were a MINOR bump because the schema is permissive on the unit
10//! payload (no schema-file edit), and a bare string remains shorthand for a file
11//! unit (a trailing-slash path denotes a directory subtree).
12
13use serde::de::{self, Deserializer};
14use serde::{Deserialize, Serialize};
15
16/// An authority unit: the granularity at which a spec claims ownership.
17///
18/// Serializes internally-tagged on `kind` (e.g. `{ "kind": "file", "path": ... }`).
19/// Deserializes from that tagged map, a bare string (= a file unit), **or** a
20/// `{ unit: <unit> }` wrapper (spec 015 sugar), so authors can write
21/// `establishes: ["src/lib.rs"]` or `establishes: [{ unit: "src/lib.rs" }]`
22/// interchangeably; all three normalize to the same unit.
23#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
24#[serde(tag = "kind", rename_all = "kebab-case")]
25pub enum Unit {
26 /// A file path (bare string shorthand resolves here). A trailing `/` denotes
27 /// the directory subtree rooted at `path`.
28 File { path: String },
29 /// A named section within a file: a Makefile target, a Markdown heading slug,
30 /// a `region:` marker, or a CI `jobs.<name>`.
31 Section { file: String, anchor: String },
32 /// A symbol (function / type / export), resolved by the indexer via
33 /// tree-sitter (Rust `.rs` and TypeScript `.ts`/`.tsx`).
34 Symbol { id: String },
35 /// A directory subtree, named explicitly (`{ kind: directory, path }`). The
36 /// subtree-prefix resolution is identical to a trailing-slash file unit; the
37 /// distinct kind preserves the author's intent across the round-trip (spec
38 /// 017). Resolves to the directory path; the gate prefix-matches it.
39 Directory { path: String },
40 /// A compilation unit by its manifest name (Cargo `[package].name` or npm
41 /// `package.json:name`), resolved against the discovered package inventory to
42 /// the package directory subtree (spec 017).
43 Crate { id: String },
44 /// A module by its `::`-qualified path (e.g. `my_crate::serialization`),
45 /// resolved by the indexer's Rust module index: file-modules (whole file)
46 /// and top-level inline `mod` blocks (line-span) (spec 017).
47 Module { id: String },
48}
49
50impl Unit {
51 /// A file unit from a path (the bare-string shorthand target).
52 pub fn file(path: impl Into<String>) -> Self {
53 Unit::File { path: path.into() }
54 }
55
56 /// True if this unit resolves to a directory subtree: a file unit whose path
57 /// ends `/`, or an explicit [`Unit::Directory`] (spec 017).
58 pub fn is_directory_subtree(&self) -> bool {
59 match self {
60 Unit::File { path } => path.ends_with('/'),
61 Unit::Directory { .. } => true,
62 _ => false,
63 }
64 }
65}
66
67impl<'de> Deserialize<'de> for Unit {
68 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69 where
70 D: Deserializer<'de>,
71 {
72 // Accept a bare string (-> file unit), the tagged map form, or the
73 // `{ unit: <unit> }` wrapper (spec 015).
74 #[derive(Deserialize)]
75 #[serde(untagged)]
76 enum Repr {
77 Bare(String),
78 Tagged(Tagged),
79 // A predecessor dialect authors every `establishes` item as a
80 // single-key `unit:` map. The wrapper carries no information beyond
81 // the unit it wraps, so it normalizes away to that inner unit -- a
82 // third 1:1 representation alongside the bare-string and tagged
83 // forms, resolved by recursing through this same impl (so the inner
84 // unit may itself be bare or tagged, and inherits its validation).
85 Wrapped { unit: Box<Unit> },
86 }
87 #[derive(Deserialize)]
88 #[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
89 enum Tagged {
90 File { path: String },
91 Section { file: String, anchor: String },
92 Symbol { id: String },
93 Directory { path: String },
94 Crate { id: String },
95 Module { id: String },
96 }
97
98 match Repr::deserialize(deserializer)? {
99 Repr::Bare(path) => {
100 if path.trim().is_empty() {
101 return Err(de::Error::custom("unit path must not be empty"));
102 }
103 Ok(Unit::File { path })
104 }
105 Repr::Tagged(Tagged::File { path }) => Ok(Unit::File { path }),
106 Repr::Tagged(Tagged::Section { file, anchor }) => Ok(Unit::Section { file, anchor }),
107 Repr::Tagged(Tagged::Symbol { id }) => Ok(Unit::Symbol { id }),
108 Repr::Tagged(Tagged::Directory { path }) => Ok(Unit::Directory { path }),
109 Repr::Tagged(Tagged::Crate { id }) => Ok(Unit::Crate { id }),
110 Repr::Tagged(Tagged::Module { id }) => Ok(Unit::Module { id }),
111 Repr::Wrapped { unit } => Ok(*unit),
112 }
113 }
114}