Skip to main content

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}