use std::collections::HashMap;
use std::path::Path;
use serde_json::{json, Value};
use crate::managed_agent::types::{
AgentManifest, DeployInput, DeployOutput, SkillPayload, SubagentPayload,
};
use crate::CorpFinanceResult;
pub fn build_deploy_payload(input: &DeployInput) -> CorpFinanceResult<DeployOutput> {
let slug = &input.slug;
let cookbook_dir = Path::new(&input.cookbooks_root).join(slug);
let manifest_path = cookbook_dir.join("agent.json");
let raw = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("Cannot read {}: {}", manifest_path.display(), e))?;
let substituted = env_substitute(&raw, &input.env_vars);
let mut manifest: AgentManifest = serde_json::from_str(&substituted)
.map_err(|e| format!("JSON parse error in {}: {}", manifest_path.display(), e))?;
let mut skill_payloads: Vec<SkillPayload> = Vec::new();
for skill_ref in &manifest.skills {
let skill_path = Path::new(&input.skills_root)
.join(&skill_ref.from_skill)
.join("SKILL.md");
let content = std::fs::read_to_string(&skill_path).unwrap_or_default();
skill_payloads.push(SkillPayload {
from_skill: skill_ref.from_skill.clone(),
skill_path: skill_path.display().to_string(),
content_length: content.len(),
});
}
let mut subagent_payloads: Vec<SubagentPayload> = Vec::new();
for ca in &manifest.callable_agents {
let sub_path = cookbook_dir.join(&ca.manifest);
let sub_raw = std::fs::read_to_string(&sub_path)
.map_err(|e| format!("Cannot read subagent {}: {}", sub_path.display(), e))?;
let sub_substituted = env_substitute(&sub_raw, &input.env_vars);
let mut sub_value: Value = serde_json::from_str(&sub_substituted)
.map_err(|e| format!("JSON parse error in {}: {}", sub_path.display(), e))?;
inline_system_prompt(&mut sub_value, &cookbook_dir, Path::new(&input.agents_root))?;
subagent_payloads.push(SubagentPayload {
manifest_path: sub_path.display().to_string(),
payload: sub_value,
});
}
let mut orchestrator_value =
serde_json::to_value(&manifest).map_err(|e| format!("Serialisation error: {}", e))?;
inline_system_prompt(
&mut orchestrator_value,
&cookbook_dir,
Path::new(&input.agents_root),
)?;
if let Value::Array(ref subs) = orchestrator_value["callable_agents"].clone() {
let patched: Vec<Value> = subs
.iter()
.enumerate()
.map(|(i, ca)| {
let mut ca = ca.clone();
if ca.get("agent_id").is_none() {
ca["agent_id"] = json!(format!("{{subagent_{}_id}}", i));
}
ca
})
.collect();
orchestrator_value["callable_agents"] = Value::Array(patched);
}
for (i, ca) in manifest.callable_agents.iter_mut().enumerate() {
if ca.agent_id.is_none() {
ca.agent_id = Some(format!("{{subagent_{}_id}}", i));
}
}
Ok(DeployOutput {
slug: slug.clone(),
orchestrator_payload: orchestrator_value,
subagent_payloads,
skill_payloads,
dry_run: true,
})
}
pub(crate) fn env_substitute(text: &str, vars: &HashMap<String, String>) -> String {
let mut result = text.to_string();
for (key, value) in vars {
let placeholder = format!("${{{}}}", key);
result = result.replace(&placeholder, value);
}
result
}
fn inline_system_prompt(
value: &mut Value,
cookbook_dir: &Path,
agents_root: &Path,
) -> CorpFinanceResult<()> {
let file_ref = match value.pointer("/system/file").and_then(Value::as_str) {
Some(s) => s.to_string(),
None => return Ok(()),
};
let candidate1 = cookbook_dir.join(&file_ref);
let candidate2 = agents_root.join(
Path::new(&file_ref)
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new(&file_ref)),
);
let system_text = if candidate1.exists() {
std::fs::read_to_string(&candidate1)
.map_err(|e| format!("Cannot read system prompt {}: {}", candidate1.display(), e))?
} else if candidate2.exists() {
std::fs::read_to_string(&candidate2)
.map_err(|e| format!("Cannot read system prompt {}: {}", candidate2.display(), e))?
} else {
format!("{{system_prompt_file_missing: {}}}", file_ref)
};
let append = value
.pointer("/system/append")
.and_then(Value::as_str)
.map(|s| format!("\n\n{}", s))
.unwrap_or_default();
let combined = format!("{}{}", system_text, append);
value["system"] = json!({ "text": combined });
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_substitute_replaces_placeholders() {
let mut vars = HashMap::new();
vars.insert("FOO".to_string(), "bar".to_string());
vars.insert("BAZ".to_string(), "qux".to_string());
let result = env_substitute("${FOO} and ${BAZ}", &vars);
assert_eq!(result, "bar and qux");
}
#[test]
fn env_substitute_leaves_unset_vars() {
let vars: HashMap<String, String> = HashMap::new();
let result = env_substitute("${UNSET_VAR}", &vars);
assert_eq!(result, "${UNSET_VAR}");
}
#[test]
fn env_substitute_partial_replacement() {
let mut vars = HashMap::new();
vars.insert("A".to_string(), "1".to_string());
let result = env_substitute("${A} ${B}", &vars);
assert_eq!(result, "1 ${B}");
}
#[test]
fn inline_system_prompt_noop_when_no_file_ref() {
let mut v = json!({"system": {"text": "Hello"}});
inline_system_prompt(&mut v, Path::new("/tmp"), Path::new("/tmp")).unwrap();
assert_eq!(v["system"]["text"], "Hello");
}
#[test]
fn inline_system_prompt_uses_placeholder_for_missing_file() {
let mut v = json!({"system": {"file": "../../../.claude/agents/cfa/ghost.md"}});
inline_system_prompt(
&mut v,
Path::new("/tmp/no_dir"),
Path::new("/tmp/no_agents"),
)
.unwrap();
let text = v["system"]["text"].as_str().unwrap();
assert!(text.contains("system_prompt_file_missing"));
}
#[test]
fn build_deploy_payload_fails_gracefully_on_missing_cookbook() {
let input = DeployInput {
slug: "equity-analyst".to_string(),
cookbooks_root: "/tmp/no_such_cookbooks_dir".to_string(),
skills_root: "/tmp/skills".to_string(),
agents_root: "/tmp/agents".to_string(),
env_vars: HashMap::new(),
};
let result = build_deploy_payload(&input);
assert!(result.is_err());
}
}