mod fragment;
pub use fragment::{FragmentContext, PromptFragment, compose_fragments};
#[derive(Clone)]
pub struct EnvironmentFragment {
pub os: String,
pub cwd: String,
pub git_branch: Option<String>,
}
impl EnvironmentFragment {
pub fn detect() -> Self {
Self {
os: std::env::consts::OS.to_string(),
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "unknown".to_string()),
git_branch: detect_git_branch(),
}
}
}
impl PromptFragment for EnvironmentFragment {
fn name(&self) -> &str {
"environment"
}
fn priority(&self) -> i32 {
50
}
fn render(&self, _ctx: &FragmentContext) -> Option<String> {
let mut lines = vec![
format!("[Environment]\n- OS: {}", self.os),
format!("- Working directory: {}", self.cwd),
];
if let Some(ref branch) = self.git_branch {
lines.push(format!("- Git branch: {}", branch));
}
Some(lines.join("\n"))
}
}
#[derive(Clone)]
pub struct DynamicToolsFragment;
impl PromptFragment for DynamicToolsFragment {
fn name(&self) -> &str {
"dynamic_tools"
}
fn priority(&self) -> i32 {
70
}
fn render(&self, ctx: &FragmentContext) -> Option<String> {
if ctx.tool_definitions.is_empty() {
return None;
}
let mut lines = vec!["[Available Tools]".to_string()];
for def in ctx.tool_definitions {
let name = def
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("unknown");
let desc = def
.get("function")
.and_then(|f| f.get("description"))
.and_then(|d| d.as_str())
.unwrap_or("");
if desc.is_empty() {
lines.push(format!("- `{}`", name));
} else {
lines.push(format!("- `{}` — {}", name, desc));
}
}
Some(lines.join("\n"))
}
}
fn detect_git_branch() -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_environment_fragment_render() {
let frag = EnvironmentFragment {
os: "macos".into(),
cwd: "/Users/test".into(),
git_branch: Some("main".into()),
};
let ctx = FragmentContext {
tool_definitions: &[],
session_id: "s1",
};
let output = frag.render(&ctx).unwrap();
assert!(output.contains("OS: macos"));
assert!(output.contains("/Users/test"));
assert!(output.contains("Git branch: main"));
}
#[test]
fn test_environment_fragment_no_git() {
let frag = EnvironmentFragment {
os: "linux".into(),
cwd: "/tmp".into(),
git_branch: None,
};
let ctx = FragmentContext {
tool_definitions: &[],
session_id: "s1",
};
let output = frag.render(&ctx).unwrap();
assert!(output.contains("OS: linux"));
assert!(!output.contains("Git branch"));
}
#[test]
fn test_dynamic_tools_fragment_with_tools() {
let tool_def = serde_json::json!({
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file from disk",
"parameters": {}
}
});
let frag = DynamicToolsFragment;
let ctx = FragmentContext {
tool_definitions: &[tool_def],
session_id: "test",
};
let output = frag.render(&ctx).unwrap();
assert!(output.contains("read_file"));
assert!(output.contains("Read a file from disk"));
}
#[test]
fn test_dynamic_tools_fragment_empty() {
let frag = DynamicToolsFragment;
let ctx = FragmentContext {
tool_definitions: &[],
session_id: "test",
};
assert!(frag.render(&ctx).is_none());
}
#[test]
fn test_compose_orders_by_priority() {
let env = EnvironmentFragment {
os: "linux".into(),
cwd: "/tmp".into(),
git_branch: None,
};
let tool_def = serde_json::json!({
"type": "function",
"function": { "name": "test_tool", "description": "A test", "parameters": {} }
});
let fragments: Vec<Box<dyn PromptFragment>> = vec![
Box::new(DynamicToolsFragment), Box::new(env), ];
let ctx = FragmentContext {
tool_definitions: &[tool_def],
session_id: "test",
};
let result = compose_fragments(&fragments, &ctx);
let env_pos = result.find("[Environment]").unwrap();
let tools_pos = result.find("[Available Tools]").unwrap();
assert!(
env_pos < tools_pos,
"Environment (50) should come before Tools (70)"
);
}
}