Skip to main content

moss_core/contract/
describe.rs

1//! Serializable payload for `moss describe --json`.
2//!
3//! The JSON shape is itself a contract — version it independently of
4//! `moss_html_version`. Bumping `describe_schema_version` is required for
5//! any breaking change to the JSON envelope.
6
7use serde::Serialize;
8use std::collections::BTreeMap;
9
10use crate::ast::shortcode::ShortcodeKind;
11use crate::contract::components::{COMPONENTS, Status};
12use crate::contract::frontmatter::{FrontmatterFieldJson, frontmatter_fields};
13use crate::contract::tokens::Tokens;
14
15pub const DESCRIBE_SCHEMA_VERSION: u32 = 5;
16pub const MOSS_HTML_VERSION: u32 = 1;
17
18#[derive(Serialize)]
19pub struct DescribePayload<'a> {
20    pub describe_schema_version: u32,
21    pub moss_html_version: u32,
22    pub moss_binary_version: &'static str,
23    pub tokens: BTreeMap<&'a str, Vec<TokenJson<'a>>>,
24    pub components: Vec<ComponentJson>,
25    pub frontmatter: Vec<FrontmatterFieldJson>,
26    /// Plugin hook contract: each capability moss supports, with arity and context type.
27    pub plugin_hooks: Vec<PluginHookInfo>,
28    /// Plugin manifest fields: each field in PluginManifest, with type and required flag.
29    pub manifest_fields: Vec<ManifestFieldInfo>,
30    /// Template injection slots: each named slot in the build pipeline.
31    pub slots: Vec<SlotInfo>,
32    /// CLI commands: each subcommand moss exposes.
33    // hand-maintained: keep in sync with run_mode.rs
34    pub cli_commands: Vec<CliCommandInfo>,
35}
36
37/// Plugin hook entry emitted in `plugin_hooks`.
38///
39/// Describes one capability a plugin may implement. Populated by
40/// `src-tauri/src/describe.rs` from the Tauri-layer `Capability` enum.
41#[derive(Serialize)]
42pub struct PluginHookInfo {
43    /// Lowercase hook name (e.g. "process"). Matches the JS function name.
44    pub name: &'static str,
45    /// One-line description of what this hook does.
46    pub description: &'static str,
47    /// "single" if at most one plugin may register this hook; "multiple" if many may.
48    pub arity: &'static str,
49    /// The name of the context struct passed to the hook function.
50    pub context: &'static str,
51}
52
53/// Plugin manifest field entry emitted in `manifest_fields`.
54///
55/// Describes one field of `PluginManifest`. Populated by
56/// `src-tauri/src/describe.rs` from the Rust struct definition.
57#[derive(Serialize)]
58pub struct ManifestFieldInfo {
59    /// Field name as it appears in the JSON manifest (snake_case).
60    pub name: &'static str,
61    /// JSON type or Rust-type description (e.g. "string", "string[]", "object").
62    pub r#type: &'static str,
63    /// Whether this field must be present in a valid manifest.
64    pub required: bool,
65    /// One-line description.
66    pub description: &'static str,
67}
68
69/// Template slot entry emitted in `slots`.
70///
71/// Describes one named injection point in the moss HTML templates. Populated
72/// by `src-tauri/src/describe.rs` from `SLOT_NAMES` and the `Slot` enum.
73#[derive(Serialize)]
74pub struct SlotInfo {
75    /// Slot name (e.g. "head-end"). Matches the `<!-- slot:NAME -->` marker.
76    pub name: &'static str,
77    /// Human-readable description of the slot's position in the page.
78    pub position: &'static str,
79    /// Whether markdown authors may target this slot via the `slot:` frontmatter field.
80    pub authorable: bool,
81}
82
83/// CLI command entry emitted in `cli_commands`.
84///
85/// Describes one moss CLI subcommand.
86// hand-maintained: keep in sync with run_mode.rs
87#[derive(Serialize)]
88pub struct CliCommandInfo {
89    /// Subcommand name (e.g. "build").
90    pub name: &'static str,
91    /// Argument signature (e.g. "<folder> [--serve] [--watch] [--no-plugins]").
92    pub args: &'static str,
93    /// One-line description.
94    pub description: &'static str,
95}
96
97#[derive(Serialize)]
98pub struct TokenJson<'a> {
99    pub name: &'a str,
100    pub value: &'a str,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub dark_value: Option<&'a str>,
103    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
104    pub type_hint: Option<&'a str>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub description: Option<&'a str>,
107}
108
109#[derive(Serialize)]
110pub struct ComponentJson {
111    pub class: &'static str,
112    pub kind: &'static str,
113    pub parent: &'static str,
114    pub data_attrs: Vec<DataAttrJson>,
115    pub example_html: &'static str,
116    pub example_markdown: &'static str,
117    pub status: &'static str,
118    pub since: &'static str,
119    pub description: &'static str,
120    /// True iff this class is the root class of an authorable shortcode
121    /// (i.e. it appears in `ShortcodeKind::all().map(|k| k.root_class())`).
122    /// Agents can use this flag to distinguish the 6 author-facing shortcodes
123    /// from the broader theme vocabulary.
124    pub authorable: bool,
125}
126
127#[derive(Serialize)]
128pub struct DataAttrJson {
129    pub name: &'static str,
130    pub values: &'static [&'static str],
131    pub default: &'static str,
132    pub description: &'static str,
133}
134
135impl<'a> DescribePayload<'a> {
136    pub fn new(tokens: &'a Tokens) -> Self {
137        let mut tokens_map: BTreeMap<&str, Vec<TokenJson>> = BTreeMap::new();
138        for group in &tokens.groups {
139            let entries: Vec<TokenJson> = group
140                .entries
141                .iter()
142                .map(|t| TokenJson {
143                    name: &t.name,
144                    value: &t.value,
145                    dark_value: t.dark_value.as_deref(),
146                    type_hint: t.type_hint.as_deref(),
147                    description: t.description.as_deref(),
148                })
149                .collect();
150            tokens_map.insert(&group.name, entries);
151        }
152
153        let authorable: std::collections::HashSet<&'static str> =
154            ShortcodeKind::all().map(|k| k.root_class()).collect();
155
156        let components: Vec<ComponentJson> = COMPONENTS
157            .iter()
158            .filter(|c| c.is_public())
159            .map(|c| ComponentJson {
160                class: c.class,
161                kind: c.kind,
162                parent: c.parent,
163                data_attrs: c
164                    .data_attrs
165                    .iter()
166                    .map(|a| DataAttrJson {
167                        name: a.name,
168                        values: a.values,
169                        default: a.default,
170                        description: a.description,
171                    })
172                    .collect(),
173                example_html: c.example_html,
174                example_markdown: c.example_markdown,
175                status: match c.status {
176                    Status::Confirmed => "confirmed",
177                    Status::Emerging => "emerging",
178                    Status::Retired => "retired",
179                },
180                since: c.since,
181                description: c.description,
182                authorable: authorable.contains(c.class),
183            })
184            .collect();
185
186        DescribePayload {
187            describe_schema_version: DESCRIBE_SCHEMA_VERSION,
188            moss_html_version: MOSS_HTML_VERSION,
189            moss_binary_version: env!("CARGO_PKG_VERSION"),
190            tokens: tokens_map,
191            components,
192            frontmatter: frontmatter_fields(),
193            // Populated by the Tauri layer (src-tauri/src/describe.rs) which
194            // has access to the Tauri-layer plugin types. Callers using
195            // DescribePayload::new() directly (e.g. moss-core unit tests) get
196            // empty vecs here; the CLI path fills them via with_plugin_contract().
197            plugin_hooks: Vec::new(),
198            manifest_fields: Vec::new(),
199            slots: Vec::new(),
200            cli_commands: Vec::new(),
201        }
202    }
203
204    /// Builder method: attach plugin contract data (hooks, manifest fields,
205    /// slots, CLI commands). Called by the Tauri-layer describe.rs after
206    /// constructing the base payload, since those types live outside moss-core.
207    pub fn with_plugin_contract(
208        mut self,
209        plugin_hooks: Vec<PluginHookInfo>,
210        manifest_fields: Vec<ManifestFieldInfo>,
211        slots: Vec<SlotInfo>,
212        cli_commands: Vec<CliCommandInfo>,
213    ) -> Self {
214        self.plugin_hooks = plugin_hooks;
215        self.manifest_fields = manifest_fields;
216        self.slots = slots;
217        self.cli_commands = cli_commands;
218        self
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::contract::tokens::load_tokens;
226
227    #[test]
228    fn apply_is_absent_from_public_contract() {
229        let tokens = load_tokens().expect("tokens");
230        let payload = DescribePayload::new(&tokens);
231        assert!(
232            payload.components.iter().all(|c| !c.class.starts_with("moss-apply")),
233            "apply classes must be demoted from the public contract"
234        );
235        assert!(
236            !payload.components.iter().any(|c| c.class == "moss-apply" && c.authorable),
237            ":::apply must not be marked authorable"
238        );
239    }
240}