Files
g3/crates/g3-cli/src/language_prompts.rs
Dhanji R. Prasanna 6cb70f26fa Fix empty Language-Specific Guidance header in system prompt
When a Rust-only workspace was detected, the Language-Specific Guidance
header was appearing with no content because Rust has an empty prompt
string (agent-specific prompts handle Rust instead).

The fix filters out empty prompt strings in get_language_prompts_for_workspace()
so the header only appears when there's actual guidance content.

Added test to verify Rust-only workspaces return None.
2026-02-05 14:00:52 +11:00

261 lines
8.5 KiB
Rust

//! Language-specific prompt injection.
//!
//! Detects programming languages in the workspace and injects relevant
//! toolchain guidance into the system prompt.
//!
//! Language prompts are embedded at compile time from `prompts/langs/*.md`.
use std::path::Path;
/// Embedded language prompts, keyed by language name.
/// The key should match common file extensions or language identifiers.
static LANGUAGE_PROMPTS: &[(&str, &[&str], &str)] = &[
// (language_name, file_extensions, prompt_content)
(
"rust",
&[".rs"],
"", // No base Rust prompt; agent-specific prompts handle this
),
(
"racket",
&[".rkt", ".rktl", ".rktd", ".scrbl"],
include_str!("../../../prompts/langs/racket.md"),
),
];
/// Embedded agent-specific language prompts.
/// Format: (agent_name, language_name, prompt_content)
static AGENT_LANGUAGE_PROMPTS: &[(&str, &str, &str)] = &[
// (agent_name, language_name, prompt_content)
("carmack", "racket", include_str!("../../../prompts/langs/carmack.racket.md")),
("carmack", "rust", include_str!("../../../prompts/langs/carmack.rust.md")),
];
/// Detect languages present in the workspace by scanning for file extensions.
/// Returns a list of detected language names.
pub fn detect_languages(workspace_dir: &Path) -> Vec<&'static str> {
let mut detected = Vec::new();
for (lang_name, extensions, _) in LANGUAGE_PROMPTS {
if has_files_with_extensions(workspace_dir, extensions) {
detected.push(*lang_name);
}
}
detected
}
/// Check if the workspace contains files with any of the given extensions.
/// Scans up to a reasonable depth to avoid slow startup on large repos.
fn has_files_with_extensions(workspace_dir: &Path, extensions: &[&str]) -> bool {
// Quick check: scan top-level and one level deep
// This avoids slow startup on large repos while catching most projects
scan_directory_for_extensions(workspace_dir, extensions, 2)
}
/// Recursively scan a directory for files with given extensions, up to max_depth.
fn scan_directory_for_extensions(dir: &Path, extensions: &[&str], max_depth: usize) -> bool {
if max_depth == 0 {
return false;
}
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return false,
};
for entry in entries.flatten() {
let path = entry.path();
// Skip hidden directories and common non-source directories
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') || name == "node_modules" || name == "target" || name == "vendor" {
continue;
}
}
if path.is_file() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
for ext in extensions {
if name.ends_with(ext) {
return true;
}
}
}
} else if path.is_dir() {
if scan_directory_for_extensions(&path, extensions, max_depth - 1) {
return true;
}
}
}
false
}
/// Get the prompt content for a specific language.
pub fn get_language_prompt(lang: &str) -> Option<&'static str> {
LANGUAGE_PROMPTS
.iter()
.find(|(name, _, _)| *name == lang)
.map(|(_, _, content)| *content)
}
/// Get all language prompts for detected languages in the workspace.
/// Returns formatted content ready for injection into the system prompt.
pub fn get_language_prompts_for_workspace(workspace_dir: &Path) -> Option<String> {
let detected = detect_languages(workspace_dir);
if detected.is_empty() {
return None;
}
let mut prompts = Vec::new();
for lang in detected {
if let Some(content) = get_language_prompt(lang) {
if !content.is_empty() {
prompts.push(content);
}
}
}
if prompts.is_empty() {
return None;
}
Some(format!(
"🔧 Language-Specific Guidance:\n\n{}",
prompts.join("\n\n---\n\n")
))
}
/// List all available language prompts.
pub fn list_available_languages() -> Vec<&'static str> {
LANGUAGE_PROMPTS.iter().map(|(name, _, _)| *name).collect()
}
/// Get agent-specific language prompt for a specific agent and language.
pub fn get_agent_language_prompt(agent_name: &str, lang: &str) -> Option<&'static str> {
AGENT_LANGUAGE_PROMPTS
.iter()
.find(|(agent, language, _)| *agent == agent_name && *language == lang)
.map(|(_, _, content)| *content)
}
/// Get agent-specific language prompts for detected languages in the workspace.
/// Returns formatted content ready for injection into the agent's system prompt.
#[allow(dead_code)]
pub fn get_agent_language_prompts_for_workspace(
workspace_dir: &Path,
agent_name: &str,
) -> Option<String> {
let (content, _) = get_agent_language_prompts_for_workspace_with_langs(workspace_dir, agent_name);
content
}
/// Get agent-specific language prompts for detected languages in the workspace.
/// Returns both the formatted content and the list of languages that had matching prompts.
pub fn get_agent_language_prompts_for_workspace_with_langs(
workspace_dir: &Path,
agent_name: &str,
) -> (Option<String>, Vec<&'static str>) {
let detected = detect_languages(workspace_dir);
let mut prompts = Vec::new();
let mut matched_langs = Vec::new();
for lang in detected {
if let Some(content) = get_agent_language_prompt(agent_name, lang) {
prompts.push(content.to_string());
matched_langs.push(lang);
}
}
let content = if prompts.is_empty() { None } else { Some(prompts.join("\n\n---\n\n")) };
(content, matched_langs)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_racket_prompt_embedded() {
let prompt = get_language_prompt("racket");
assert!(prompt.is_some());
assert!(prompt.unwrap().contains("raco"));
}
#[test]
fn test_list_available_languages() {
let langs = list_available_languages();
assert!(langs.contains(&"racket"));
}
#[test]
fn test_detect_racket_files() {
let temp_dir = TempDir::new().unwrap();
let rkt_file = temp_dir.path().join("main.rkt");
fs::write(&rkt_file, "#lang racket\n").unwrap();
let detected = detect_languages(temp_dir.path());
assert!(detected.contains(&"racket"));
}
#[test]
fn test_no_detection_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let detected = detect_languages(temp_dir.path());
assert!(detected.is_empty());
}
#[test]
fn test_get_prompts_for_workspace() {
let temp_dir = TempDir::new().unwrap();
let rkt_file = temp_dir.path().join("main.rkt");
fs::write(&rkt_file, "#lang racket\n").unwrap();
let prompts = get_language_prompts_for_workspace(temp_dir.path());
assert!(prompts.is_some());
let content = prompts.unwrap();
assert!(content.contains("🔧 Language-Specific Guidance"));
assert!(content.contains("raco"));
}
#[test]
fn test_carmack_racket_prompt_embedded() {
let prompt = get_agent_language_prompt("carmack", "racket");
assert!(prompt.is_some());
assert!(prompt.unwrap().contains("obvious, readable Racket"));
}
#[test]
fn test_agent_language_prompt_not_found() {
let prompt = get_agent_language_prompt("nonexistent", "racket");
assert!(prompt.is_none());
}
#[test]
fn test_get_agent_prompts_for_workspace() {
let temp_dir = TempDir::new().unwrap();
let rkt_file = temp_dir.path().join("main.rkt");
fs::write(&rkt_file, "#lang racket\n").unwrap();
let prompts = get_agent_language_prompts_for_workspace(temp_dir.path(), "carmack");
assert!(prompts.is_some());
let content = prompts.unwrap();
assert!(content.contains("obvious, readable Racket"));
}
#[test]
fn test_rust_only_returns_none() {
// Rust has an empty prompt, so a Rust-only workspace should return None
let temp_dir = TempDir::new().unwrap();
let rs_file = temp_dir.path().join("main.rs");
fs::write(&rs_file, "fn main() {}").unwrap();
let prompts = get_language_prompts_for_workspace(temp_dir.path());
assert!(prompts.is_none(), "Rust-only workspace should return None since Rust has no base prompt");
}
}