Skip to main content

gid_core/
design.rs

1//! Design module for LLM-assisted graph generation.
2//!
3//! Generates prompts and parses LLM responses. Does NOT call LLM directly.
4
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use crate::graph::{Graph, Node, Edge, NodeStatus, ProjectMeta};
8
9/// A proposed feature from requirements decomposition.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct FeatureProposal {
12    pub name: String,
13    pub description: String,
14    /// Priority: core, supporting, or optional
15    pub priority: String,
16    /// Whether this feature is selected for implementation
17    #[serde(default = "default_true")]
18    pub selected: bool,
19}
20
21fn default_true() -> bool { true }
22
23/// A proposed component for a feature.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ComponentProposal {
26    pub name: String,
27    pub description: String,
28    /// Layer: interface, application, domain, infrastructure
29    pub layer: String,
30    /// IDs of components this one depends on
31    #[serde(default)]
32    pub depends_on: Vec<String>,
33}
34
35/// Result from parsing an LLM graph design response.
36#[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
43/// Generate a prompt for decomposing requirements into features.
44pub 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
76/// Generate a prompt for designing components for a feature.
77pub 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
126/// Generate a prompt that produces a complete graph YAML.
127pub 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
188/// Parse an LLM response containing features JSON.
189pub 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
203/// Parse an LLM response containing components JSON.
204pub 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
218/// Parse an LLM response containing a graph YAML.
219pub 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
228/// Build a Graph from features and components.
229pub 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    // Add feature nodes
244    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        // Add priority as tag
255        node.tags.push(feature.priority.clone());
256        
257        graph.add_node(node);
258    }
259    
260    // Add component nodes
261    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        // Add layer as tag
269        node.tags.push(component.layer.clone());
270        
271        graph.add_node(node);
272        
273        // Add dependency edges
274        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
283/// Extract JSON from a response that may contain markdown code blocks.
284fn extract_json(response: &str) -> Result<String> {
285    // Try to find JSON in code block
286    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    // Try plain code block
294    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            // Skip language identifier if present
299            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    // Try to find raw JSON (starts with { or [)
310    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
318/// Extract YAML from a response that may contain markdown code blocks.
319fn extract_yaml(response: &str) -> Result<String> {
320    // Try to find YAML in code block
321    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    // Try yml variant
329    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    // Try plain code block
337    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            // Skip language identifier if present
342            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    // Assume raw YAML
353    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
361/// Convert PascalCase or any case to snake_case.
362fn 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"); // All caps treated as sequence
432        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}