use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::Path;
use tera::Tera;
use super::paths::resolve_in_workspace;
use super::Tool;
use crate::account::{get_accounts_path, AccountStore};
use crate::project::Project;
pub struct ProjectInitTool;
#[async_trait]
impl Tool for ProjectInitTool {
fn name(&self) -> &str {
"project_init"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::Write
}
fn description(&self) -> &str {
"Initialize a new Soroban project with scaffolded contracts"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Project name"
},
"template": {
"type": "string",
"enum": ["token", "empty"],
"description": "Project template (default: empty)"
},
"path": {
"type": "string",
"description": "Directory to create project in (default: current directory)"
}
},
"required": ["name"]
})
}
async fn execute(&self, input: Value) -> Result<String, String> {
let name = input
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing 'name' parameter")?;
let template = input
.get("template")
.and_then(|v| v.as_str())
.unwrap_or("empty");
if name.is_empty() || name.contains('/') || name.contains('\\') || name.starts_with('.') {
return Err(format!(
"Invalid project name '{}': must not be empty, start with '.', or contain path separators",
name
));
}
let base_path = input.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let project_dir = resolve_in_workspace(base_path)?.join(name);
let procyon_dir = project_dir.join(".procyon");
let contracts_dir = project_dir.join("contracts");
tokio::fs::create_dir_all(&procyon_dir)
.await
.map_err(|e| format!("Failed to create .procyon dir: {}", e))?;
tokio::fs::create_dir_all(&contracts_dir)
.await
.map_err(|e| format!("Failed to create contracts dir: {}", e))?;
let project = Project::new(name);
project
.save(&procyon_dir.join("project.toml"))
.await
.map_err(|e| format!("Failed to save project config: {}", e))?;
if template == "token" {
let contract_name = format!("{}_token", name.replace('-', "_"));
scaffold_token_contract(&contracts_dir, &contract_name).await?;
}
Ok(format!(
"Project '{}' initialized at {}\nTemplate: {}\n\nCreated:\n- .procyon/project.toml\n- contracts/",
name,
project_dir.display(),
template
))
}
}
fn to_upper_camel(name: &str) -> String {
name.split(['_', '-'])
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
async fn scaffold_token_contract(contracts_dir: &Path, contract_name: &str) -> Result<(), String> {
let contract_dir = contracts_dir.join(contract_name);
let src_dir = contract_dir.join("src");
tokio::fs::create_dir_all(&src_dir)
.await
.map_err(|e| format!("Failed to create contract src dir: {}", e))?;
let mut tera = Tera::default();
tera.add_raw_template(
"contract.rs",
include_str!("../../templates/token_contract.rs"),
)
.map_err(|e| format!("Failed to add template: {}", e))?;
tera.add_raw_template(
"Cargo.toml",
include_str!("../../templates/contract_cargo.toml"),
)
.map_err(|e| format!("Failed to add template: {}", e))?;
let mut context = tera::Context::new();
context.insert("contract_name", contract_name);
context.insert("contract_struct", &to_upper_camel(contract_name));
let contract_code = tera
.render("contract.rs", &context)
.map_err(|e| format!("Failed to render contract template: {}", e))?;
let cargo_toml = tera
.render("Cargo.toml", &context)
.map_err(|e| format!("Failed to render Cargo.toml template: {}", e))?;
tokio::fs::write(src_dir.join("lib.rs"), contract_code)
.await
.map_err(|e| format!("Failed to write lib.rs: {}", e))?;
tokio::fs::write(contract_dir.join("Cargo.toml"), cargo_toml)
.await
.map_err(|e| format!("Failed to write Cargo.toml: {}", e))?;
Ok(())
}
pub struct ProjectInfoTool;
#[async_trait]
impl Tool for ProjectInfoTool {
fn name(&self) -> &str {
"project_info"
}
fn capability(&self) -> crate::risk::Capability {
crate::risk::Capability::ReadOnly
}
fn description(&self) -> &str {
"Get information about the current project"
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {},
"required": []
})
}
async fn execute(&self, _input: Value) -> Result<String, String> {
let current_dir = std::env::current_dir()
.map_err(|e| format!("Failed to get current directory: {}", e))?;
let project_dir = Project::find_project_dir(¤t_dir)
.await
.map_err(|e| e.to_string())?;
let project_file = project_dir.join(".procyon").join("project.toml");
let project = Project::load(&project_file)
.await
.map_err(|e| format!("Failed to load project: {}", e))?;
let mut output = format!(
"Project: {} v{}\nNetwork: {}\n",
project.name, project.version, project.default_network
);
if !project.contracts.is_empty() {
output.push_str("\nContracts:\n");
for contract in &project.contracts {
let addr = contract.address.as_deref().unwrap_or("not deployed");
output.push_str(&format!(" - {} ({})\n", contract.name, addr));
}
}
let store = AccountStore::load(&get_accounts_path(&project_dir))
.await
.map_err(|e| format!("Failed to load accounts: {}", e))?;
if !store.list().is_empty() {
output.push_str("\nAccounts:\n");
for account in store.list() {
output.push_str(&format!(
" - {} ({}) [{}]\n",
account.name, account.address, account.network
));
}
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_templates_render() {
let mut tera = Tera::default();
tera.add_raw_template(
"contract.rs",
include_str!("../../templates/token_contract.rs"),
)
.unwrap();
tera.add_raw_template(
"Cargo.toml",
include_str!("../../templates/contract_cargo.toml"),
)
.unwrap();
let mut context = tera::Context::new();
context.insert("contract_name", "my_token");
context.insert("contract_struct", "MyToken");
let contract = tera
.render("contract.rs", &context)
.expect("contract template must render");
let cargo = tera
.render("Cargo.toml", &context)
.expect("Cargo.toml template must render");
assert!(contract.contains("MyToken"), "struct name not substituted");
assert!(
!contract.contains("{{"),
"unrendered placeholder left behind"
);
assert!(cargo.contains("my_token"));
assert!(!cargo.contains("{{"));
}
#[test]
fn upper_camel_handles_separators_and_casing() {
assert_eq!(to_upper_camel("my_token"), "MyToken");
assert_eq!(to_upper_camel("my-cool-token"), "MyCoolToken");
assert_eq!(to_upper_camel("token"), "Token");
assert_eq!(to_upper_camel("my__token"), "MyToken");
assert_eq!(to_upper_camel("MY_token"), "MYToken");
}
#[test]
fn rendered_struct_name_is_a_valid_rust_identifier() {
let mut tera = Tera::default();
tera.add_raw_template(
"contract.rs",
include_str!("../../templates/token_contract.rs"),
)
.unwrap();
let mut context = tera::Context::new();
context.insert("contract_name", "my_cool_token");
context.insert("contract_struct", &to_upper_camel("my_cool_token"));
let contract = tera.render("contract.rs", &context).unwrap();
assert!(contract.contains("pub struct MyCoolTokenContract;"));
assert!(contract.contains("MyCoolTokenContractClient"));
}
#[tokio::test]
#[ignore]
async fn project_init_tool_end_to_end() {
let base = "target/procyon-demo";
let name = "token_demo";
let root = std::path::Path::new(base).join(name);
let _ = tokio::fs::remove_dir_all(&root).await;
let report = ProjectInitTool
.execute(json!({"name": name, "template": "token", "path": base}))
.await
.expect("project_init should succeed");
println!("--- project_init ---\n{}", report);
let project_file = root.join(".procyon/project.toml");
assert!(project_file.exists(), "missing {}", project_file.display());
let project = Project::load(&project_file)
.await
.expect("project.toml parses");
assert_eq!(project.name, name);
assert_eq!(project.default_network.to_string(), "testnet");
let crate_dir = root.join("contracts").join(format!("{}_token", name));
let lib = tokio::fs::read_to_string(crate_dir.join("src/lib.rs"))
.await
.expect("lib.rs");
assert!(
lib.contains("pub struct TokenDemoTokenContract;"),
"got a bad struct name"
);
assert!(!lib.contains("{{"), "unrendered placeholder survived");
assert!(lib.contains("#[contracterror]"));
let wasm = std::process::Command::new("cargo")
.args(["build", "--release", "--target", "wasm32v1-none"])
.current_dir(&crate_dir)
.output()
.expect("cargo runs");
assert!(
wasm.status.success(),
"generated contract failed to build:\n{}",
String::from_utf8_lossy(&wasm.stderr)
);
let artifact = crate_dir.join(format!("target/wasm32v1-none/release/{}_token.wasm", name));
let size = tokio::fs::metadata(&artifact).await.expect("wasm").len();
println!("wasm: {} ({} bytes)", artifact.display(), size);
assert!(size > 0);
}
#[tokio::test]
#[ignore]
async fn scaffolded_contract_builds_and_passes_its_own_tests() {
let temp = tempfile::tempdir().unwrap();
let contracts_dir = temp.path().join("contracts");
scaffold_token_contract(&contracts_dir, "my_token")
.await
.expect("scaffolding must succeed");
let crate_dir = contracts_dir.join("my_token");
assert!(crate_dir.join("src/lib.rs").exists());
assert!(crate_dir.join("Cargo.toml").exists());
let tests = std::process::Command::new("cargo")
.args(["test"])
.current_dir(&crate_dir)
.output()
.expect("cargo must be runnable");
assert!(
tests.status.success(),
"generated contract failed its own tests:\n{}",
String::from_utf8_lossy(&tests.stderr)
);
let wasm = std::process::Command::new("cargo")
.args(["build", "--release", "--target", "wasm32v1-none"])
.current_dir(&crate_dir)
.output()
.expect("cargo must be runnable");
assert!(
wasm.status.success(),
"generated contract failed to build for wasm32v1-none:\n{}",
String::from_utf8_lossy(&wasm.stderr)
);
assert!(
crate_dir
.join("target/wasm32v1-none/release/my_token.wasm")
.exists(),
"no wasm artifact produced"
);
}
}