use super::manual_errors::ManualErrors;
use anyhow::{Context, Result};
use heck::ToLowerCamelCase;
use serde_json::Value;
use std::path::Path;
use syn::{Item, Token};
const CODAMA_DERIVES: &[&str] = &[
"CodamaAccount",
"CodamaAccounts",
"CodamaErrors",
"CodamaEvent",
"CodamaEvents",
"CodamaInstruction",
"CodamaInstructions",
"CodamaPda",
"CodamaType",
];
pub fn codama_macros_detected(crate_root: &Path, src_dir: &Path) -> Result<bool> {
let cargo_toml = crate_root.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_toml)
.with_context(|| format!("Failed to read {}", cargo_toml.display()))?;
let manifest: toml::Value = toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", cargo_toml.display()))?;
let has_codama_dep = manifest
.get("dependencies")
.and_then(|deps| deps.get("codama"))
.is_some();
if !has_codama_dep {
return Ok(false);
}
scan_for_codama_derives(src_dir)
}
fn scan_for_codama_derives(dir: &Path) -> Result<bool> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
if scan_for_codama_derives(&path)? {
return Ok(true);
}
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(file) = syn::parse_file(&src) else {
continue;
};
for item in &file.items {
if item_has_codama_derive(item) {
return Ok(true);
}
}
}
Ok(false)
}
fn item_has_codama_derive(item: &Item) -> bool {
let attrs = match item {
Item::Struct(s) => &s.attrs,
Item::Enum(e) => &e.attrs,
_ => return false,
};
attrs.iter().any(|attr| {
if !attr.path().is_ident("derive") {
return false;
}
let Ok(paths) = attr
.parse_args_with(syn::punctuated::Punctuated::<syn::Path, Token![,]>::parse_terminated)
else {
return false;
};
paths.iter().any(|p| {
p.segments
.last()
.is_some_and(|seg| CODAMA_DERIVES.iter().any(|name| seg.ident == name))
})
})
}
pub fn extract_native_codama_idl(
crate_root: &Path,
resolved_address: Option<&str>,
fallback_errors: &[Value],
manual: Option<&ManualErrors>,
) -> Result<String> {
let json = codama::Codama::load(crate_root)?.get_json_idl()?;
let mut value: Value = serde_json::from_str(&json)?;
if let Some(address) = resolved_address {
value["program"]["publicKey"] = Value::String(address.to_string());
}
let has_native_errors = value["program"]["errors"]
.as_array()
.is_some_and(|a| !a.is_empty());
let fills_errors = !has_native_errors && !fallback_errors.is_empty();
if program_is_empty(&value) {
if fills_errors {
println!("⚠️ Native Codama extraction found no instructions or accounts (does this program use Codama's derive macros?). Its {} error(s) in .codama.json come from the shank IDL.", fallback_errors.len());
} else {
println!("⚠️ Native Codama extraction found no instructions, accounts, or errors. Does this program actually use Codama's derive macros?");
}
} else if fills_errors {
println!(
"ℹ️ No `CodamaErrors` found; added {} error(s) from the shank IDL to .codama.json",
fallback_errors.len()
);
}
if has_native_errors {
backfill_messages(&mut value["program"]["errors"], fallback_errors);
if let Some(manual) = manual.filter(|m| m.changes_codes()) {
convert_discriminant_codes(&mut value["program"]["errors"], manual);
}
}
if fills_errors {
value["program"]["errors"] = Value::Array(fallback_errors.iter().map(error_node).collect());
}
Ok(serde_json::to_string_pretty(&value)?)
}
fn name_key(name: &str) -> String {
name.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase())
.collect()
}
fn backfill_messages(errors: &mut Value, fallback_errors: &[Value]) {
let Some(nodes) = errors.as_array_mut() else {
return;
};
for node in nodes {
if !node["message"].as_str().unwrap_or_default().is_empty() {
continue;
}
let key = name_key(node["name"].as_str().unwrap_or_default());
let fallback = fallback_errors
.iter()
.find(|e| name_key(e["name"].as_str().unwrap_or_default()) == key)
.and_then(|e| e["msg"].as_str());
if let Some(message) = fallback.filter(|m| !m.is_empty()) {
node["message"] = Value::from(message);
}
}
}
fn convert_discriminant_codes(errors: &mut Value, manual: &ManualErrors) {
let Some(nodes) = errors.as_array_mut() else {
return;
};
let mut converted = 0;
let mut unmatched = Vec::new();
let mut explicit = Vec::new();
for node in nodes.iter_mut() {
let name = node["name"].as_str().unwrap_or_default().to_string();
let key = name_key(&name);
let mut matches = manual
.discriminants
.iter()
.enumerate()
.filter(|(_, (variant, _))| name_key(variant) == key);
let (Some((index, (_, discriminant))), None) = (matches.next(), matches.next()) else {
unmatched.push(format!("`{name}`"));
continue;
};
let program_code = manual.errors[index].code;
let native_code = node["code"].as_u64();
if native_code == Some(u64::from(program_code)) {
continue;
}
if native_code == Some(u64::from(*discriminant)) {
node["code"] = Value::from(program_code);
converted += 1;
} else {
explicit.push(format!(
"`{name}` (Codama {}, program {program_code})",
node["code"]
));
}
}
let total = nodes.len();
if converted == total {
println!(
"ℹ️ .codama.json error codes converted the same way (Codama reads raw discriminants)"
);
} else if converted > 0 {
println!("ℹ️ .codama.json: {converted} of {total} error codes converted the same way (Codama reads raw discriminants)");
}
if !unmatched.is_empty() {
println!(
"⚠️ .codama.json: {} error code(s) left as raw discriminants, because no variant of the enum behind `impl From<_> for ProgramError` matches: {}. They will not match the codes the program returns.",
unmatched.len(),
listed(&unmatched)
);
}
if !explicit.is_empty() {
println!(
"⚠️ .codama.json: {} error(s) keep a Codama code that is neither the discriminant nor what `impl From<_> for ProgramError` returns: {}.",
explicit.len(),
listed(&explicit)
);
}
}
fn listed(names: &[String]) -> String {
const SHOWN: usize = 5;
let mut out = names
.iter()
.take(SHOWN)
.cloned()
.collect::<Vec<_>>()
.join(", ");
if names.len() > SHOWN {
out.push_str(&format!(", and {} more", names.len() - SHOWN));
}
out
}
fn error_node(error: &Value) -> Value {
let name = error["name"].as_str().unwrap_or_default();
serde_json::json!({
"kind": "errorNode",
"name": name.to_lower_camel_case(),
"code": error["code"],
"message": error["msg"].as_str().unwrap_or_default(),
})
}
fn program_is_empty(root: &Value) -> bool {
let is_empty_array = |key: &str| {
root["program"][key]
.as_array()
.map(|a| a.is_empty())
.unwrap_or(true)
};
is_empty_array("instructions") && is_empty_array("accounts") && is_empty_array("errors")
}