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/// Generate a graph prompt scoped to a single feature, including existing graph context.
189///
190/// Unlike `generate_graph_prompt()` which generates a full graph from scratch, this function
191/// generates ONLY new nodes for the specified feature while referencing existing nodes by ID.
192/// This avoids ID collisions and enables cross-feature dependency edges.
193pub 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
250/// Parse an LLM response containing features JSON.
251pub 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
265/// Parse an LLM response containing components JSON.
266pub 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
280/// Parse an LLM response containing a graph YAML.
281pub 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
290/// Build a Graph from features and components.
291pub 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    // Add feature nodes
306    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        // Add priority as tag
317        node.tags.push(feature.priority.clone());
318        
319        graph.add_node(node);
320    }
321    
322    // Add component nodes
323    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        // Add layer as tag
331        node.tags.push(component.layer.clone());
332        
333        graph.add_node(node);
334        
335        // Add dependency edges
336        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
345/// Extract JSON from a response that may contain markdown code blocks.
346fn extract_json(response: &str) -> Result<String> {
347    // Try to find JSON in code block
348    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    // Try plain code block
356    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            // Skip language identifier if present
361            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    // Try to find raw JSON (starts with { or [)
372    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
380/// Extract YAML from a response that may contain markdown code blocks.
381fn extract_yaml(response: &str) -> Result<String> {
382    // Try to find YAML in code block
383    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    // Try yml variant
391    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    // Try plain code block
399    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            // Skip language identifier if present
404            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    // Assume raw YAML
415    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
423/// Convert PascalCase or any case to snake_case.
424fn 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            // to_lowercase() can yield multiple chars for some Unicode (e.g. 'İ' → 'i̇')
434            // Use the first char, fallback to original if iterator is empty (shouldn't happen)
435            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"); // All caps treated as sequence
498        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        // Should still contain output format instructions
570        assert!(prompt.contains("implements|depends_on"));
571        assert!(prompt.contains("task-{feature-slug}-{task-slug}"));
572    }
573}