use std::collections::BTreeMap;
use std::path::Path;
use serde_json::json;
use tokio::fs;
use atomr_agents_coding_cli_core::{
ConceptProjection, MapperError, PersonaSnapshot, SkillSnapshot, ToolSetSnapshot,
};
pub async fn materialize(projection: &ConceptProjection, workdir: &Path) -> Result<(), MapperError> {
write_system_instructions(projection, workdir).await?;
write_settings(&projection.toolsets, workdir).await?;
Ok(())
}
async fn write_system_instructions(
p: &ConceptProjection,
workdir: &Path,
) -> Result<(), MapperError> {
let body = compose(p.persona.as_ref(), p.project_memory.as_deref(), &p.skills);
let dir = workdir.join(".gemini");
fs::create_dir_all(&dir)
.await
.map_err(|e| MapperError::io(dir.display().to_string(), e))?;
let path = dir.join("system_instructions.md");
fs::write(&path, body.as_bytes())
.await
.map_err(|e| MapperError::io(path.display().to_string(), e))
}
fn compose(
persona: Option<&PersonaSnapshot>,
project_memory: Option<&str>,
skills: &[SkillSnapshot],
) -> String {
let mut out = String::new();
out.push_str("# System instructions\n\n");
out.push_str(
"<!-- Generated by atomr-agents-coding-cli-harness. Do not edit by hand. -->\n\n",
);
if let Some(p) = persona {
out.push_str("## Identity\n\n");
out.push_str(&p.identity);
if !p.identity.ends_with('\n') {
out.push('\n');
}
if !p.salient_traits.is_empty() {
out.push_str("\n### Salient traits\n\n");
for t in &p.salient_traits {
out.push_str("- ");
out.push_str(t);
out.push('\n');
}
}
if let Some(tone) = &p.style_tone {
out.push_str("\n### Style / tone\n\n");
out.push_str(tone);
out.push('\n');
}
out.push('\n');
}
if let Some(mem) = project_memory {
out.push_str("## Project memory\n\n");
out.push_str(mem);
if !mem.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
if !skills.is_empty() {
out.push_str("## Skills\n\n");
for s in skills {
out.push_str(&format!("### {} (priority {})\n\n", s.name, s.priority));
out.push_str(&s.instruction_fragment);
if !s.instruction_fragment.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
}
out
}
async fn write_settings(toolsets: &[ToolSetSnapshot], workdir: &Path) -> Result<(), MapperError> {
if toolsets.iter().all(|t| t.mcp_servers.is_empty()) {
return Ok(());
}
let dir = workdir.join(".gemini");
fs::create_dir_all(&dir)
.await
.map_err(|e| MapperError::io(dir.display().to_string(), e))?;
let mut servers = BTreeMap::new();
for ts in toolsets {
for s in &ts.mcp_servers {
servers.insert(
s.name.clone(),
json!({"command": s.command, "args": s.args, "env": s.env}),
);
}
}
let body = json!({ "mcpServers": servers });
let pretty = serde_json::to_vec_pretty(&body)?;
let path = dir.join("settings.json");
fs::write(&path, &pretty)
.await
.map_err(|e| MapperError::io(path.display().to_string(), e))
}
#[cfg(test)]
mod tests {
use super::*;
use atomr_agents_coding_cli_core::{
McpServerSnapshot, PersonaSnapshot, SkillSnapshot, ToolSetSnapshot,
};
use tempfile::TempDir;
#[tokio::test]
async fn materializes_system_instructions_and_settings() {
let dir = TempDir::new().unwrap();
let p = ConceptProjection {
persona: Some(PersonaSnapshot {
identity: "Web hardener.".into(),
salient_traits: vec![],
style_tone: None,
}),
skills: vec![SkillSnapshot {
id: "review".into(),
name: "Review".into(),
instruction_fragment: "Look for XSS.".into(),
keywords: vec![],
priority: 5,
tools: vec![],
}],
toolsets: vec![ToolSetSnapshot {
id: "mcp".into(),
mcp_servers: vec![McpServerSnapshot {
name: "linear".into(),
command: "npx".into(),
args: vec![],
env: Default::default(),
}],
tool_names: vec![],
}],
policy: Default::default(),
project_memory: None,
};
materialize(&p, dir.path()).await.unwrap();
let body = std::fs::read_to_string(dir.path().join(".gemini/system_instructions.md")).unwrap();
assert!(body.contains("# System instructions"));
assert!(body.contains("Web hardener"));
assert!(body.contains("### Review (priority 5)"));
let settings = std::fs::read_to_string(dir.path().join(".gemini/settings.json")).unwrap();
assert!(settings.contains("\"linear\""));
}
}