Skip to main content

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}