1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11#[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#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct GraphNode {
27 pub id: String,
29 pub kind: NodeKind,
31 pub path: String,
33 pub hash: String,
35 pub updated_at: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct GraphEdge {
42 pub from: String,
44 pub to: String,
46}
47
48#[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#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct PromptMetadata {
71 pub prompt_version: u32,
73 pub generated_at: String,
75 pub node_id: String,
77 pub depends_on: Vec<String>,
79 pub artifact_paths: Vec<String>,
81 pub artifact_hashes: BTreeMap<String, String>,
83}
84
85pub 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 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; 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
117pub 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 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}