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 generate_scoped_graph_prompt(
194 design_doc: &str,
195 existing_nodes: &[&Node],
196 feature_scope: &str,
197) -> String {
198 let existing_context = if existing_nodes.is_empty() {
199 " (none — this is the first feature)\n".to_string()
200 } else {
201 existing_nodes
202 .iter()
203 .map(|n| {
204 let node_type = n.node_type.as_deref().unwrap_or("unknown");
205 format!(" - {} ({}): {}", n.id, node_type, n.title)
206 })
207 .collect::<Vec<_>>()
208 .join("\n")
209 + "\n"
210 };
211
212 format!(
213 r#"You are a software architect. Generate ONLY the new graph nodes for feature "{feature_scope}".
214
215EXISTING GRAPH NODES (do NOT recreate these, reference them by ID in edges):
216{existing_context}
217NEW FEATURE DESIGN:
218{design_doc}
219
220Instructions:
221- Generate YAML with ONLY new nodes and edges for this feature
222- Use existing node IDs in edges for cross-feature dependencies
223- New task IDs should follow the pattern: task-{{feature-slug}}-{{task-slug}}
224- Create a feature node: feat-{{feature-slug}}
225- Each task should have: implements edge to the feature node
226- Add depends_on edges where tasks have dependencies
227
228Output format:
229```yaml
230nodes:
231 - id: ...
232 title: ...
233 node_type: task|feature
234 status: todo
235 ...
236edges:
237 - from: ...
238 to: ...
239 relation: implements|depends_on|...
240```
241
242Only output valid YAML. No explanation before or after.
243Start your response with "```yaml" and end with "```"."#,
244 feature_scope = feature_scope,
245 existing_context = existing_context,
246 design_doc = design_doc,
247 )
248}
249
250pub fn parse_features_response(response: &str) -> Result<Vec<FeatureProposal>> {
252 let json_str = extract_json(response)?;
253
254 #[derive(Deserialize)]
255 struct FeaturesResponse {
256 features: Vec<FeatureProposal>,
257 }
258
259 let parsed: FeaturesResponse = serde_json::from_str(&json_str)
260 .context("Failed to parse features JSON")?;
261
262 Ok(parsed.features)
263}
264
265pub fn parse_components_response(response: &str) -> Result<Vec<ComponentProposal>> {
267 let json_str = extract_json(response)?;
268
269 #[derive(Deserialize)]
270 struct ComponentsResponse {
271 components: Vec<ComponentProposal>,
272 }
273
274 let parsed: ComponentsResponse = serde_json::from_str(&json_str)
275 .context("Failed to parse components JSON")?;
276
277 Ok(parsed.components)
278}
279
280pub fn parse_llm_response(response: &str) -> Result<Graph> {
282 let yaml_str = extract_yaml(response)?;
283
284 let graph: Graph = serde_yaml::from_str(&yaml_str)
285 .context("Failed to parse graph YAML")?;
286
287 Ok(graph)
288}
289
290pub fn build_graph_from_proposals(
292 project_name: &str,
293 features: &[FeatureProposal],
294 components: &[ComponentProposal],
295) -> Graph {
296 let mut graph = Graph {
297 project: Some(ProjectMeta {
298 name: project_name.to_string(),
299 description: None,
300 }),
301 nodes: Vec::new(),
302 edges: Vec::new(),
303 };
304
305 for feature in features {
307 if !feature.selected {
308 continue;
309 }
310
311 let mut node = Node::new(&feature.name, &feature.name);
312 node.description = Some(feature.description.clone());
313 node.node_type = Some("feature".to_string());
314 node.status = NodeStatus::Todo;
315
316 node.tags.push(feature.priority.clone());
318
319 graph.add_node(node);
320 }
321
322 for component in components {
324 let id = to_snake_case(&component.name);
325 let mut node = Node::new(&id, &component.name);
326 node.description = Some(component.description.clone());
327 node.node_type = Some("component".to_string());
328 node.status = NodeStatus::Todo;
329
330 node.tags.push(component.layer.clone());
332
333 graph.add_node(node);
334
335 for dep in &component.depends_on {
337 let dep_id = to_snake_case(dep);
338 graph.add_edge(Edge::new(&id, &dep_id, "depends_on"));
339 }
340 }
341
342 graph
343}
344
345fn extract_json(response: &str) -> Result<String> {
347 if let Some(start) = response.find("```json") {
349 let content = &response[start + 7..];
350 if let Some(end) = content.find("```") {
351 return Ok(content[..end].trim().to_string());
352 }
353 }
354
355 if let Some(start) = response.find("```") {
357 let content = &response[start + 3..];
358 if let Some(end) = content.find("```") {
359 let inner = content[..end].trim();
360 if let Some(newline) = inner.find('\n') {
362 let first_line = &inner[..newline];
363 if !first_line.starts_with('{') && !first_line.starts_with('[') {
364 return Ok(inner[newline..].trim().to_string());
365 }
366 }
367 return Ok(inner.to_string());
368 }
369 }
370
371 let trimmed = response.trim();
373 if trimmed.starts_with('{') || trimmed.starts_with('[') {
374 return Ok(trimmed.to_string());
375 }
376
377 bail!("No JSON found in response")
378}
379
380fn extract_yaml(response: &str) -> Result<String> {
382 if let Some(start) = response.find("```yaml") {
384 let content = &response[start + 7..];
385 if let Some(end) = content.find("```") {
386 return Ok(content[..end].trim().to_string());
387 }
388 }
389
390 if let Some(start) = response.find("```yml") {
392 let content = &response[start + 6..];
393 if let Some(end) = content.find("```") {
394 return Ok(content[..end].trim().to_string());
395 }
396 }
397
398 if let Some(start) = response.find("```") {
400 let content = &response[start + 3..];
401 if let Some(end) = content.find("```") {
402 let inner = content[..end].trim();
403 if let Some(newline) = inner.find('\n') {
405 let first_line = &inner[..newline];
406 if !first_line.contains(':') {
407 return Ok(inner[newline..].trim().to_string());
408 }
409 }
410 return Ok(inner.to_string());
411 }
412 }
413
414 let trimmed = response.trim();
416 if trimmed.contains(':') {
417 return Ok(trimmed.to_string());
418 }
419
420 bail!("No YAML found in response")
421}
422
423fn to_snake_case(s: &str) -> String {
425 let mut result = String::new();
426 let mut prev_was_upper = false;
427
428 for (i, c) in s.chars().enumerate() {
429 if c.is_uppercase() {
430 if i > 0 && !prev_was_upper {
431 result.push('_');
432 }
433 for lc in c.to_lowercase() {
436 result.push(lc);
437 }
438 prev_was_upper = true;
439 } else if c == '-' || c == ' ' {
440 result.push('_');
441 prev_was_upper = false;
442 } else {
443 result.push(c);
444 prev_was_upper = false;
445 }
446 }
447
448 result
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn test_extract_json_from_code_block() {
457 let response = r#"Here's the JSON:
458```json
459{
460 "features": [{"name": "test", "description": "Test feature", "priority": "core"}]
461}
462```
463"#;
464 let json = extract_json(response).unwrap();
465 assert!(json.contains("features"));
466 }
467
468 #[test]
469 fn test_extract_yaml_from_code_block() {
470 let response = r#"```yaml
471project:
472 name: test
473nodes: []
474edges: []
475```"#;
476 let yaml = extract_yaml(response).unwrap();
477 assert!(yaml.contains("project:"));
478 }
479
480 #[test]
481 fn test_parse_features_response() {
482 let response = r#"```json
483{
484 "features": [
485 {"name": "auth", "description": "Authentication", "priority": "core"}
486 ]
487}
488```"#;
489 let features = parse_features_response(response).unwrap();
490 assert_eq!(features.len(), 1);
491 assert_eq!(features[0].name, "auth");
492 }
493
494 #[test]
495 fn test_to_snake_case() {
496 assert_eq!(to_snake_case("AuthService"), "auth_service");
497 assert_eq!(to_snake_case("HTTPClient"), "httpclient"); assert_eq!(to_snake_case("user-auth"), "user_auth");
499 }
500
501 #[test]
502 fn test_build_graph() {
503 let features = vec![
504 FeatureProposal {
505 name: "auth".to_string(),
506 description: "Authentication".to_string(),
507 priority: "core".to_string(),
508 selected: true,
509 },
510 ];
511
512 let components = vec![
513 ComponentProposal {
514 name: "AuthService".to_string(),
515 description: "Auth service".to_string(),
516 layer: "application".to_string(),
517 depends_on: vec![],
518 },
519 ];
520
521 let graph = build_graph_from_proposals("test", &features, &components);
522 assert_eq!(graph.nodes.len(), 2);
523 }
524
525 #[test]
526 fn test_scoped_prompt_includes_existing_node_context() {
527 let mut node1 = Node::new("feat-auth", "Authentication system");
528 node1.node_type = Some("feature".to_string());
529 let mut node2 = Node::new("task-auth-jwt", "Implement JWT validation");
530 node2.node_type = Some("task".to_string());
531
532 let existing: Vec<&Node> = vec![&node1, &node2];
533 let prompt = generate_scoped_graph_prompt(
534 "Add payment processing",
535 &existing,
536 "payments",
537 );
538
539 assert!(prompt.contains("feat-auth (feature): Authentication system"));
540 assert!(prompt.contains("task-auth-jwt (task): Implement JWT validation"));
541 }
542
543 #[test]
544 fn test_scoped_prompt_includes_design_doc() {
545 let design_doc = "Add Stripe-based payment processing with webhooks";
546 let prompt = generate_scoped_graph_prompt(design_doc, &[], "payments");
547
548 assert!(prompt.contains(design_doc));
549 }
550
551 #[test]
552 fn test_scoped_prompt_specifies_feature_scope() {
553 let prompt = generate_scoped_graph_prompt("Some design", &[], "payments");
554
555 assert!(prompt.contains(r#"feature "payments""#));
556 }
557
558 #[test]
559 fn test_scoped_prompt_with_empty_existing_nodes() {
560 let prompt = generate_scoped_graph_prompt(
561 "Build the first feature",
562 &[],
563 "initial-setup",
564 );
565
566 assert!(prompt.contains("(none — this is the first feature)"));
567 assert!(prompt.contains(r#"feature "initial-setup""#));
568 assert!(prompt.contains("Build the first feature"));
569 assert!(prompt.contains("implements|depends_on"));
571 assert!(prompt.contains("task-{feature-slug}-{task-slug}"));
572 }
573}