use crate::codegen::transaction::FileTransaction;
use crate::graph::{ConstructExecutor, Graph};
use crate::manifest::{GenerationRule, GgenManifest, InferenceRule};
use crate::utils::error::{Error, Result};
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
pub trait LlmService: Send + Sync {
fn generate_skill_impl(
&self, skill_name: &str, system_prompt: &str, implementation_hint: &str, language: &str,
) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>>;
fn clone_box(&self) -> Box<dyn LlmService>;
}
type GlobalLlmService = Arc<Mutex<Option<Box<dyn LlmService>>>>;
static GLOBAL_LLM_SERVICE: once_cell::sync::Lazy<GlobalLlmService> =
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
pub fn set_llm_service(service: Box<dyn LlmService>) {
let mut svc = GLOBAL_LLM_SERVICE.lock().unwrap();
*svc = Some(service);
}
pub fn get_llm_service() -> Option<Box<dyn LlmService>> {
let svc = GLOBAL_LLM_SERVICE.lock().unwrap();
svc.as_ref().map(|s| s.clone_box())
}
#[derive(Debug, Clone)]
struct TemplateFallbackService;
impl LlmService for TemplateFallbackService {
fn generate_skill_impl(
&self, skill_name: &str, system_prompt: &str, implementation_hint: &str, language: &str,
) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>> {
let stub = match language {
"rust" => format!(
"// [ManualImplementation] Implement {} skill (Rust)\n// Description: {}\n// Hint: {}\n\
// Note: LLM auto-generation is not configured (TemplateFallback used)",
skill_name, system_prompt, implementation_hint
),
"elixir" => format!(
"# [ManualImplementation] Implement {} skill (Elixir)\n# Description: {}\n# Hint: {}\n\
# Note: LLM auto-generation is not configured (TemplateFallback used)",
skill_name, system_prompt, implementation_hint
),
"typescript" | "javascript" => format!(
"// [ManualImplementation] Implement {} skill (TypeScript/JavaScript)\n\
// Description: {}\n// Hint: {}\n\
// Note: LLM auto-generation is not configured (TemplateFallback used)",
skill_name, system_prompt, implementation_hint
),
_ => format!(
"// [ManualImplementation] Implement {} skill ({})\n// Description: {}\n// Hint: {}\n\
// Note: TemplateFallback used",
skill_name, language, system_prompt, implementation_hint
),
};
Ok(stub)
}
fn clone_box(&self) -> Box<dyn LlmService> {
Box::new(self.clone())
}
}
pub struct PipelineState {
pub manifest: GgenManifest,
pub ontology_graph: Graph,
pub executed_rules: Vec<ExecutedRule>,
pub generated_files: Vec<GeneratedFile>,
pub validation_results: Vec<ValidationResult>,
pub started_at: Instant,
}
#[derive(Debug, Clone, Serialize)]
pub struct ExecutedRule {
pub name: String,
pub rule_type: RuleType,
pub triples_added: usize,
pub duration_ms: u64,
pub query_hash: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub enum RuleType {
Inference,
Generation,
}
#[derive(Debug, Clone, Serialize)]
pub struct GeneratedFile {
pub path: PathBuf,
pub content_hash: String,
pub size_bytes: usize,
pub source_rule: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationResult {
pub rule_name: String,
pub passed: bool,
pub message: Option<String>,
pub severity: ValidationSeverity,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub enum ValidationSeverity {
Error,
Warning,
}
pub struct GenerationPipeline {
manifest: GgenManifest,
base_path: PathBuf,
ontology_graph: Option<Graph>,
executed_rules: Vec<ExecutedRule>,
generated_files: Vec<GeneratedFile>,
validation_results: Vec<ValidationResult>,
started_at: Instant,
force_overwrite: bool,
output_dir_override: Option<PathBuf>,
llm_service: Option<Box<dyn LlmService>>,
}
fn clean_sparql_term(value: &str) -> String {
if value.starts_with('<') && value.ends_with('>') {
value[1..value.len() - 1].to_string()
} else if let Some(without_prefix) = value.strip_prefix('"') {
if let Some(quote_end) = without_prefix.find('"') {
without_prefix[..quote_end].to_string()
} else {
value.to_string()
}
} else {
value.to_string()
}
}
impl GenerationPipeline {
pub fn new(manifest: GgenManifest, base_path: PathBuf) -> Self {
Self {
manifest,
base_path,
ontology_graph: None,
executed_rules: Vec::new(),
generated_files: Vec::new(),
validation_results: Vec::new(),
started_at: Instant::now(),
force_overwrite: false,
output_dir_override: None,
llm_service: None,
}
}
pub fn set_llm_service(&mut self, service: Option<Box<dyn LlmService>>) {
self.llm_service = service;
}
pub fn set_force_overwrite(&mut self, force: bool) {
self.force_overwrite = force;
}
pub fn set_output_dir(&mut self, output_dir: PathBuf) {
self.output_dir_override = Some(output_dir);
}
pub fn load_ontology(&mut self) -> Result<()> {
let graph = Graph::new()?;
let source_path = self.base_path.join(&self.manifest.ontology.source);
let content = std::fs::read_to_string(&source_path).map_err(|e| {
Error::new(&format!(
"Failed to read ontology '{}': {}",
source_path.display(),
e
))
})?;
graph.insert_turtle(&content)?;
for import in &self.manifest.ontology.imports {
let import_path = self.base_path.join(import);
let import_content = std::fs::read_to_string(&import_path).map_err(|e| {
Error::new(&format!(
"Failed to read ontology import '{}': {}",
import_path.display(),
e
))
})?;
graph.insert_turtle(&import_content)?;
}
self.ontology_graph = Some(graph);
Ok(())
}
pub fn execute_inference_rules(&mut self) -> Result<Vec<ExecutedRule>> {
let mut executed = Vec::new();
let mut rules: Vec<_> = self.manifest.inference.rules.clone();
rules.sort_by_key(|r| r.order);
for rule in rules {
let result = self.execute_inference_rule(&rule)?;
executed.push(result);
}
self.executed_rules.extend(executed.clone());
Ok(executed)
}
fn execute_inference_rule(&mut self, rule: &InferenceRule) -> Result<ExecutedRule> {
let start = Instant::now();
let graph = self
.ontology_graph
.as_ref()
.ok_or_else(|| Error::new("Ontology graph not loaded. Call load_ontology() first."))?;
if let Some(ref when_query) = rule.when {
if !self.evaluate_condition(when_query)? {
return Ok(ExecutedRule {
name: rule.name.clone(),
rule_type: RuleType::Inference,
triples_added: 0, duration_ms: start.elapsed().as_millis() as u64,
query_hash: "skipped".to_string(),
});
}
}
let executor = ConstructExecutor::new(graph);
let triples_added = executor
.execute_and_materialize(&rule.construct)
.map_err(|e| Error::new(&format!("Inference rule '{}' failed: {}", rule.name, e)))?;
let duration = start.elapsed();
let query_hash = format!("{:x}", sha2::Sha256::digest(rule.construct.as_bytes()));
Ok(ExecutedRule {
name: rule.name.clone(),
rule_type: RuleType::Inference,
triples_added,
duration_ms: duration.as_millis() as u64,
query_hash,
})
}
fn evaluate_condition(&self, ask_query: &str) -> Result<bool> {
use oxigraph::sparql::QueryResults;
let graph = self
.ontology_graph
.as_ref()
.ok_or_else(|| Error::new("Ontology graph not loaded"))?;
let results = graph
.query(ask_query)
.map_err(|e| Error::new(&format!("Condition query failed: {}", e)))?;
match results {
QueryResults::Boolean(result) => Ok(result),
_ => Err(Error::new(
"error[E0002]: Condition query must return boolean (ASK), not results\n --> query used in WHEN condition\n |\n = help: Change SPARQL query from SELECT/CONSTRUCT to ASK:\n = ASK { ... }\n = help: Conditions must return true/false, not result rows\n = help: Example: ASK { ?x a :Type }",
)),
}
}
pub fn execute_generation_rules(&mut self) -> Result<Vec<GeneratedFile>> {
use crate::manifest::{GenerationMode, QuerySource, TemplateSource};
use oxigraph::sparql::QueryResults;
let mut generated = Vec::new();
let graph = self
.ontology_graph
.as_ref()
.ok_or_else(|| Error::new("Ontology graph not loaded. Call load_ontology() first."))?;
let rules = self.manifest.generation.rules.clone();
let output_dir = if let Some(ref override_dir) = self.output_dir_override {
self.base_path.join(override_dir)
} else {
self.base_path.join(&self.manifest.generation.output_dir)
};
let mut transaction = FileTransaction::new()?;
for rule in &rules {
let start = Instant::now();
if let Some(when_query) = &rule.when {
if !self.evaluate_condition(when_query)? {
continue;
}
}
let query = match &rule.query {
QuerySource::File { file } => {
let query_path = self.base_path.join(file);
std::fs::read_to_string(&query_path).map_err(|e| {
Error::new(&format!(
"Failed to read query file '{}': {}",
query_path.display(),
e
))
})?
}
QuerySource::Inline { inline } => inline.clone(),
};
let results = graph.query(&query).map_err(|e| {
Error::new(&format!(
"Generation rule '{}' query failed: {}",
rule.name, e
))
})?;
let rows = match results {
QueryResults::Solutions(solutions) => {
let mut rows = Vec::new();
for solution in solutions {
let solution = solution
.map_err(|e| Error::new(&format!("SPARQL solution error: {}", e)))?;
let mut row = BTreeMap::new();
for (var, term) in solution.iter() {
let key = var.to_string();
let clean_key = key.strip_prefix('?').unwrap_or(&key).to_string();
row.insert(clean_key, clean_sparql_term(&term.to_string()));
}
rows.push(row);
}
rows
}
_ => {
return Err(Error::new(&format!(
"error[E0003]: Generation rules require SELECT queries (not CONSTRUCT/ASK)\n --> rule: '{}'\n |\n = help: Change SPARQL query to SELECT to return result rows for template rendering\n = help: Example: SELECT ?var WHERE {{ ... }}",
rule.name
)));
}
};
if rows.is_empty() && rule.skip_empty {
continue;
}
let (template_content, _template_source_info) = match &rule.template {
TemplateSource::File { file } => {
let template_path = self.base_path.join(file);
let content = std::fs::read_to_string(&template_path).map_err(|e| {
Error::new(&format!(
"error[E0008]: Failed to read template file\n --> path: '{}'\n |\n = error: {}\n = help: Check if file exists and is readable\n = help: Verify template path in ggen.toml is relative to project root",
template_path.display(),
e
))
})?;
(content, format!("file '{}'", template_path.display()))
}
TemplateSource::Inline { inline } => {
(inline.clone(), "inline template".to_string())
}
TemplateSource::Git { git, branch, path } => {
let temp_id = uuid::Uuid::new_v4();
let temp_dir = std::env::temp_dir().join(format!("ggen-git-{}", temp_id));
let mut cmd = std::process::Command::new("git");
cmd.arg("clone").arg("--depth").arg("1");
if let Some(b) = branch {
cmd.arg("--branch").arg(b);
}
cmd.arg(git).arg(&temp_dir);
let status = cmd
.status()
.map_err(|e| Error::new(&format!("Failed to execute git clone: {}", e)))?;
if !status.success() {
return Err(Error::new(&format!(
"Failed to clone git repository: {}",
git
)));
}
let template_path = temp_dir.join(path);
let content = std::fs::read_to_string(&template_path).map_err(|e| {
Error::new(&format!("Failed to read template file from git: {}", e))
})?;
let _ = std::fs::remove_dir_all(temp_dir);
(content, format!("git '{}'", git))
}
TemplateSource::Package {
package,
version,
path,
} => {
let home = dirs::home_dir()
.ok_or_else(|| Error::new("Failed to determine home directory"))?;
let mut pack_dir = home.join(".ggen").join("packs").join(package);
if let Some(v) = version {
pack_dir = pack_dir.join(v);
} else {
pack_dir = pack_dir.join("latest");
}
let template_path = pack_dir.join(path);
let content = std::fs::read_to_string(&template_path).map_err(|e| {
Error::new(&format!(
"Failed to read template file from package {}: {}",
package, e
))
})?;
(content, format!("package '{}'", package))
}
};
let mut tera = tera::Tera::default();
crate::register::register_all(&mut tera);
tera.add_raw_template("generation_rule", &template_content)
.map_err(|e| {
Error::new(&format!(
"Template parse error in rule '{}': {}",
rule.name, e
))
})?;
for row in &rows {
let mut context = tera::Context::new();
for (key, value) in row {
let clean_key = key.strip_prefix('?').unwrap_or(key);
context.insert(clean_key, value.as_str());
}
let results_json = serde_json::json!(rows);
context.insert("results", &results_json);
context.insert("sparql_results", &results_json);
context.insert("entities", &results_json);
if self.manifest.generation.enable_llm {
let skill_name = row
.get("?skill_name")
.or_else(|| row.get("skill_name"))
.map(|s| s.as_str())
.unwrap_or("");
let system_prompt = row
.get("?system_prompt")
.or_else(|| row.get("system_prompt"))
.or_else(|| row.get("?skill_description"))
.map(|s| s.as_str())
.unwrap_or("");
let implementation_hint = row
.get("?implementation_hint")
.or_else(|| row.get("implementation_hint"))
.map(|s| s.as_str())
.unwrap_or("Implement this skill");
let language = row
.get("?language")
.or_else(|| row.get("language"))
.or_else(|| row.get("?target_language"))
.map(|s| s.as_str())
.unwrap_or_else(|| {
let output_ext = rule.output_file.rsplit('.').next().unwrap_or("");
match output_ext {
"rs" => "rust",
"ex" | "exs" => "elixir",
"ts" => "typescript",
"js" => "javascript",
"go" => "go",
"java" => "java",
_ => "rust", }
});
if !skill_name.is_empty() && !system_prompt.is_empty() {
match self.generate_skill_impl(
skill_name,
system_prompt,
implementation_hint,
language,
) {
Ok(generated_code) => {
context.insert("generated_impl", &generated_code);
}
Err(e) => {
eprintln!(
"Warning: LLM generation failed for skill '{}': {}. Using TemplateFallback stub.",
skill_name, e
);
context.insert(
"generated_impl",
&format!(
"// [ManualImplementation] Implement {} skill: {}\n// Hint: {}\n// Note: LLM generation failed (TemplateFallback used)",
skill_name, system_prompt, implementation_hint
),
);
}
}
}
}
let rendered = tera.render("generation_rule", &context).map_err(|e| {
let var_names: Vec<String> = row
.keys()
.map(|k| k.strip_prefix('?').unwrap_or(k).to_string())
.collect();
let row_values: Vec<String> = row
.iter()
.map(|(k, v)| {
let clean_key = k.strip_prefix('?').unwrap_or(k);
let display_value = if v.len() > 100 {
format!("{}...", &v[..100])
} else {
v.clone()
};
format!("{} = \"{}\"", clean_key, display_value)
})
.collect();
let mut error_chain = format!("{}", e);
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
error_chain.push_str(&format!("\n Caused by: {}", cause));
source = std::error::Error::source(cause);
}
Error::new(&format!(
"Failed to render template for rule '{}': {}\n\
Template source: {}\n\
Available variables: {}\n\
Row values:\n {}",
rule.name,
error_chain,
_template_source_info,
var_names.join(", "),
row_values.join("\n ")
))
})?;
let output_path_rendered =
tera.render_str(&rule.output_file, &context).map_err(|e| {
Error::new(&format!(
"Output path template error in rule '{}': {}",
rule.name, e
))
})?;
let full_output_path = output_dir.join(&output_path_rendered);
let final_content = match rule.mode {
GenerationMode::Create => {
if full_output_path.exists() {
continue;
}
rendered.clone()
}
GenerationMode::Overwrite => rendered.clone(),
GenerationMode::Merge => {
if full_output_path.exists() {
let existing =
std::fs::read_to_string(&full_output_path).map_err(|e| {
Error::new(&format!(
"Failed to read existing file for merge '{}': {}",
full_output_path.display(),
e
))
})?;
crate::codegen::merge::merge_sections(&rendered, &existing)?
} else {
crate::codegen::merge::merge_sections(&rendered, "")?
}
}
};
Self::validate_generated_output(
&final_content,
full_output_path.as_path(),
&rule.name,
)?;
transaction.write_file(&full_output_path, &final_content)?;
let content_hash = format!("{:x}", sha2::Sha256::digest(final_content.as_bytes()));
generated.push(GeneratedFile {
path: full_output_path,
content_hash,
size_bytes: final_content.len(),
source_rule: rule.name.clone(),
});
}
let duration = start.elapsed();
let query_hash = format!("{:x}", sha2::Sha256::digest(query.as_bytes()));
self.executed_rules.push(ExecutedRule {
name: rule.name.clone(),
rule_type: RuleType::Generation,
triples_added: 0, duration_ms: duration.as_millis() as u64,
query_hash,
});
}
let _receipt = transaction.commit()?;
self.generated_files.extend(generated.clone());
Ok(generated)
}
pub fn execute_generation_rule(&mut self, rule: &GenerationRule) -> Result<Vec<GeneratedFile>> {
let original_rules = std::mem::take(&mut self.manifest.generation.rules);
self.manifest.generation.rules = vec![rule.clone()];
let result = self.execute_generation_rules();
self.manifest.generation.rules = original_rules;
result
}
pub fn run(&mut self) -> Result<PipelineState> {
self.load_ontology()?;
self.execute_inference_rules()?;
self.execute_generation_rules()?;
let state = PipelineState {
manifest: self.manifest.clone(),
ontology_graph: self
.ontology_graph
.take()
.ok_or_else(|| Error::new("Ontology graph not initialized"))?,
executed_rules: self.executed_rules.clone(),
generated_files: self.generated_files.clone(),
validation_results: self.validation_results.clone(),
started_at: self.started_at,
};
Ok(state)
}
pub fn expand_output_path(pattern: &str, context: &BTreeMap<String, String>) -> PathBuf {
let mut result = pattern.to_string();
for (key, value) in context {
let clean_key = key.strip_prefix('?').unwrap_or(key);
let clean_value = if value.starts_with('<') && value.ends_with('>') {
&value[1..value.len() - 1]
} else if let Some(without_prefix) = value.strip_prefix('"') {
if let Some(quote_end) = without_prefix.find('"') {
&without_prefix[..quote_end]
} else {
value.as_str()
}
} else {
value.as_str()
};
let placeholder = format!("{{{{{}}}}}", clean_key);
result = result.replace(&placeholder, clean_value);
}
PathBuf::from(result)
}
fn validate_generated_output(content: &str, path: &Path, rule_id: &str) -> Result<()> {
if content.is_empty() {
return Err(Error::new(&format!(
"error[E0004]: Generated content is empty\n --> rule: '{}', output: '{}'\n |\n = help: Check if:\n = 1. SPARQL query returned results (test in separate SPARQL tool)\n = 2. Template has content (not empty file)\n = 3. Template variables match query result columns\n = help: Use 'ggen validate --dry-run' to see query results",
rule_id,
path.display()
)));
}
const MAX_SIZE_BYTES: usize = 10 * 1024 * 1024;
let size_bytes = content.len();
if size_bytes > MAX_SIZE_BYTES {
return Err(Error::new(&format!(
"error[E0005]: Generated file too large ({} bytes, limit: 10MB)\n --> rule: '{}', output: '{}'\n |\n = help: Consider splitting into multiple smaller files\n = help: Or adjust template to reduce output size\n = help: Check for unexpected data duplication in SPARQL results",
size_bytes,
rule_id,
path.display()
)));
}
let path_str = path.to_string_lossy();
if path_str.contains("../") || path_str.contains("..\\") {
return Err(Error::new(&format!(
"error[E0006]: Directory traversal pattern detected in output path\n --> rule: '{}', path: '{}'\n |\n = help: Remove '../' or '..\\' from template output path\n = help: Use relative paths from base directory without '..'\n = security: Directory traversal is blocked for security reasons",
rule_id,
path.display()
)));
}
Ok(())
}
pub fn generate_skill_impl(
&self, skill_name: &str, system_prompt: &str, implementation_hint: &str, language: &str,
) -> Result<String> {
if !self.manifest.generation.enable_llm {
return Ok(format!(
"// [ManualImplementation] Implement {} skill: {}\n// Hint: {}",
skill_name, system_prompt, implementation_hint
));
}
let service = self
.llm_service
.as_ref()
.map(|s| s.as_ref())
.unwrap_or(&TemplateFallbackService);
service
.generate_skill_impl(skill_name, system_prompt, implementation_hint, language)
.map_err(|e| Error::new(&format!("LLM generation failed: {}", e)))
}
}
use sha2::Digest;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_output_path() {
let mut ctx = BTreeMap::new();
ctx.insert("name".to_string(), "user".to_string());
ctx.insert("module".to_string(), "models".to_string());
let result = GenerationPipeline::expand_output_path("src/{{module}}/{{name}}.rs", &ctx);
assert_eq!(result, PathBuf::from("src/models/user.rs"));
}
#[test]
fn test_expand_output_path_no_vars() {
let ctx = BTreeMap::new();
let result = GenerationPipeline::expand_output_path("src/fixed.rs", &ctx);
assert_eq!(result, PathBuf::from("src/fixed.rs"));
}
#[test]
fn test_validation_rejects_empty_output() {
let empty_content = "";
let path = PathBuf::from("src/output.rs");
let rule_id = "test_rule";
let result = GenerationPipeline::validate_generated_output(empty_content, &path, rule_id);
assert!(
result.is_err(),
"Expected validation to fail for empty content"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("empty"),
"Error message should mention empty content: {}",
err_msg
);
assert!(
err_msg.contains("test_rule"),
"Error message should include rule_id: {}",
err_msg
);
assert!(
err_msg.contains("src/output.rs"),
"Error message should include path: {}",
err_msg
);
}
#[test]
fn test_validation_rejects_path_traversal() {
let valid_content = "fn main() {}";
let traversal_path = PathBuf::from("../../../etc/passwd");
let rule_id = "malicious_rule";
let result =
GenerationPipeline::validate_generated_output(valid_content, &traversal_path, rule_id);
assert!(
result.is_err(),
"Expected validation to fail for path traversal"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("traversal"),
"Error message should mention path traversal: {}",
err_msg
);
assert!(
err_msg.contains("malicious_rule"),
"Error message should include rule_id: {}",
err_msg
);
assert!(
err_msg.contains(".."),
"Error message should mention the traversal pattern: {}",
err_msg
);
}
#[test]
fn test_validation_accepts_valid_output() {
let valid_content = "pub struct User { id: u64 }";
let path = PathBuf::from("src/models/user.rs");
let rule_id = "generate_struct";
let result = GenerationPipeline::validate_generated_output(valid_content, &path, rule_id);
assert!(
result.is_ok(),
"Expected validation to pass for valid content: {:?}",
result
);
}
#[test]
fn test_validation_rejects_oversized_output() {
let oversized_content = "x".repeat(11 * 1024 * 1024); let path = PathBuf::from("src/huge.rs");
let rule_id = "huge_generator";
let result =
GenerationPipeline::validate_generated_output(&oversized_content, &path, rule_id);
assert!(
result.is_err(),
"Expected validation to fail for oversized content"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("10MB"),
"Error message should mention 10MB limit: {}",
err_msg
);
assert!(
err_msg.contains("huge_generator"),
"Error message should include rule_id: {}",
err_msg
);
}
#[test]
fn test_error_shows_available_variables() {
use std::collections::BTreeMap;
use tera::Tera;
let mut tera = Tera::default();
let template = "Hello {{ undefined_var }}!";
tera.add_raw_template("test_template", template).unwrap();
let mut context = tera::Context::new();
context.insert("name", "Alice");
context.insert("email", "alice@example.com");
let mut row: BTreeMap<String, String> = BTreeMap::new();
row.insert("?name".to_string(), "Alice".to_string());
row.insert("?email".to_string(), "alice@example.com".to_string());
let render_result = tera.render("test_template", &context).map_err(|e| {
let var_names: Vec<String> = row
.keys()
.map(|k| k.strip_prefix('?').unwrap_or(k).to_string())
.collect();
let row_values: Vec<String> = row
.iter()
.map(|(k, v)| {
let clean_key = k.strip_prefix('?').unwrap_or(k);
let display_value = if v.len() > 100 {
format!("{}...", &v[..100])
} else {
v.clone()
};
format!("{} = \"{}\"", clean_key, display_value)
})
.collect();
Error::new(&format!(
"Failed to render template for rule 'test_rule': {}\n\
Template source: inline template\n\
Available variables: {}\n\
Row values:\n {}",
e,
var_names.join(", "),
row_values.join("\n ")
))
});
assert!(render_result.is_err(), "Expected template render to fail");
let err_msg = render_result.unwrap_err().to_string();
assert!(
err_msg.contains("Available variables: email, name"),
"Error should list available variables, got: {}",
err_msg
);
assert!(
err_msg.contains("name = \"Alice\""),
"Error should show row values, got: {}",
err_msg
);
assert!(
err_msg.contains("email = \"alice@example.com\""),
"Error should show row values, got: {}",
err_msg
);
assert!(
err_msg.contains("Template source: inline template"),
"Error should show template source, got: {}",
err_msg
);
assert!(
err_msg.contains("test_rule"),
"Error should include rule name, got: {}",
err_msg
);
}
#[test]
fn test_set_and_get_llm_service() {
struct MockLlmService {
skill_name: String,
}
impl LlmService for MockLlmService {
fn generate_skill_impl(
&self, skill_name: &str, _system_prompt: &str, _implementation_hint: &str,
_language: &str,
) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>> {
Ok(format!("// Mock implementation for {}", skill_name))
}
fn clone_box(&self) -> Box<dyn LlmService> {
Box::new(MockLlmService {
skill_name: self.skill_name.clone(),
})
}
}
let service = Box::new(MockLlmService {
skill_name: "test_skill".to_string(),
});
set_llm_service(service);
let retrieved = get_llm_service();
assert!(retrieved.is_some(), "LLM service should be set");
let result = retrieved
.unwrap()
.generate_skill_impl("test_skill", "desc", "hint", "rust")
.unwrap();
assert!(
result.contains("test_skill"),
"Generated code should contain skill name"
);
}
#[ignore]
#[test]
fn test_get_llm_service_returns_none_when_not_set() {
let mut svc = GLOBAL_LLM_SERVICE.lock().unwrap();
*svc = None;
drop(svc);
let retrieved = get_llm_service();
assert!(
retrieved.is_none(),
"LLM service should be None when not set"
);
}
#[test]
fn test_llm_service_clone_box() {
struct CloneableLlmService {
counter: std::sync::Arc<std::sync::atomic::AtomicU32>,
}
impl LlmService for CloneableLlmService {
fn generate_skill_impl(
&self, skill_name: &str, _system_prompt: &str, _implementation_hint: &str,
_language: &str,
) -> std::result::Result<String, Box<dyn std::error::Error + Send + Sync>> {
Ok(format!("// Implementation {}", skill_name))
}
fn clone_box(&self) -> Box<dyn LlmService> {
self.counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Box::new(CloneableLlmService {
counter: std::sync::Arc::clone(&self.counter),
})
}
}
let counter = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let service1: Box<dyn LlmService> = Box::new(CloneableLlmService {
counter: std::sync::Arc::clone(&counter),
});
let service2 = service1.clone_box();
let result1 = service1
.generate_skill_impl("skill1", "desc", "hint", "rust")
.unwrap();
let result2 = service2
.generate_skill_impl("skill2", "desc", "hint", "rust")
.unwrap();
assert!(result1.contains("skill1"));
assert!(result2.contains("skill2"));
assert_eq!(
counter.load(std::sync::atomic::Ordering::SeqCst),
1,
"clone_box should have been called once"
);
}
#[test]
fn test_template_fallback_service_generates_stubs() {
let service = TemplateFallbackService;
let rust_impl = service
.generate_skill_impl("my_skill", "Do something", "Use async", "rust")
.unwrap();
let elixir_impl = service
.generate_skill_impl("my_skill", "Do something", "Use GenServer", "elixir")
.unwrap();
let ts_impl = service
.generate_skill_impl("my_skill", "Do something", "Use async/await", "typescript")
.unwrap();
assert!(rust_impl.contains("[ManualImplementation]"));
assert!(rust_impl.to_uppercase().contains("RUST"));
assert!(rust_impl.contains("my_skill"));
assert!(elixir_impl.contains("[ManualImplementation]"));
assert!(elixir_impl.to_uppercase().contains("ELIXIR"));
assert!(elixir_impl.contains("my_skill"));
assert!(ts_impl.contains("[ManualImplementation]"));
assert!(ts_impl.to_uppercase().contains("TYPESCRIPT"));
assert!(ts_impl.contains("my_skill"));
}
}