ranvier_core/static_gen.rs
1//! Static State Generation Support
2//!
3//! This module provides traits and types for building static state at build time.
4//! Static axons are executed without external input to generate pre-computed state
5//! for frontend SSG (Static Site Generation).
6
7use crate::bus::Bus;
8use crate::outcome::Outcome;
9use crate::schematic::NodeKind;
10use anyhow::Result;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::path::Path;
14
15/// Legacy trait for static graph nodes (kept for backward compatibility)
16pub trait StaticNode {
17 /// Unique identifier for the node
18 fn id(&self) -> &'static str;
19
20 /// The kind of node (Start, Process, etc.)
21 fn kind(&self) -> NodeKind;
22
23 /// List of IDs of nodes this node connects to
24 fn next_nodes(&self) -> Vec<&'static str>;
25}
26
27/// Marker trait for axons that can be executed statically at build time.
28///
29/// # Static Safety Contract
30///
31/// Static axons MUST:
32/// - Have NO external input (request-free execution)
33/// - Have limited side-effects (read-only operations preferred)
34/// - Be deterministic (same input → same output)
35/// - NOT use WebSocket, DB writes, or random sources
36///
37/// # Example
38///
39/// ```rust
40/// use ranvier_core::static_gen::StaticAxon;
41/// use ranvier_core::Bus;
42/// use ranvier_core::Outcome;
43/// use anyhow::Result;
44///
45/// # #[derive(serde::Serialize)]
46/// # struct LandingState { title: String }
47/// #
48/// # #[derive(Debug, Clone)]
49/// # struct AppError;
50/// #
51/// # impl std::fmt::Display for AppError {
52/// # fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53/// # write!(f, "AppError")
54/// # }
55/// # }
56/// #
57/// # impl std::error::Error for AppError {}
58/// #
59/// # impl From<anyhow::Error> for AppError {
60/// # fn from(_: anyhow::Error) -> Self { AppError }
61/// # }
62/// #
63/// struct LandingPageAxon;
64///
65/// impl StaticAxon for LandingPageAxon {
66/// type Output = LandingState;
67/// type Error = AppError;
68///
69/// fn name(&self) -> &'static str {
70/// "landing_page"
71/// }
72///
73/// fn generate(&self, _bus: &mut Bus) -> Result<Outcome<LandingState, AppError>> {
74/// // Load features, pricing from read-only sources
75/// Ok(Outcome::Next(LandingState { title: "Welcome".to_string() }))
76/// }
77/// }
78/// ```
79pub trait StaticAxon: Send + Sync {
80 /// The output state type (must be serializable)
81 type Output: Serialize;
82
83 /// Error type for static generation failures
84 type Error: Into<anyhow::Error> + std::fmt::Debug;
85
86 /// Unique identifier for this static state
87 fn name(&self) -> &'static str;
88
89 /// Execute the static axon to generate state.
90 ///
91 /// This is called at build time with an empty or pre-configured Bus.
92 fn generate(&self, bus: &mut Bus) -> Result<Outcome<Self::Output, Self::Error>>;
93}
94
95/// Manifest for static build output.
96///
97/// The manifest lists all generated static states and metadata about the build.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct StaticManifest {
100 /// Schema version
101 pub version: String,
102
103 /// When this build was generated
104 pub generated_at: DateTime<Utc>,
105
106 /// List of generated state entries
107 pub states: Vec<StaticStateEntry>,
108}
109
110impl StaticManifest {
111 /// Create a new manifest with the current timestamp
112 pub fn new() -> Self {
113 Self {
114 version: env!("CARGO_PKG_VERSION").to_string(),
115 generated_at: Utc::now(),
116 states: Vec::new(),
117 }
118 }
119
120 /// Add a state entry to the manifest
121 pub fn add_state(&mut self, name: impl Into<String>, file: impl Into<String>) {
122 self.states.push(StaticStateEntry {
123 name: name.into(),
124 file: file.into(),
125 content_type: "application/json".to_string(),
126 });
127 }
128}
129
130impl Default for StaticManifest {
131 fn default() -> Self {
132 Self::new()
133 }
134}
135
136/// Entry in the static build manifest.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct StaticStateEntry {
139 /// Logical name of the state (e.g., "landing_page")
140 pub name: String,
141
142 /// Relative path to the generated JSON file
143 pub file: String,
144
145 /// MIME type of the content
146 pub content_type: String,
147}
148
149/// Configuration for static builds.
150#[derive(Debug, Clone)]
151pub struct StaticBuildConfig {
152 /// Output directory for generated files
153 pub output_dir: Option<String>,
154
155 /// Optional filter to build only specific axons
156 pub only: Option<String>,
157
158 /// Whether to include schematic.json in output
159 pub include_schematic: bool,
160
161 /// Whether to pretty-print JSON output
162 pub pretty: bool,
163}
164
165impl StaticBuildConfig {
166 /// Create a new default build config
167 pub fn new() -> Self {
168 Self {
169 output_dir: None,
170 only: None,
171 include_schematic: true,
172 pretty: true,
173 }
174 }
175
176 /// Set the output directory
177 pub fn with_output_dir(mut self, dir: impl Into<String>) -> Self {
178 self.output_dir = Some(dir.into());
179 self
180 }
181
182 /// Filter to build only the specified axon
183 pub fn with_only(mut self, name: impl Into<String>) -> Self {
184 self.only = Some(name.into());
185 self
186 }
187
188 /// Enable or disable schematic output
189 pub fn with_schematic(mut self, include: bool) -> Self {
190 self.include_schematic = include;
191 self
192 }
193
194 /// Enable or disable pretty JSON output
195 pub fn with_pretty(mut self, pretty: bool) -> Self {
196 self.pretty = pretty;
197 self
198 }
199
200 /// Get the default output directory
201 pub fn get_output_dir(&self) -> &str {
202 self.output_dir.as_deref().unwrap_or("./dist/static")
203 }
204}
205
206impl Default for StaticBuildConfig {
207 fn default() -> Self {
208 Self::new()
209 }
210}
211
212/// Result of a single static build execution.
213#[derive(Debug)]
214pub struct StaticBuildResult {
215 /// Name of the axon that was built
216 pub name: String,
217
218 /// Path to the generated JSON file
219 pub file_path: String,
220
221 /// Whether the build was successful
222 pub success: bool,
223}
224
225/// Write a serializable value to a JSON file.
226pub fn write_json_file<T: Serialize>(path: &Path, value: &T, pretty: bool) -> anyhow::Result<()> {
227 let json = if pretty {
228 serde_json::to_string_pretty(value)?
229 } else {
230 serde_json::to_string(value)?
231 };
232
233 // Ensure parent directory exists
234 if let Some(parent) = path.parent() {
235 std::fs::create_dir_all(parent)?;
236 }
237
238 std::fs::write(path, json)?;
239 Ok(())
240}
241
242/// Read a JSON file and deserialize it.
243pub fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> anyhow::Result<T> {
244 let content = std::fs::read_to_string(path)?;
245 let value = serde_json::from_str(&content)?;
246 Ok(value)
247}