1use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use crate::graph::{Graph, Node, Edge, NodeStatus, ProjectMeta};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct FeatureProposal {
12 pub name: String,
13 pub description: String,
14 pub priority: String,
16 #[serde(default = "default_true")]
18 pub selected: bool,
19}
20
21fn default_true() -> bool { true }
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ComponentProposal {
26 pub name: String,
27 pub description: String,
28 pub layer: String,
30 #[serde(default)]
32 pub depends_on: Vec<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DesignResult {
38 pub features: Vec<FeatureProposal>,
39 pub components: Vec<ComponentProposal>,
40 pub graph: Option<Graph>,
41}
42
43pub fn generate_features_prompt(requirements: &str) -> String {
45 format!(r#"You are a software architect. Analyze the following requirements and decompose them into features.
46
47REQUIREMENTS:
48{}
49
50Respond with a JSON object containing a "features" array. Each feature should have:
51- name: Short identifier (snake_case)
52- description: One sentence explaining the feature
53- priority: "core" (essential), "supporting" (needed but not critical), or "optional"
54
55Example response:
56```json
57{{
58 "features": [
59 {{
60 "name": "user_authentication",
61 "description": "Allow users to sign up, log in, and manage their accounts",
62 "priority": "core"
63 }},
64 {{
65 "name": "data_export",
66 "description": "Export user data to various formats like CSV and JSON",
67 "priority": "supporting"
68 }}
69 ]
70}}
71```
72
73Only output valid JSON. No explanation before or after."#, requirements)
74}
75
76pub fn generate_components_prompt(feature: &FeatureProposal, context: Option<&str>) -> String {
78 let context_section = context.map(|c| format!("\nEXISTING CONTEXT:\n{}\n", c)).unwrap_or_default();
79
80 format!(r#"You are a software architect. Design components for implementing the following feature.
81{context_section}
82FEATURE:
83Name: {}
84Description: {}
85Priority: {}
86
87Design components following Clean Architecture layers:
88- interface: User-facing (CLI commands, API routes, UI components)
89- application: Use cases and orchestration
90- domain: Core business logic and entities
91- infrastructure: External integrations (DB, filesystem, APIs)
92
93Respond with a JSON object containing a "components" array. Each component should have:
94- name: Short identifier (PascalCase)
95- description: What this component does
96- layer: One of interface, application, domain, infrastructure
97- depends_on: Array of other component names this depends on
98
99Example response:
100```json
101{{
102 "components": [
103 {{
104 "name": "AuthController",
105 "description": "Handles HTTP authentication endpoints",
106 "layer": "interface",
107 "depends_on": ["AuthService"]
108 }},
109 {{
110 "name": "AuthService",
111 "description": "Orchestrates authentication logic",
112 "layer": "application",
113 "depends_on": ["UserRepository", "TokenValidator"]
114 }}
115 ]
116}}
117```
118
119Only output valid JSON. No explanation before or after."#,
120 feature.name,
121 feature.description,
122 feature.priority
123 )
124}
125
126pub fn generate_graph_prompt(requirements: &str) -> String {
128 format!(r#"You are a software architect. Generate a GID (Graph Indexed Development) graph for the following requirements.
129
130REQUIREMENTS:
131{}
132
133Output a valid YAML graph with this structure:
134- project: Project metadata (name, description)
135- nodes: Array of nodes (tasks, features, components)
136- edges: Array of edges (dependencies between nodes)
137
138Node structure:
139- id: Unique identifier (snake_case)
140- title: Human-readable title
141- status: todo, in_progress, done, blocked
142- description: Optional detailed description
143- tags: Optional array of tags
144- type: Optional type (task, feature, component, file)
145
146Edge structure:
147- from: Source node ID
148- to: Target node ID
149- relation: depends_on, implements, contains
150
151Example output:
152```yaml
153project:
154 name: my-project
155 description: A sample project
156
157nodes:
158 - id: setup_repo
159 title: Initialize repository
160 status: todo
161 type: task
162
163 - id: user_auth
164 title: User Authentication
165 status: todo
166 type: feature
167 description: Allow users to sign in
168
169 - id: auth_service
170 title: Authentication Service
171 status: todo
172 type: component
173
174edges:
175 - from: auth_service
176 to: user_auth
177 relation: implements
178
179 - from: user_auth
180 to: setup_repo
181 relation: depends_on
182```
183
184Only output valid YAML. No explanation before or after.
185Start your response with "```yaml" and end with "```"."#, requirements)
186}
187
188pub fn parse_features_response(response: &str) -> Result<Vec<FeatureProposal>> {
190 let json_str = extract_json(response)?;
191
192 #[derive(Deserialize)]
193 struct FeaturesResponse {
194 features: Vec<FeatureProposal>,
195 }
196
197 let parsed: FeaturesResponse = serde_json::from_str(&json_str)
198 .context("Failed to parse features JSON")?;
199
200 Ok(parsed.features)
201}
202
203pub fn parse_components_response(response: &str) -> Result<Vec<ComponentProposal>> {
205 let json_str = extract_json(response)?;
206
207 #[derive(Deserialize)]
208 struct ComponentsResponse {
209 components: Vec<ComponentProposal>,
210 }
211
212 let parsed: ComponentsResponse = serde_json::from_str(&json_str)
213 .context("Failed to parse components JSON")?;
214
215 Ok(parsed.components)
216}
217
218pub fn parse_llm_response(response: &str) -> Result<Graph> {
220 let yaml_str = extract_yaml(response)?;
221
222 let graph: Graph = serde_yaml::from_str(&yaml_str)
223 .context("Failed to parse graph YAML")?;
224
225 Ok(graph)
226}
227
228pub fn build_graph_from_proposals(
230 project_name: &str,
231 features: &[FeatureProposal],
232 components: &[ComponentProposal],
233) -> Graph {
234 let mut graph = Graph {
235 project: Some(ProjectMeta {
236 name: project_name.to_string(),
237 description: None,
238 }),
239 nodes: Vec::new(),
240 edges: Vec::new(),
241 };
242
243 for feature in features {
245 if !feature.selected {
246 continue;
247 }
248
249 let mut node = Node::new(&feature.name, &feature.name);
250 node.description = Some(feature.description.clone());
251 node.node_type = Some("feature".to_string());
252 node.status = NodeStatus::Todo;
253
254 node.tags.push(feature.priority.clone());
256
257 graph.add_node(node);
258 }
259
260 for component in components {
262 let id = to_snake_case(&component.name);
263 let mut node = Node::new(&id, &component.name);
264 node.description = Some(component.description.clone());
265 node.node_type = Some("component".to_string());
266 node.status = NodeStatus::Todo;
267
268 node.tags.push(component.layer.clone());
270
271 graph.add_node(node);
272
273 for dep in &component.depends_on {
275 let dep_id = to_snake_case(dep);
276 graph.add_edge(Edge::new(&id, &dep_id, "depends_on"));
277 }
278 }
279
280 graph
281}
282
283fn extract_json(response: &str) -> Result<String> {
285 if let Some(start) = response.find("```json") {
287 let content = &response[start + 7..];
288 if let Some(end) = content.find("```") {
289 return Ok(content[..end].trim().to_string());
290 }
291 }
292
293 if let Some(start) = response.find("```") {
295 let content = &response[start + 3..];
296 if let Some(end) = content.find("```") {
297 let inner = content[..end].trim();
298 if let Some(newline) = inner.find('\n') {
300 let first_line = &inner[..newline];
301 if !first_line.starts_with('{') && !first_line.starts_with('[') {
302 return Ok(inner[newline..].trim().to_string());
303 }
304 }
305 return Ok(inner.to_string());
306 }
307 }
308
309 let trimmed = response.trim();
311 if trimmed.starts_with('{') || trimmed.starts_with('[') {
312 return Ok(trimmed.to_string());
313 }
314
315 bail!("No JSON found in response")
316}
317
318fn extract_yaml(response: &str) -> Result<String> {
320 if let Some(start) = response.find("```yaml") {
322 let content = &response[start + 7..];
323 if let Some(end) = content.find("```") {
324 return Ok(content[..end].trim().to_string());
325 }
326 }
327
328 if let Some(start) = response.find("```yml") {
330 let content = &response[start + 6..];
331 if let Some(end) = content.find("```") {
332 return Ok(content[..end].trim().to_string());
333 }
334 }
335
336 if let Some(start) = response.find("```") {
338 let content = &response[start + 3..];
339 if let Some(end) = content.find("```") {
340 let inner = content[..end].trim();
341 if let Some(newline) = inner.find('\n') {
343 let first_line = &inner[..newline];
344 if !first_line.contains(':') {
345 return Ok(inner[newline..].trim().to_string());
346 }
347 }
348 return Ok(inner.to_string());
349 }
350 }
351
352 let trimmed = response.trim();
354 if trimmed.contains(':') {
355 return Ok(trimmed.to_string());
356 }
357
358 bail!("No YAML found in response")
359}
360
361fn to_snake_case(s: &str) -> String {
363 let mut result = String::new();
364 let mut prev_was_upper = false;
365
366 for (i, c) in s.chars().enumerate() {
367 if c.is_uppercase() {
368 if i > 0 && !prev_was_upper {
369 result.push('_');
370 }
371 result.push(c.to_lowercase().next().unwrap());
372 prev_was_upper = true;
373 } else if c == '-' || c == ' ' {
374 result.push('_');
375 prev_was_upper = false;
376 } else {
377 result.push(c);
378 prev_was_upper = false;
379 }
380 }
381
382 result
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn test_extract_json_from_code_block() {
391 let response = r#"Here's the JSON:
392```json
393{
394 "features": [{"name": "test", "description": "Test feature", "priority": "core"}]
395}
396```
397"#;
398 let json = extract_json(response).unwrap();
399 assert!(json.contains("features"));
400 }
401
402 #[test]
403 fn test_extract_yaml_from_code_block() {
404 let response = r#"```yaml
405project:
406 name: test
407nodes: []
408edges: []
409```"#;
410 let yaml = extract_yaml(response).unwrap();
411 assert!(yaml.contains("project:"));
412 }
413
414 #[test]
415 fn test_parse_features_response() {
416 let response = r#"```json
417{
418 "features": [
419 {"name": "auth", "description": "Authentication", "priority": "core"}
420 ]
421}
422```"#;
423 let features = parse_features_response(response).unwrap();
424 assert_eq!(features.len(), 1);
425 assert_eq!(features[0].name, "auth");
426 }
427
428 #[test]
429 fn test_to_snake_case() {
430 assert_eq!(to_snake_case("AuthService"), "auth_service");
431 assert_eq!(to_snake_case("HTTPClient"), "httpclient"); assert_eq!(to_snake_case("user-auth"), "user_auth");
433 }
434
435 #[test]
436 fn test_build_graph() {
437 let features = vec![
438 FeatureProposal {
439 name: "auth".to_string(),
440 description: "Authentication".to_string(),
441 priority: "core".to_string(),
442 selected: true,
443 },
444 ];
445
446 let components = vec![
447 ComponentProposal {
448 name: "AuthService".to_string(),
449 description: "Auth service".to_string(),
450 layer: "application".to_string(),
451 depends_on: vec![],
452 },
453 ];
454
455 let graph = build_graph_from_proposals("test", &features, &components);
456 assert_eq!(graph.nodes.len(), 2);
457 }
458}