Skip to main content

lexicon_spec/
prompt.rs

1//! Prompt DAG types — dependency graph and metadata for implementation prompts.
2//!
3//! Implementation prompts are derived artifacts compiled from repository law
4//! (contracts, conformance tests, gates, etc.). This module defines the graph
5//! structure that tracks dependencies and enables incremental rebuilds.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11/// Node types in the prompt dependency DAG.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum NodeKind {
15    Contract,
16    Conformance,
17    Gate,
18    ScoringModel,
19    ApiBaseline,
20    ArchitectureRule,
21    Prompt,
22}
23
24/// A node in the prompt DAG.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct GraphNode {
27    /// Stable identifier, e.g. "contract:blob-store" or "prompt:memory-blob-store".
28    pub id: String,
29    /// What kind of artifact this node represents.
30    pub kind: NodeKind,
31    /// File path relative to repo root.
32    pub path: String,
33    /// SHA-256 hex digest of the file contents at last build.
34    pub hash: String,
35    /// ISO 8601 timestamp of last update.
36    pub updated_at: String,
37}
38
39/// A directed edge from a source artifact to a derived prompt.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct GraphEdge {
42    /// Source node ID (e.g. "contract:blob-store").
43    pub from: String,
44    /// Derived node ID (e.g. "prompt:memory-blob-store").
45    pub to: String,
46}
47
48/// The full prompt dependency graph, persisted at `.lexicon/prompt-graph.json`.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct PromptGraph {
51    pub schema_version: String,
52    pub nodes: Vec<GraphNode>,
53    pub edges: Vec<GraphEdge>,
54    pub built_at: String,
55}
56
57impl Default for PromptGraph {
58    fn default() -> Self {
59        Self {
60            schema_version: "0.1.0".to_string(),
61            nodes: Vec::new(),
62            edges: Vec::new(),
63            built_at: String::new(),
64        }
65    }
66}
67
68/// YAML frontmatter metadata embedded in each generated prompt file.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct PromptMetadata {
71    /// Incremented on each regeneration.
72    pub prompt_version: u32,
73    /// ISO 8601 timestamp of generation.
74    pub generated_at: String,
75    /// Stable node ID in the DAG, e.g. "prompt:memory-blob-store".
76    pub node_id: String,
77    /// Source node IDs this prompt depends on.
78    pub depends_on: Vec<String>,
79    /// Source file paths (relative to repo root).
80    pub artifact_paths: Vec<String>,
81    /// Map of source file path to SHA-256 hex digest at generation time.
82    pub artifact_hashes: BTreeMap<String, String>,
83}
84
85/// Parse a prompt file into its metadata frontmatter and body content.
86///
87/// The file format is:
88/// ```text
89/// ---
90/// <yaml frontmatter>
91/// ---
92/// <markdown body>
93/// ```
94///
95/// Returns `None` if the file does not contain valid frontmatter.
96pub fn parse_prompt_file(content: &str) -> Option<(PromptMetadata, String)> {
97    let content = content.trim_start();
98    if !content.starts_with("---") {
99        return None;
100    }
101
102    // Find the second "---" delimiter
103    let after_first = &content[3..];
104    let end_idx = after_first.find("\n---")?;
105    let yaml_block = &after_first[..end_idx].trim();
106    let body_start = 3 + end_idx + 4; // skip past "\n---"
107    let body = if body_start < content.len() {
108        content[body_start..].trim_start_matches('\n').to_string()
109    } else {
110        String::new()
111    };
112
113    let meta: PromptMetadata = serde_yaml::from_str(yaml_block).ok()?;
114    Some((meta, body))
115}
116
117/// Render a prompt file with YAML frontmatter metadata and markdown body.
118pub fn render_prompt_file(meta: &PromptMetadata, body: &str) -> String {
119    let yaml = serde_yaml::to_string(meta).unwrap_or_default();
120    format!("---\n{yaml}---\n\n{body}\n")
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_prompt_graph_default() {
129        let graph = PromptGraph::default();
130        assert_eq!(graph.schema_version, "0.1.0");
131        assert!(graph.nodes.is_empty());
132        assert!(graph.edges.is_empty());
133    }
134
135    #[test]
136    fn test_node_kind_serde() {
137        let kind = NodeKind::Contract;
138        let json = serde_json::to_string(&kind).unwrap();
139        assert_eq!(json, "\"contract\"");
140        let parsed: NodeKind = serde_json::from_str(&json).unwrap();
141        assert_eq!(kind, parsed);
142    }
143
144    #[test]
145    fn test_parse_and_render_prompt_file() {
146        let meta = PromptMetadata {
147            prompt_version: 1,
148            generated_at: "2026-03-13T12:00:00Z".to_string(),
149            node_id: "prompt:test-prompt".to_string(),
150            depends_on: vec!["contract:test".to_string()],
151            artifact_paths: vec!["specs/contracts/test.toml".to_string()],
152            artifact_hashes: BTreeMap::from([(
153                "specs/contracts/test.toml".to_string(),
154                "abc123".to_string(),
155            )]),
156        };
157
158        let body = "# IMPLEMENTATION PROMPT -- Test\n\n## Objective\n\nTest objective.";
159        let rendered = render_prompt_file(&meta, body);
160
161        assert!(rendered.starts_with("---\n"));
162        assert!(rendered.contains("prompt_version: 1"));
163        assert!(rendered.contains("node_id: 'prompt:test-prompt'") || rendered.contains("node_id: prompt:test-prompt"));
164        assert!(rendered.contains("# IMPLEMENTATION PROMPT -- Test"));
165
166        // Round-trip
167        let (parsed_meta, parsed_body) = parse_prompt_file(&rendered).unwrap();
168        assert_eq!(parsed_meta.prompt_version, 1);
169        assert_eq!(parsed_meta.node_id, "prompt:test-prompt");
170        assert_eq!(parsed_meta.depends_on, vec!["contract:test"]);
171        assert!(parsed_body.contains("# IMPLEMENTATION PROMPT -- Test"));
172    }
173
174    #[test]
175    fn test_parse_no_frontmatter() {
176        let content = "# Just a regular markdown file\n\nNo frontmatter here.";
177        assert!(parse_prompt_file(content).is_none());
178    }
179
180    #[test]
181    fn test_prompt_graph_serde_roundtrip() {
182        let graph = PromptGraph {
183            schema_version: "0.1.0".to_string(),
184            nodes: vec![GraphNode {
185                id: "contract:test".to_string(),
186                kind: NodeKind::Contract,
187                path: "specs/contracts/test.toml".to_string(),
188                hash: "abc123".to_string(),
189                updated_at: "2026-03-13T12:00:00Z".to_string(),
190            }],
191            edges: vec![GraphEdge {
192                from: "contract:test".to_string(),
193                to: "prompt:test-impl".to_string(),
194            }],
195            built_at: "2026-03-13T12:00:00Z".to_string(),
196        };
197
198        let json = serde_json::to_string_pretty(&graph).unwrap();
199        let parsed: PromptGraph = serde_json::from_str(&json).unwrap();
200        assert_eq!(parsed.nodes.len(), 1);
201        assert_eq!(parsed.edges.len(), 1);
202        assert_eq!(parsed.nodes[0].id, "contract:test");
203    }
204}