Skip to main content

ggen_cli_lib/cmds/
framework.rs

1//! Framework Bridge Commands - clap-noun-verb v4.0.2 Integration
2//!
3//! This module implements framework bridge commands for generating adapters
4//! to connect ggen-generated components with external frameworks like LangChain.
5//!
6//! Typical usage:
7//! ```bash
8//! ggen framework bridge langchain extract_claims
9//! ```
10
11use clap_noun_verb::Result as NounVerbResult;
12use clap_noun_verb_macros::verb;
13use serde::Serialize;
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18// ============================================================================
19// Output Types
20// ============================================================================
21
22#[derive(Serialize)]
23struct BridgeLangChainOutput {
24    tool_name: String,
25    framework: String,
26    output_path: String,
27    status: String,
28    message: String,
29    python_syntax_valid: bool,
30}
31
32// ============================================================================
33// Template Context
34// ============================================================================
35
36#[derive(Clone, Debug)]
37struct TemplateContext {
38    tool_name: String,
39    description: String,
40    parameters: String,
41}
42
43// ============================================================================
44// Verb Functions
45// ============================================================================
46
47/// Bridge to LangChain Python framework
48///
49/// Generates a LangChain BaseTool adapter for a ggen-generated component.
50/// The adapter can be used in LangChain agents and chains.
51///
52/// # Arguments
53/// * `name` - The name of the component (e.g., "extract_claims")
54///
55/// # Example
56/// ```bash
57/// ggen framework bridge langchain extract_claims
58/// ```
59#[verb]
60fn bridge_langchain(name: String) -> NounVerbResult<BridgeLangChainOutput> {
61    // Step 1: Validate input
62    validate_component_name(&name)?;
63
64    // Step 2: Create output directory
65    let output_dir = PathBuf::from("output/langchain");
66    fs::create_dir_all(&output_dir).map_err(|e| {
67        clap_noun_verb::NounVerbError::execution_error(format!(
68            "Failed to create output directory: {}",
69            e
70        ))
71    })?;
72
73    // Step 3: Build template context
74    let context = TemplateContext {
75        tool_name: name.clone(),
76        description: format!("LangChain adapter for {}", name),
77        parameters: "input: str".to_string(),
78    };
79
80    // Step 4: Render template
81    let adapter_code = render_langchain_template(&context)?;
82
83    // Step 5: Verify Python syntax
84    let syntax_valid = verify_python_syntax(&adapter_code).unwrap_or(false);
85    if !syntax_valid {
86        return Err(clap_noun_verb::NounVerbError::execution_error(
87            "Generated Python code has invalid syntax".to_string(),
88        ));
89    }
90
91    // Step 6: Write output file
92    let output_file = output_dir.join(format!("{}.tool.py", name));
93    fs::write(&output_file, &adapter_code).map_err(|e| {
94        clap_noun_verb::NounVerbError::execution_error(format!(
95            "Failed to write output file: {}",
96            e
97        ))
98    })?;
99
100    // Step 7: Return success
101    let output_path = output_file.to_string_lossy().to_string();
102    Ok(BridgeLangChainOutput {
103        tool_name: name.clone(),
104        framework: "langchain".to_string(),
105        output_path,
106        status: "generated".to_string(),
107        message: format!(
108            "LangChain adapter for '{}' generated successfully. Location: {}",
109            name,
110            output_dir.display()
111        ),
112        python_syntax_valid: syntax_valid,
113    })
114}
115
116// ============================================================================
117// Helper Functions
118// ============================================================================
119
120/// Validate component name
121fn validate_component_name(name: &str) -> NounVerbResult<()> {
122    if name.trim().is_empty() {
123        return Err(clap_noun_verb::NounVerbError::argument_error(
124            "Component name must not be empty",
125        ));
126    }
127    let valid = name
128        .chars()
129        .all(|c| c.is_alphanumeric() || c == '_' || c == '-');
130    if !valid {
131        return Err(clap_noun_verb::NounVerbError::argument_error(
132            "Component name contains invalid characters. Use alphanumeric, underscores, hyphens only.",
133        ));
134    }
135    Ok(())
136}
137
138/// Render the LangChain template using Tera
139fn render_langchain_template(context: &TemplateContext) -> NounVerbResult<String> {
140    // Try to load and render the Tera template
141    let template_path = "templates/langchain.tool.py.tera";
142
143    if Path::new(template_path).exists() {
144        // Use Tera template if it exists
145        use tera::Tera;
146
147        // Tera::new expects a glob pattern, so we use a wildcard pattern
148        let tera = Tera::new("templates/*.tera").map_err(|e| {
149            clap_noun_verb::NounVerbError::execution_error(format!(
150                "Failed to load templates: {}",
151                e
152            ))
153        })?;
154
155        let mut ctx = tera::Context::new();
156        ctx.insert("tool_name", &context.tool_name);
157        ctx.insert("description", &context.description);
158        ctx.insert("parameters", &context.parameters);
159
160        tera.render("langchain.tool.py.tera", &ctx).map_err(|e| {
161            clap_noun_verb::NounVerbError::execution_error(format!(
162                "Failed to render template: {}",
163                e
164            ))
165        })
166    } else {
167        // Fallback: generate template inline
168        generate_langchain_fallback(context)
169    }
170}
171
172/// Generate LangChain adapter code without a template (fallback)
173fn generate_langchain_fallback(context: &TemplateContext) -> NounVerbResult<String> {
174    let pascal_name = pascal_case(&context.tool_name);
175
176    let code = format!(
177        r#"from langchain.tools import BaseTool
178from typing import Optional
179
180class {}Tool(BaseTool):
181    """{}"""
182
183    name = "{}"
184    description = "{}"
185
186    def _run(self, {}) -> str:
187        """Execute the tool."""
188        # Call wrapped component
189        return "{} executed"
190
191    async def _arun(self, {}) -> str:
192        """Execute the tool asynchronously."""
193        return self._run({})
194"#,
195        pascal_name,
196        context.description,
197        context.tool_name,
198        context.description,
199        context.parameters,
200        context.tool_name,
201        context.parameters,
202        extract_param_names(&context.parameters)
203    );
204
205    Ok(code)
206}
207
208/// Convert snake_case to PascalCase
209fn pascal_case(input: &str) -> String {
210    input
211        .split('_')
212        .map(|word| {
213            let mut chars = word.chars();
214            match chars.next() {
215                None => String::new(),
216                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
217            }
218        })
219        .collect()
220}
221
222/// Extract parameter names from "param1: type, param2: type" format
223fn extract_param_names(params: &str) -> String {
224    params
225        .split(',')
226        .filter_map(|p| {
227            p.trim()
228                .split(':')
229                .next()
230                .map(|name| name.trim().to_string())
231        })
232        .collect::<Vec<_>>()
233        .join(", ")
234}
235
236/// Verify Python syntax by attempting to compile
237fn verify_python_syntax(code: &str) -> Result<bool, String> {
238    // Create a temporary file with the code
239    let temp_file = std::env::temp_dir().join("ggen_verify_syntax.py");
240
241    fs::write(&temp_file, code).map_err(|e| format!("Failed to write temp file: {}", e))?;
242
243    // Use Python to verify syntax
244    let output = Command::new("python3")
245        .arg("-m")
246        .arg("py_compile")
247        .arg(temp_file.to_str().unwrap())
248        .output()
249        .or_else(|_| {
250            // Fallback to python if python3 not found
251            Command::new("python")
252                .arg("-m")
253                .arg("py_compile")
254                .arg(temp_file.to_str().unwrap())
255                .output()
256        })
257        .map_err(|e| format!("Failed to run Python: {}", e))?;
258
259    // Clean up temp file
260    let _ = fs::remove_file(&temp_file);
261
262    Ok(output.status.success())
263}