Skip to main content

anchor_cli/
codama.rs

1//! Codama IDL integration for the Anchor CLI.
2//!
3//! - `anchor codama convert <path>` translates a (post-0.30, v0.1.x spec)
4//!   Anchor IDL JSON file into a Codama IDL JSON tree rooted at a `rootNode`.
5//!   The conversion mirrors the reference TypeScript implementation shipped
6//!   in `@codama/nodes-from-anchor` (`v01/`), so the output should be
7//!   byte-stable against the JS toolchain modulo property ordering.
8//! - `anchor codama generate -l <langs> -p <path> <idl>` first runs the same
9//!   conversion in-process, then drives `@codama/cli` to render clients in
10//!   the requested languages (`js`, `js-umi`, `rust`, `go`).
11
12use {
13    crate::AbsolutePath,
14    anyhow::{anyhow, bail, Context, Result},
15    clap::{Parser, ValueEnum},
16    serde_json::{json, Map, Value as JsonValue},
17    std::{
18        collections::{BTreeSet, HashMap},
19        fs,
20        path::{Path, PathBuf},
21        process::{Command, Stdio},
22    },
23};
24
25/// The `@codama/nodes` package version we target. The Codama IDL is versioned
26/// independently from the Anchor IDL, and the JS converter stamps the running
27/// `@codama/nodes` version into `rootNode.version`. We pin a known good value
28/// so consumers that key off this field have a deterministic input.
29const CODAMA_VERSION: &str = "1.6.0";
30
31#[derive(Debug, Parser, AbsolutePath)]
32pub enum CodamaCommand {
33    /// Convert an Anchor IDL JSON file (post-0.30 spec) into a Codama IDL
34    /// rooted at a `rootNode`.
35    Convert {
36        /// Path to the Anchor IDL JSON file.
37        path: String,
38        /// Output file (stdout if not specified).
39        #[clap(short, long)]
40        out: Option<String>,
41    },
42    /// Convert an Anchor IDL and run Codama renderers to produce client
43    /// libraries in one or more languages.
44    ///
45    /// The IDL is converted in-process; the resulting Codama IDL is handed to
46    /// `@codama/cli` (run via `npx --yes codama` by default), which loads the
47    /// per-language renderer packages and writes generated sources under
48    /// `<path>/<language>`.
49    Generate {
50        /// Languages to generate clients for. Repeat the flag or comma-
51        /// separate values: `-l js,go -l rust`.
52        #[clap(
53            short = 'l',
54            long = "language",
55            value_delimiter = ',',
56            value_enum,
57            required = true
58        )]
59        language: Vec<Language>,
60        /// Base output directory; per-language clients are written to
61        /// `<path>/<language>`.
62        #[clap(short = 'p', long = "path", default_value = "clients")]
63        path: String,
64        /// Path to the Anchor IDL JSON file.
65        idl: String,
66    },
67}
68
69/// Languages with an officially-published `@codama/renderers-*` package.
70#[derive(Debug, Clone, Copy, ValueEnum, Eq, Ord, PartialEq, PartialOrd, AbsolutePath)]
71#[clap(rename_all = "kebab-case")]
72pub enum Language {
73    Js,
74    JsUmi,
75    Rust,
76    Go,
77}
78
79impl Language {
80    /// Stable identifier used both as the Codama script name and the
81    /// per-language output subdirectory.
82    pub fn id(self) -> &'static str {
83        match self {
84            Language::Js => "js",
85            Language::JsUmi => "js-umi",
86            Language::Rust => "rust",
87            Language::Go => "go",
88        }
89    }
90
91    /// Inverse of [`Self::id`]. Returns `None` for unknown ids so callers
92    /// can decide whether to error or silently skip.
93    pub fn from_id(id: &str) -> Option<Self> {
94        match id {
95            "js" => Some(Language::Js),
96            "js-umi" => Some(Language::JsUmi),
97            "rust" => Some(Language::Rust),
98            "go" => Some(Language::Go),
99            _ => None,
100        }
101    }
102
103    /// npm package providing the renderer's default export.
104    pub fn renderer_package(self) -> &'static str {
105        match self {
106            Language::Js => "@codama/renderers-js",
107            Language::JsUmi => "@codama/renderers-js-umi",
108            Language::Rust => "@codama/renderers-rust",
109            Language::Go => "@codama/renderers-go",
110        }
111    }
112}
113
114pub fn entry(cmd: CodamaCommand) -> Result<()> {
115    match cmd {
116        CodamaCommand::Convert { path, out } => convert(path, out),
117        CodamaCommand::Generate {
118            language,
119            path,
120            idl,
121        } => generate(idl, path, language),
122    }
123}
124
125pub fn convert(path: String, out: Option<String>) -> Result<()> {
126    let bytes = fs::read(&path).with_context(|| format!("Failed to read IDL file `{path}`"))?;
127    let idl: JsonValue = serde_json::from_slice(&bytes)
128        .with_context(|| format!("Failed to parse IDL JSON at `{path}`"))?;
129    let root = root_node_from_anchor(&idl)?;
130    let json = serde_json::to_string_pretty(&root)?;
131    match out {
132        Some(out) => fs::write(out, json)?,
133        None => println!("{json}"),
134    }
135    Ok(())
136}
137
138/// Convert the Anchor IDL at `idl_path` to a Codama IDL, then drive the
139/// Codama CLI to render clients for each requested language under
140/// `<base_path>/<language>`.
141///
142/// Implementation notes:
143/// - Conversion happens in-process via [`root_node_from_anchor`]. The
144///   converted Codama IDL is staged under `<base_path>/.codama/idl.json`
145///   alongside a generated `codama.json` config so the user can inspect or
146///   re-run the rendering manually with `codama run --all`.
147/// - The Codama CLI is invoked as `npx --yes codama run --config <cfg>
148///   --all` by default. Set `ANCHOR_CODAMA_CMD` to override the binary
149///   (e.g. when `codama` is already on `PATH`); arguments are appended as
150///   given. Codama itself installs missing renderer packages on demand.
151pub fn generate(idl_path: String, base_path: String, languages: Vec<Language>) -> Result<()> {
152    if languages.is_empty() {
153        bail!("`anchor codama generate` requires at least one --language");
154    }
155    // Dedup while keeping a deterministic order for the generated config so
156    // re-runs produce identical files.
157    let unique: BTreeSet<Language> = languages.into_iter().collect();
158    let base = PathBuf::from(&base_path);
159    let targets: Vec<(Language, PathBuf)> =
160        unique.iter().map(|l| (*l, base.join(l.id()))).collect();
161    let stage_dir = base.join(".codama");
162    render_targets(Path::new(&idl_path), &stage_dir, &targets)
163}
164
165/// Convenience entry point for the `anchor build` integration: reads the
166/// `[clients]` section of `Anchor.toml`, expands it into resolved
167/// `(Language, output_path)` targets, and runs Codama for each IDL produced
168/// by the build.
169///
170/// `workspace_dir` is the root of the workspace (the directory containing
171/// `Anchor.toml`); IDL files are expected at `<workspace_dir>/target/idl/*.json`
172/// (the standard `anchor build` output). When the workspace ships more than
173/// one program the configured per-language path is treated as a *base*
174/// directory and clients land at `<base>/<program>` to avoid clobbering.
175pub fn auto_generate_for_workspace(
176    clients_cfg: &crate::config::ClientsConfig,
177    workspace_dir: &Path,
178    idl_paths: &[PathBuf],
179) -> Result<()> {
180    if !clients_cfg.auto {
181        return Ok(());
182    }
183    let base = workspace_dir.join("clients");
184    let entries = clients_cfg.enabled(&base);
185    if entries.is_empty() {
186        eprintln!(
187            "warning: `[clients] auto = true` but no language is enabled — nothing to generate.",
188        );
189        return Ok(());
190    }
191    if idl_paths.is_empty() {
192        eprintln!(
193            "warning: `[clients] auto = true` but no IDL files were produced by the build — \
194             nothing to generate.",
195        );
196        return Ok(());
197    }
198
199    let multi_program = idl_paths.len() > 1;
200    let codama_stage_root = workspace_dir.join("target").join("codama");
201    for idl_path in idl_paths {
202        let stem = idl_path
203            .file_stem()
204            .and_then(|s| s.to_str())
205            .ok_or_else(|| anyhow!("Invalid IDL filename: {}", idl_path.display()))?;
206        let targets: Vec<(Language, PathBuf)> = entries
207            .iter()
208            .filter_map(|(id, path)| {
209                let lang = Language::from_id(id)?;
210                let out = if multi_program {
211                    path.join(stem)
212                } else {
213                    path.clone()
214                };
215                Some((lang, out))
216            })
217            .collect();
218        if targets.is_empty() {
219            continue;
220        }
221        let stage_dir = codama_stage_root.join(stem);
222        render_targets(idl_path, &stage_dir, &targets)?;
223    }
224    Ok(())
225}
226
227/// Shared rendering backend used by both the `anchor codama generate`
228/// subcommand and the `anchor build` auto-generation hook.
229///
230/// Steps:
231/// 1. Convert the Anchor IDL at `idl_path` to a Codama IDL JSON tree.
232/// 2. Stage the converted IDL + a generated `codama.json` config under
233///    `stage_dir/`. Keeping the stage on disk (rather than in `$TMPDIR`)
234///    means failures leave a debuggable artifact and `codama run --all`
235///    can be re-invoked manually.
236/// 3. Spawn the Codama CLI (`npx --yes codama` by default; see
237///    [`run_codama`] for the override knob) which loads each renderer
238///    package and writes generated sources into the per-language paths.
239///
240/// All paths in the generated config are absolute: Codama forwards visitor
241/// args to the renderer verbatim and the renderer's `node:fs` calls resolve
242/// relative paths against the *runtime* cwd, which would otherwise depend
243/// on where the user invoked `anchor` from.
244fn render_targets(
245    idl_path: &Path,
246    stage_dir: &Path,
247    targets: &[(Language, PathBuf)],
248) -> Result<()> {
249    if targets.is_empty() {
250        return Ok(());
251    }
252
253    let bytes = fs::read(idl_path)
254        .with_context(|| format!("Failed to read IDL file `{}`", idl_path.display()))?;
255    let idl: JsonValue = serde_json::from_slice(&bytes)
256        .with_context(|| format!("Failed to parse IDL JSON at `{}`", idl_path.display()))?;
257    let root = root_node_from_anchor(&idl)?;
258
259    fs::create_dir_all(stage_dir).with_context(|| {
260        format!(
261            "Failed to create staging directory `{}`",
262            stage_dir.display()
263        )
264    })?;
265    let staged_idl = stage_dir.join("idl.json");
266    fs::write(&staged_idl, serde_json::to_string_pretty(&root)?)
267        .with_context(|| format!("Failed to write `{}`", staged_idl.display()))?;
268
269    // Build per-target output dirs eagerly so `canonicalize` succeeds; the
270    // renderers themselves will (re)create + clean them, but they must exist
271    // for path resolution.
272    for (_lang, out) in targets {
273        fs::create_dir_all(out)
274            .with_context(|| format!("Failed to create output directory `{}`", out.display()))?;
275    }
276
277    let abs_idl = staged_idl
278        .canonicalize()
279        .with_context(|| format!("Failed to resolve `{}`", staged_idl.display()))?;
280    let mut scripts = Map::new();
281    for (lang, out) in targets {
282        let abs_out = out
283            .canonicalize()
284            .with_context(|| format!("Failed to resolve `{}`", out.display()))?;
285        scripts.insert(
286            lang.id().to_string(),
287            json!({
288                "from": lang.renderer_package(),
289                "args": [abs_out.to_string_lossy()],
290            }),
291        );
292    }
293    let config = json!({
294        "idl": abs_idl.to_string_lossy(),
295        "scripts": scripts,
296    });
297    let config_path = stage_dir.join("codama.json");
298    fs::write(&config_path, serde_json::to_string_pretty(&config)?)
299        .with_context(|| format!("Failed to write `{}`", config_path.display()))?;
300
301    let labels: Vec<&str> = targets.iter().map(|(l, _)| l.id()).collect();
302    eprintln!(
303        "Generating Codama clients [{}] for `{}` ...",
304        labels.join(", "),
305        idl_path.display(),
306    );
307    run_codama(&config_path)?;
308    Ok(())
309}
310
311fn run_codama(config_path: &Path) -> Result<()> {
312    let (program, leading_args) = match std::env::var("ANCHOR_CODAMA_CMD") {
313        Ok(s) if !s.trim().is_empty() => {
314            // Allow the override to bake in flags (e.g. `pnpm codama`).
315            let mut parts = s.split_whitespace().map(str::to_owned);
316            let program = parts.next().expect("non-empty after trim");
317            (program, parts.collect::<Vec<_>>())
318        }
319        _ => (
320            "npx".to_string(),
321            vec!["--yes".to_string(), "codama".to_string()],
322        ),
323    };
324
325    let mut cmd = Command::new(&program);
326    for arg in &leading_args {
327        cmd.arg(arg);
328    }
329    cmd.arg("run")
330        .arg("--config")
331        .arg(config_path.as_os_str())
332        .arg("--all")
333        .stdin(Stdio::inherit())
334        .stdout(Stdio::inherit())
335        .stderr(Stdio::inherit());
336
337    let status = cmd.status().map_err(|e| {
338        anyhow!(
339            "Failed to spawn `{}`: {e}. Install Node.js + npm, or set ANCHOR_CODAMA_CMD to point \
340             at your Codama binary.",
341            display_command(&program, &leading_args),
342        )
343    })?;
344    if !status.success() {
345        bail!(
346            "`{} run --config {} --all` failed with {status}",
347            display_command(&program, &leading_args),
348            config_path.display(),
349        );
350    }
351    Ok(())
352}
353
354fn display_command(program: &str, args: &[String]) -> String {
355    if args.is_empty() {
356        program.to_string()
357    } else {
358        format!("{program} {}", args.join(" "))
359    }
360}
361
362// ---------------------------------------------------------------------------
363// Top-level node builders.
364// ---------------------------------------------------------------------------
365
366fn root_node_from_anchor(idl: &JsonValue) -> Result<JsonValue> {
367    let program = program_node_from_anchor(idl)?;
368    Ok(json!({
369        "kind": "rootNode",
370        "standard": "codama",
371        "version": CODAMA_VERSION,
372        "program": program,
373        "additionalPrograms": [],
374    }))
375}
376
377fn program_node_from_anchor(idl: &JsonValue) -> Result<JsonValue> {
378    let metadata = idl
379        .get("metadata")
380        .and_then(JsonValue::as_object)
381        .ok_or_else(|| anyhow!("IDL is missing `metadata`"))?;
382    let name = metadata
383        .get("name")
384        .and_then(JsonValue::as_str)
385        .ok_or_else(|| anyhow!("IDL is missing `metadata.name`"))?;
386    let version = metadata
387        .get("version")
388        .and_then(JsonValue::as_str)
389        .unwrap_or("0.0.0");
390    let public_key = idl
391        .get("address")
392        .and_then(JsonValue::as_str)
393        .ok_or_else(|| anyhow!("IDL is missing `address`"))?;
394
395    let types = idl
396        .get("types")
397        .and_then(JsonValue::as_array)
398        .cloned()
399        .unwrap_or_default();
400    let accounts = idl
401        .get("accounts")
402        .and_then(JsonValue::as_array)
403        .cloned()
404        .unwrap_or_default();
405    let events = idl
406        .get("events")
407        .and_then(JsonValue::as_array)
408        .cloned()
409        .unwrap_or_default();
410    let instructions = idl
411        .get("instructions")
412        .and_then(JsonValue::as_array)
413        .cloned()
414        .unwrap_or_default();
415    let errors = idl
416        .get("errors")
417        .and_then(JsonValue::as_array)
418        .cloned()
419        .unwrap_or_default();
420
421    let (non_generic_types, generics) = extract_generics(&types);
422
423    // Anchor stuffs account- and event-backing structs into `types`. Codama
424    // promotes them to first-class `accountNode`/`eventNode`s instead, so we
425    // must filter the duplicates out before exporting `definedTypes`.
426    let account_names: Vec<&str> = accounts.iter().filter_map(named).collect();
427    let event_names: Vec<&str> = events.iter().filter_map(named).collect();
428    let mut defined_types = Vec::new();
429    for ty in &non_generic_types {
430        let n = match named(ty) {
431            Some(n) => n,
432            None => continue,
433        };
434        if account_names.contains(&n) || event_names.contains(&n) {
435            continue;
436        }
437        defined_types.push(defined_type_node_from_anchor(ty, &generics)?);
438    }
439
440    let account_nodes: Vec<JsonValue> = accounts
441        .iter()
442        .map(|a| account_node_from_anchor(a, &types, &generics))
443        .collect::<Result<_>>()?;
444    let event_nodes: Vec<JsonValue> = events
445        .iter()
446        .map(|e| event_node_from_anchor(e, &types, &generics))
447        .collect::<Result<_>>()?;
448    let instruction_nodes: Vec<JsonValue> = instructions
449        .iter()
450        .map(|i| instruction_node_from_anchor(i, &generics))
451        .collect::<Result<_>>()?;
452    let error_nodes: Vec<JsonValue> = errors.iter().map(error_node_from_anchor).collect();
453
454    Ok(json!({
455        "kind": "programNode",
456        "name": camel_case(name),
457        "publicKey": public_key,
458        "version": version,
459        "origin": "anchor",
460        "docs": [],
461        "accounts": account_nodes,
462        "instructions": instruction_nodes,
463        "definedTypes": defined_types,
464        "pdas": [],
465        "events": event_nodes,
466        "errors": error_nodes,
467    }))
468}
469
470fn named(v: &JsonValue) -> Option<&str> {
471    v.get("name").and_then(JsonValue::as_str)
472}
473
474// ---------------------------------------------------------------------------
475// Generics handling — Anchor's `types` may declare generic parameters that we
476// must substitute when a `defined { name, generics }` reference is reached.
477// ---------------------------------------------------------------------------
478
479#[derive(Debug, Clone, Default)]
480struct Generics {
481    /// Generic type-defs keyed by name (only those that declare `generics`).
482    types: HashMap<String, JsonValue>,
483    /// Type-arg substitutions in the current scope, *pre-resolved* in the
484    /// outer scope at substitution time. Mapping: name → Codama type node.
485    type_args: HashMap<String, JsonValue>,
486    /// Const-arg substitutions in the current scope, pre-resolved to a value
487    /// string at substitution time. Mapping: name → numeric literal.
488    const_args: HashMap<String, String>,
489}
490
491fn extract_generics(types: &[JsonValue]) -> (Vec<JsonValue>, Generics) {
492    let mut non_generic = Vec::new();
493    let mut generic_types = HashMap::new();
494    for t in types {
495        let has_generics = t
496            .get("generics")
497            .and_then(JsonValue::as_array)
498            .is_some_and(|a| !a.is_empty());
499        if has_generics {
500            if let Some(n) = named(t) {
501                generic_types.insert(n.to_string(), t.clone());
502            }
503        } else {
504            non_generic.push(t.clone());
505        }
506    }
507    (
508        non_generic,
509        Generics {
510            types: generic_types,
511            type_args: HashMap::new(),
512            const_args: HashMap::new(),
513        },
514    )
515}
516
517fn unwrap_generic_type(defined: &JsonValue, generics: &Generics) -> Result<JsonValue> {
518    let inner = defined
519        .get("defined")
520        .and_then(JsonValue::as_object)
521        .ok_or_else(|| anyhow!("Expected `defined` object"))?;
522    let name = inner
523        .get("name")
524        .and_then(JsonValue::as_str)
525        .ok_or_else(|| anyhow!("`defined` missing `name`"))?;
526    let generic_type = generics
527        .types
528        .get(name)
529        .ok_or_else(|| anyhow!("Generic type `{name}` not found"))?
530        .clone();
531    let generic_definitions = generic_type
532        .get("generics")
533        .and_then(JsonValue::as_array)
534        .cloned()
535        .unwrap_or_default();
536    let generic_args = inner
537        .get("generics")
538        .and_then(JsonValue::as_array)
539        .cloned()
540        .unwrap_or_default();
541
542    // Build a *fresh* scope, pre-resolving every arg in the OUTER scope. This
543    // breaks the self-shadowing recursion that would otherwise occur whenever
544    // a callee re-uses one of its caller's parameter names — e.g. passing
545    // `T` from `Outer<T>` into `Inner<T, U>`. If we kept the args as raw
546    // Anchor IDL nodes we'd resolve them lazily *in* the inner scope, where
547    // `T → {generic: T}` loops forever.
548    let mut type_args: HashMap<String, JsonValue> = HashMap::new();
549    let mut const_args: HashMap<String, String> = HashMap::new();
550    for (i, def) in generic_definitions.iter().enumerate() {
551        let def_name = def
552            .get("name")
553            .and_then(JsonValue::as_str)
554            .ok_or_else(|| anyhow!("Generic definition missing `name`"))?
555            .to_string();
556        let def_kind = def
557            .get("kind")
558            .and_then(JsonValue::as_str)
559            .unwrap_or("type");
560        let arg = generic_args
561            .get(i)
562            .ok_or_else(|| anyhow!("Missing generic argument for `{def_name}`"))?;
563        if def_kind == "const" {
564            // Common case: `{kind:"const", value:"<literal>"}`.
565            if let Some(v) = arg.get("value").and_then(JsonValue::as_str) {
566                const_args.insert(def_name, v.to_string());
567            } else {
568                // Anchor sometimes forwards an outer const generic by emitting
569                // `{kind:"type", type:{generic:"N"}}` even when the callee
570                // declares the parameter as `const` — the IDL doesn't model
571                // const-generic forwarding cleanly. Resolve via the outer
572                // scope's `const_args` instead.
573                let outer_name = arg
574                    .get("type")
575                    .and_then(|t| t.get("generic"))
576                    .and_then(JsonValue::as_str)
577                    .ok_or_else(|| anyhow!("Const generic arg `{def_name}` missing `value`"))?;
578                let v = generics.const_args.get(outer_name).ok_or_else(|| {
579                    anyhow!(
580                        "Const generic arg `{def_name}` forwards unknown outer const \
581                         `{outer_name}`"
582                    )
583                })?;
584                const_args.insert(def_name, v.clone());
585            }
586        } else {
587            let arg_type = arg
588                .get("type")
589                .ok_or_else(|| anyhow!("Type generic arg `{def_name}` missing `type`"))?;
590            let resolved = type_node_from_anchor(arg_type, generics)?;
591            type_args.insert(def_name, resolved);
592        }
593    }
594
595    let scoped = Generics {
596        types: generics.types.clone(),
597        type_args,
598        const_args,
599    };
600    let inner_ty = generic_type
601        .get("type")
602        .ok_or_else(|| anyhow!("Generic typedef `{name}` missing `type`"))?;
603    type_node_from_anchor(inner_ty, &scoped)
604}
605
606// ---------------------------------------------------------------------------
607// Type nodes — recursively translate Anchor IDL type expressions.
608// ---------------------------------------------------------------------------
609
610const NUMBER_LEAVES: &[&str] = &[
611    "u8", "u16", "u32", "u64", "u128", "i8", "i16", "i32", "i64", "i128", "f32", "f64", "shortU16",
612];
613
614fn type_node_from_anchor(ty: &JsonValue, generics: &Generics) -> Result<JsonValue> {
615    // Leaf primitives are encoded as JSON strings.
616    if let Some(leaf) = ty.as_str() {
617        return Ok(match leaf {
618            "bool" => json!({ "kind": "booleanTypeNode", "size": number_node("u8") }),
619            "pubkey" => json!({ "kind": "publicKeyTypeNode" }),
620            "string" => size_prefix_node(string_node("utf8"), number_node("u32")),
621            "bytes" => size_prefix_node(bytes_node(), number_node("u32")),
622            n if NUMBER_LEAVES.contains(&n) => number_node(n),
623            other => bail!("Unrecognized Anchor IDL leaf type: `{other}`"),
624        });
625    }
626    let obj = ty
627        .as_object()
628        .ok_or_else(|| anyhow!("Unrecognized Anchor IDL type: {ty}"))?;
629
630    if obj.contains_key("array") {
631        let arr = obj["array"]
632            .as_array()
633            .ok_or_else(|| anyhow!("`array` must be a 2-tuple"))?;
634        if arr.len() != 2 {
635            bail!("`array` must be a 2-tuple, got {} elements", arr.len());
636        }
637        let item = type_node_from_anchor(&arr[0], generics)?;
638        let size = match &arr[1] {
639            JsonValue::Number(n) => n
640                .as_u64()
641                .ok_or_else(|| anyhow!("Array length must be a non-negative integer"))?,
642            JsonValue::Object(o) if o.contains_key("generic") => {
643                let gname = o["generic"]
644                    .as_str()
645                    .ok_or_else(|| anyhow!("`generic` must be a string"))?;
646                let v = generics
647                    .const_args
648                    .get(gname)
649                    .ok_or_else(|| anyhow!("Const generic `{gname}` not found"))?;
650                v.parse::<u64>()
651                    .with_context(|| format!("Const generic `{gname}` value `{v}` is not u64"))?
652            }
653            other => bail!("Unrecognized array length: {other}"),
654        };
655        return Ok(json!({
656            "kind": "arrayTypeNode",
657            "item": item,
658            "count": { "kind": "fixedCountNode", "value": size },
659        }));
660    }
661
662    if let Some(inner) = obj.get("vec") {
663        let item = type_node_from_anchor(inner, generics)?;
664        return Ok(json!({
665            "kind": "arrayTypeNode",
666            "item": item,
667            "count": { "kind": "prefixedCountNode", "prefix": number_node("u32") },
668        }));
669    }
670
671    if let Some(defined) = obj.get("defined") {
672        // The post-0.30 spec uses an object form `{name, generics?}`. We don't
673        // accept the legacy bare-string form here because `anchor idl convert`
674        // already normalizes it.
675        let def_obj = defined
676            .as_object()
677            .ok_or_else(|| anyhow!("`defined` must be an object"))?;
678        let has_generics = def_obj
679            .get("generics")
680            .and_then(JsonValue::as_array)
681            .is_some_and(|a| !a.is_empty());
682        if has_generics {
683            return unwrap_generic_type(ty, generics);
684        }
685        let name = def_obj
686            .get("name")
687            .and_then(JsonValue::as_str)
688            .ok_or_else(|| anyhow!("`defined` missing `name`"))?;
689        return Ok(json!({
690            "kind": "definedTypeLinkNode",
691            "name": camel_case(name),
692        }));
693    }
694
695    if let Some(generic) = obj.get("generic").and_then(JsonValue::as_str) {
696        // Already resolved at substitution time — see `unwrap_generic_type`.
697        let resolved = generics
698            .type_args
699            .get(generic)
700            .ok_or_else(|| anyhow!("Type generic `{generic}` not found"))?;
701        return Ok(resolved.clone());
702    }
703
704    if let Some(inner) = obj.get("option") {
705        let item = type_node_from_anchor(inner, generics)?;
706        return Ok(json!({
707            "kind": "optionTypeNode",
708            "fixed": false,
709            "item": item,
710            "prefix": number_node("u8"),
711        }));
712    }
713
714    if let Some(inner) = obj.get("coption") {
715        let item = type_node_from_anchor(inner, generics)?;
716        return Ok(json!({
717            "kind": "optionTypeNode",
718            "fixed": true,
719            "item": item,
720            "prefix": number_node("u32"),
721        }));
722    }
723
724    let kind = obj.get("kind").and_then(JsonValue::as_str);
725    if matches!(kind, Some("enum")) {
726        let variants = obj
727            .get("variants")
728            .and_then(JsonValue::as_array)
729            .cloned()
730            .unwrap_or_default();
731        let variant_nodes: Vec<JsonValue> = variants
732            .iter()
733            .map(|v| enum_variant_from_anchor(v, generics))
734            .collect::<Result<_>>()?;
735        return Ok(json!({
736            "kind": "enumTypeNode",
737            "variants": variant_nodes,
738            "size": number_node("u8"),
739        }));
740    }
741
742    // Anchor's type aliases serialize as `{kind: "type", alias: T}` (per the
743    // current `anchor-lang-idl-spec`). The TypeScript Codama converter checks
744    // for `{kind: "alias", value: T}` instead, so we accept both forms.
745    if matches!(kind, Some("type")) {
746        if let Some(alias) = obj.get("alias") {
747            return type_node_from_anchor(alias, generics);
748        }
749    }
750    if matches!(kind, Some("alias")) {
751        if let Some(value) = obj.get("value") {
752            return type_node_from_anchor(value, generics);
753        }
754    }
755
756    if matches!(kind, Some("struct")) {
757        let fields = obj
758            .get("fields")
759            .and_then(JsonValue::as_array)
760            .cloned()
761            .unwrap_or_default();
762        return struct_or_tuple_from_fields(&fields, generics);
763    }
764
765    bail!("Unrecognized Anchor IDL type: {ty}")
766}
767
768fn struct_or_tuple_from_fields(fields: &[JsonValue], generics: &Generics) -> Result<JsonValue> {
769    if fields.is_empty() || is_struct_field_array(fields) {
770        let nodes: Vec<JsonValue> = fields
771            .iter()
772            .map(|f| struct_field_from_anchor(f, generics))
773            .collect::<Result<_>>()?;
774        return Ok(json!({ "kind": "structTypeNode", "fields": nodes }));
775    }
776    if is_tuple_field_array(fields) {
777        let items: Vec<JsonValue> = fields
778            .iter()
779            .map(|f| type_node_from_anchor(f, generics))
780            .collect::<Result<_>>()?;
781        return Ok(json!({ "kind": "tupleTypeNode", "items": items }));
782    }
783    bail!("Mixed named/positional fields in struct: {:?}", fields)
784}
785
786fn is_struct_field(field: &JsonValue) -> bool {
787    field
788        .as_object()
789        .is_some_and(|o| o.contains_key("name") && o.contains_key("type"))
790}
791
792fn is_struct_field_array(fields: &[JsonValue]) -> bool {
793    fields.iter().all(is_struct_field)
794}
795
796fn is_tuple_field_array(fields: &[JsonValue]) -> bool {
797    fields.iter().all(|f| !is_struct_field(f))
798}
799
800fn struct_field_from_anchor(field: &JsonValue, generics: &Generics) -> Result<JsonValue> {
801    let obj = field
802        .as_object()
803        .ok_or_else(|| anyhow!("Struct field must be an object: {field}"))?;
804    let name = obj
805        .get("name")
806        .and_then(JsonValue::as_str)
807        .ok_or_else(|| anyhow!("Struct field missing `name`"))?;
808    let ty = obj
809        .get("type")
810        .ok_or_else(|| anyhow!("Struct field `{name}` missing `type`"))?;
811    Ok(json!({
812        "kind": "structFieldTypeNode",
813        "name": camel_case(name),
814        "docs": docs(obj.get("docs")),
815        "type": type_node_from_anchor(ty, generics)?,
816    }))
817}
818
819fn enum_variant_from_anchor(variant: &JsonValue, generics: &Generics) -> Result<JsonValue> {
820    let obj = variant
821        .as_object()
822        .ok_or_else(|| anyhow!("Enum variant must be an object: {variant}"))?;
823    let name = obj.get("name").and_then(JsonValue::as_str).unwrap_or("");
824    let fields = obj.get("fields").and_then(JsonValue::as_array);
825    match fields {
826        None => Ok(json!({
827            "kind": "enumEmptyVariantTypeNode",
828            "name": camel_case(name),
829        })),
830        Some(fs) if fs.is_empty() => Ok(json!({
831            "kind": "enumEmptyVariantTypeNode",
832            "name": camel_case(name),
833        })),
834        Some(fs) if is_struct_field_array(fs) => {
835            let nodes: Vec<JsonValue> = fs
836                .iter()
837                .map(|f| struct_field_from_anchor(f, generics))
838                .collect::<Result<_>>()?;
839            Ok(json!({
840                "kind": "enumStructVariantTypeNode",
841                "name": camel_case(name),
842                "struct": { "kind": "structTypeNode", "fields": nodes },
843            }))
844        }
845        Some(fs) => {
846            let items: Vec<JsonValue> = fs
847                .iter()
848                .map(|f| type_node_from_anchor(f, generics))
849                .collect::<Result<_>>()?;
850            Ok(json!({
851                "kind": "enumTupleVariantTypeNode",
852                "name": camel_case(name),
853                "tuple": { "kind": "tupleTypeNode", "items": items },
854            }))
855        }
856    }
857}
858
859// ---------------------------------------------------------------------------
860// Account/event/error/defined-type nodes.
861// ---------------------------------------------------------------------------
862
863fn defined_type_node_from_anchor(ty: &JsonValue, generics: &Generics) -> Result<JsonValue> {
864    let name = named(ty).unwrap_or("");
865    let inner = ty
866        .get("type")
867        .cloned()
868        .unwrap_or_else(|| json!({ "kind": "struct", "fields": [] }));
869    let node = type_node_from_anchor(&inner, generics)?;
870    Ok(json!({
871        "kind": "definedTypeNode",
872        "name": camel_case(name),
873        "docs": docs(ty.get("docs")),
874        "type": node,
875    }))
876}
877
878fn account_node_from_anchor(
879    acc: &JsonValue,
880    types: &[JsonValue],
881    generics: &Generics,
882) -> Result<JsonValue> {
883    let name = named(acc).ok_or_else(|| anyhow!("Account missing `name`"))?;
884    let ty_def = types
885        .iter()
886        .find(|t| named(t) == Some(name))
887        .ok_or_else(|| anyhow!("Account type `{name}` not found in `types`"))?;
888    let inner = ty_def
889        .get("type")
890        .ok_or_else(|| anyhow!("Account type `{name}` missing `type`"))?;
891    let data = type_node_from_anchor(inner, generics)?;
892    let data_obj = data
893        .as_object()
894        .filter(|o| o.get("kind").and_then(JsonValue::as_str) == Some("structTypeNode"))
895        .ok_or_else(|| anyhow!("Account `{name}` data must be a struct"))?;
896    let mut fields = data_obj
897        .get("fields")
898        .and_then(JsonValue::as_array)
899        .cloned()
900        .unwrap_or_default();
901    let disc = discriminator_bytes(acc)?;
902    let discriminator_field = json!({
903        "kind": "structFieldTypeNode",
904        "name": "discriminator",
905        "docs": [],
906        "type": {
907            "kind": "fixedSizeTypeNode",
908            "size": disc.len(),
909            "type": bytes_node(),
910        },
911        "defaultValue": discriminator_value(&disc),
912        "defaultValueStrategy": "omitted",
913    });
914    fields.insert(0, discriminator_field);
915    Ok(json!({
916        "kind": "accountNode",
917        "name": camel_case(name),
918        "docs": [],
919        "data": { "kind": "structTypeNode", "fields": fields },
920        "discriminators": [{ "kind": "fieldDiscriminatorNode", "name": "discriminator", "offset": 0 }],
921    }))
922}
923
924fn event_node_from_anchor(
925    ev: &JsonValue,
926    types: &[JsonValue],
927    generics: &Generics,
928) -> Result<JsonValue> {
929    let name = named(ev).ok_or_else(|| anyhow!("Event missing `name`"))?;
930    let ty_def = types
931        .iter()
932        .find(|t| named(t) == Some(name))
933        .ok_or_else(|| anyhow!("Event type `{name}` not found in `types`"))?;
934    let inner = ty_def
935        .get("type")
936        .ok_or_else(|| anyhow!("Event type `{name}` missing `type`"))?;
937    let data = type_node_from_anchor(inner, generics)?;
938    let disc = discriminator_bytes(ev)?;
939    let constant = json!({
940        "kind": "constantValueNode",
941        "type": { "kind": "fixedSizeTypeNode", "size": disc.len(), "type": bytes_node() },
942        "value": discriminator_value(&disc),
943    });
944    Ok(json!({
945        "kind": "eventNode",
946        "name": camel_case(name),
947        "docs": [],
948        "data": {
949            "kind": "hiddenPrefixTypeNode",
950            "type": data,
951            "prefix": [constant.clone()],
952        },
953        "discriminators": [{
954            "kind": "constantDiscriminatorNode",
955            "offset": 0,
956            "constant": constant,
957        }],
958    }))
959}
960
961fn error_node_from_anchor(err: &JsonValue) -> JsonValue {
962    let name = named(err).unwrap_or("");
963    let msg = err
964        .get("msg")
965        .and_then(JsonValue::as_str)
966        .unwrap_or("")
967        .to_string();
968    let code = err.get("code").and_then(JsonValue::as_i64).unwrap_or(-1);
969    json!({
970        "kind": "errorNode",
971        "name": camel_case(name),
972        "code": code,
973        "message": msg,
974        "docs": [format!("{name}: {msg}")],
975    })
976}
977
978// ---------------------------------------------------------------------------
979// Instruction node + accounts/arguments/PDA seeds.
980// ---------------------------------------------------------------------------
981
982fn instruction_node_from_anchor(ix: &JsonValue, generics: &Generics) -> Result<JsonValue> {
983    let name = named(ix).ok_or_else(|| anyhow!("Instruction missing `name`"))?;
984    let args = ix
985        .get("args")
986        .and_then(JsonValue::as_array)
987        .cloned()
988        .unwrap_or_default();
989    let mut data_arguments: Vec<JsonValue> = args
990        .iter()
991        .map(|a| instruction_argument_from_anchor(a, generics))
992        .collect::<Result<_>>()?;
993    let disc = discriminator_bytes(ix)?;
994    let discriminator_arg = json!({
995        "kind": "instructionArgumentNode",
996        "name": "discriminator",
997        "docs": [],
998        "type": {
999            "kind": "fixedSizeTypeNode",
1000            "size": disc.len(),
1001            "type": bytes_node(),
1002        },
1003        "defaultValue": discriminator_value(&disc),
1004        "defaultValueStrategy": "omitted",
1005    });
1006    data_arguments.insert(0, discriminator_arg);
1007
1008    let raw_accounts = ix
1009        .get("accounts")
1010        .and_then(JsonValue::as_array)
1011        .cloned()
1012        .unwrap_or_default();
1013    let accounts =
1014        instruction_account_nodes_from_anchor(&raw_accounts, &data_arguments, None, false)?;
1015
1016    Ok(json!({
1017        "kind": "instructionNode",
1018        "name": camel_case(name),
1019        "docs": ix.get("docs").cloned().unwrap_or_else(|| json!([])),
1020        "optionalAccountStrategy": "programId",
1021        "accounts": accounts,
1022        "arguments": data_arguments,
1023        "discriminators": [{ "kind": "fieldDiscriminatorNode", "name": "discriminator", "offset": 0 }],
1024    }))
1025}
1026
1027fn instruction_argument_from_anchor(arg: &JsonValue, generics: &Generics) -> Result<JsonValue> {
1028    let obj = arg
1029        .as_object()
1030        .ok_or_else(|| anyhow!("Instruction argument must be an object: {arg}"))?;
1031    let name = obj
1032        .get("name")
1033        .and_then(JsonValue::as_str)
1034        .ok_or_else(|| anyhow!("Instruction argument missing `name`"))?;
1035    let ty = obj
1036        .get("type")
1037        .ok_or_else(|| anyhow!("Instruction argument `{name}` missing `type`"))?;
1038    Ok(json!({
1039        "kind": "instructionArgumentNode",
1040        "name": camel_case(name),
1041        "docs": docs(obj.get("docs")),
1042        "type": type_node_from_anchor(ty, generics)?,
1043    }))
1044}
1045
1046/// Collect every leaf account name in the (possibly nested) account tree,
1047/// camelCased, to detect collisions that force prefixing.
1048fn collect_camel_names(items: &[JsonValue], out: &mut Vec<String>) {
1049    for item in items {
1050        let Some(obj) = item.as_object() else {
1051            continue;
1052        };
1053        if let Some(nested) = obj.get("accounts").and_then(JsonValue::as_array) {
1054            collect_camel_names(nested, out);
1055        } else if let Some(n) = obj.get("name").and_then(JsonValue::as_str) {
1056            out.push(camel_case(n));
1057        }
1058    }
1059}
1060
1061fn has_duplicate_account_names(items: &[JsonValue]) -> bool {
1062    let mut names = Vec::new();
1063    collect_camel_names(items, &mut names);
1064    let mut seen = std::collections::HashSet::new();
1065    !names.into_iter().all(|n| seen.insert(n))
1066}
1067
1068fn instruction_account_nodes_from_anchor(
1069    items: &[JsonValue],
1070    instruction_arguments: &[JsonValue],
1071    prefix: Option<&str>,
1072    // True when an ancestor required prefixing — propagates into nested groups.
1073    forced: bool,
1074) -> Result<Vec<JsonValue>> {
1075    let should_prefix = forced || prefix.is_some() || has_duplicate_account_names(items);
1076    let mut out = Vec::new();
1077    for item in items {
1078        let obj = match item.as_object() {
1079            Some(o) => o,
1080            None => continue,
1081        };
1082        if let Some(nested) = obj.get("accounts").and_then(JsonValue::as_array) {
1083            let group_name = obj.get("name").and_then(JsonValue::as_str).unwrap_or("");
1084            let new_prefix = if should_prefix {
1085                Some(match prefix {
1086                    Some(p) => format!("{p}_{group_name}"),
1087                    None => group_name.to_string(),
1088                })
1089            } else {
1090                None
1091            };
1092            // Once we've decided to prefix at this level, the recursion must
1093            // also prefix even if its own siblings aren't ambiguous on their
1094            // own — otherwise the `prefix` we pass would silently be dropped.
1095            let nested_nodes = instruction_account_nodes_from_anchor(
1096                nested,
1097                instruction_arguments,
1098                new_prefix.as_deref(),
1099                should_prefix,
1100            )?;
1101            out.extend(nested_nodes);
1102        } else {
1103            out.push(instruction_account_node_from_anchor(
1104                item,
1105                instruction_arguments,
1106                if should_prefix { prefix } else { None },
1107            )?);
1108        }
1109    }
1110    Ok(out)
1111}
1112
1113fn instruction_account_node_from_anchor(
1114    item: &JsonValue,
1115    instruction_arguments: &[JsonValue],
1116    prefix: Option<&str>,
1117) -> Result<JsonValue> {
1118    let obj = item
1119        .as_object()
1120        .ok_or_else(|| anyhow!("Account item must be an object: {item}"))?;
1121    let raw_name = obj.get("name").and_then(JsonValue::as_str).unwrap_or("");
1122    let name = match prefix {
1123        Some(p) => format!("{p}_{raw_name}"),
1124        None => raw_name.to_string(),
1125    };
1126    let camel_name = camel_case(&name);
1127    let is_writable = obj
1128        .get("writable")
1129        .and_then(JsonValue::as_bool)
1130        .unwrap_or(false);
1131    let is_signer = obj
1132        .get("signer")
1133        .and_then(JsonValue::as_bool)
1134        .unwrap_or(false);
1135    let is_optional = obj
1136        .get("optional")
1137        .and_then(JsonValue::as_bool)
1138        .unwrap_or(false);
1139    let docs_v = docs(obj.get("docs"));
1140
1141    let mut node = Map::new();
1142    node.insert("kind".into(), json!("instructionAccountNode"));
1143    node.insert("name".into(), json!(camel_name.clone()));
1144    node.insert("isWritable".into(), json!(is_writable));
1145    node.insert("isSigner".into(), json!(is_signer));
1146    node.insert("isOptional".into(), json!(is_optional));
1147    node.insert("docs".into(), docs_v);
1148
1149    if let Some(addr) = obj.get("address").and_then(JsonValue::as_str) {
1150        node.insert(
1151            "defaultValue".into(),
1152            json!({
1153                "kind": "publicKeyValueNode",
1154                "publicKey": addr,
1155                "identifier": camel_name,
1156            }),
1157        );
1158    } else if let Some(pda) = obj.get("pda").and_then(JsonValue::as_object) {
1159        let seeds = pda
1160            .get("seeds")
1161            .and_then(JsonValue::as_array)
1162            .cloned()
1163            .unwrap_or_default();
1164        // Match the Codama TS converter: skip PDA defaults entirely whenever
1165        // any seed references a nested path (`some.nested.field`). Codama
1166        // doesn't model nested-path lookups today and silently drops the PDA.
1167        let nested_path = seeds.iter().any(|s| {
1168            s.get("path")
1169                .and_then(JsonValue::as_str)
1170                .is_some_and(|p| p.contains('.'))
1171        });
1172        if !nested_path {
1173            let mut definitions = Vec::new();
1174            let mut values = Vec::new();
1175            for seed in &seeds {
1176                let (def, val) = pda_seed_node_from_anchor(seed, instruction_arguments, prefix)?;
1177                definitions.push(def);
1178                if let Some(v) = val {
1179                    values.push(v);
1180                }
1181            }
1182            // Resolve `pda.program` if present. A constant base58 program
1183            // address surfaces as `programId` on the pda link; an account/arg
1184            // reference surfaces as `programId` on the pdaValueNode.
1185            let mut program_id: Option<String> = None;
1186            let mut program_id_value: Option<JsonValue> = None;
1187            if let Some(prog) = pda.get("program") {
1188                let (def, val) = pda_seed_node_from_anchor(prog, instruction_arguments, prefix)?;
1189                if let Some(def_obj) = def.as_object() {
1190                    if def_obj.get("kind").and_then(JsonValue::as_str)
1191                        == Some("constantPdaSeedNode")
1192                    {
1193                        if let Some(value) = def_obj.get("value").and_then(JsonValue::as_object) {
1194                            if value.get("kind").and_then(JsonValue::as_str)
1195                                == Some("bytesValueNode")
1196                                && value.get("encoding").and_then(JsonValue::as_str)
1197                                    == Some("base58")
1198                            {
1199                                program_id = value
1200                                    .get("data")
1201                                    .and_then(JsonValue::as_str)
1202                                    .map(str::to_string);
1203                            }
1204                        }
1205                    }
1206                }
1207                if program_id.is_none() {
1208                    if let Some(v) = val {
1209                        if let Some(inner_value) = v.get("value").cloned() {
1210                            if let Some(k) = inner_value.get("kind").and_then(JsonValue::as_str) {
1211                                if k == "accountValueNode" || k == "argumentValueNode" {
1212                                    program_id_value = Some(inner_value);
1213                                }
1214                            }
1215                        }
1216                    }
1217                }
1218            }
1219
1220            let mut pda_link = Map::new();
1221            pda_link.insert("kind".into(), json!("pdaNode"));
1222            pda_link.insert("name".into(), json!(camel_name.clone()));
1223            pda_link.insert("docs".into(), json!([]));
1224            if let Some(pid) = program_id {
1225                pda_link.insert("programId".into(), json!(pid));
1226            }
1227            pda_link.insert("seeds".into(), json!(definitions));
1228
1229            let mut pda_value = Map::new();
1230            pda_value.insert("kind".into(), json!("pdaValueNode"));
1231            pda_value.insert("pda".into(), JsonValue::Object(pda_link));
1232            pda_value.insert("seeds".into(), json!(values));
1233            if let Some(pidv) = program_id_value {
1234                pda_value.insert("programId".into(), pidv);
1235            }
1236            node.insert("defaultValue".into(), JsonValue::Object(pda_value));
1237        }
1238    }
1239
1240    Ok(JsonValue::Object(node))
1241}
1242
1243fn pda_seed_node_from_anchor(
1244    seed: &JsonValue,
1245    instruction_arguments: &[JsonValue],
1246    prefix: Option<&str>,
1247) -> Result<(JsonValue, Option<JsonValue>)> {
1248    let obj = seed
1249        .as_object()
1250        .ok_or_else(|| anyhow!("PDA seed must be an object: {seed}"))?;
1251    let kind = obj
1252        .get("kind")
1253        .and_then(JsonValue::as_str)
1254        .ok_or_else(|| anyhow!("PDA seed missing `kind`"))?;
1255    match kind {
1256        "const" => {
1257            let bytes = obj
1258                .get("value")
1259                .and_then(JsonValue::as_array)
1260                .ok_or_else(|| anyhow!("Const seed missing `value` array"))?;
1261            let raw: Vec<u8> = bytes
1262                .iter()
1263                .map(|b| {
1264                    b.as_u64()
1265                        .and_then(|n| u8::try_from(n).ok())
1266                        .ok_or_else(|| anyhow!("Const seed byte must be 0..=255"))
1267                })
1268                .collect::<Result<_>>()?;
1269            let data = bs58::encode(raw).into_string();
1270            Ok((
1271                json!({
1272                    "kind": "constantPdaSeedNode",
1273                    "type": bytes_node(),
1274                    "value": {
1275                        "kind": "bytesValueNode",
1276                        "encoding": "base58",
1277                        "data": data,
1278                    },
1279                }),
1280                None,
1281            ))
1282        }
1283        "account" => {
1284            let path = obj
1285                .get("path")
1286                .and_then(JsonValue::as_str)
1287                .ok_or_else(|| anyhow!("Account seed missing `path`"))?;
1288            let head = path.split('.').next().unwrap_or("");
1289            let prefixed = match prefix {
1290                Some(p) => format!("{p}_{head}"),
1291                None => head.to_string(),
1292            };
1293            let camel_name = camel_case(&prefixed);
1294            Ok((
1295                json!({
1296                    "kind": "variablePdaSeedNode",
1297                    "name": camel_name.clone(),
1298                    "docs": [],
1299                    "type": { "kind": "publicKeyTypeNode" },
1300                }),
1301                Some(json!({
1302                    "kind": "pdaSeedValueNode",
1303                    "name": camel_name.clone(),
1304                    "value": { "kind": "accountValueNode", "name": camel_name },
1305                })),
1306            ))
1307        }
1308        "arg" => {
1309            let path = obj
1310                .get("path")
1311                .and_then(JsonValue::as_str)
1312                .ok_or_else(|| anyhow!("Arg seed missing `path`"))?;
1313            let head = path.split('.').next().unwrap_or("");
1314            let arg_name = camel_case(head);
1315            let arg_node = instruction_arguments
1316                .iter()
1317                .find(|a| a.get("name").and_then(JsonValue::as_str) == Some(arg_name.as_str()))
1318                .ok_or_else(|| anyhow!("Arg seed `{path}` not found in instruction arguments"))?;
1319            // Anchor PDA seeds use the raw UTF-8 bytes of a string argument
1320            // (no Borsh size prefix); detect that pattern and unwrap so the
1321            // generated codec doesn't write the length on-chain.
1322            let arg_type = arg_node.get("type").cloned().unwrap_or_else(|| json!("u8"));
1323            let unwrapped = if is_borsh_string(&arg_type) {
1324                json!({ "kind": "stringTypeNode", "encoding": "utf8" })
1325            } else {
1326                arg_type
1327            };
1328            Ok((
1329                json!({
1330                    "kind": "variablePdaSeedNode",
1331                    "name": arg_name.clone(),
1332                    "docs": [],
1333                    "type": unwrapped,
1334                }),
1335                Some(json!({
1336                    "kind": "pdaSeedValueNode",
1337                    "name": arg_name.clone(),
1338                    "value": { "kind": "argumentValueNode", "name": arg_name },
1339                })),
1340            ))
1341        }
1342        other => bail!("Unimplemented PDA seed kind: `{other}`"),
1343    }
1344}
1345
1346fn is_borsh_string(ty: &JsonValue) -> bool {
1347    let Some(obj) = ty.as_object() else {
1348        return false;
1349    };
1350    if obj.get("kind").and_then(JsonValue::as_str) != Some("sizePrefixTypeNode") {
1351        return false;
1352    }
1353    let inner = obj.get("type").and_then(JsonValue::as_object);
1354    let prefix = obj.get("prefix").and_then(JsonValue::as_object);
1355    let inner_ok = inner.is_some_and(|o| {
1356        o.get("kind").and_then(JsonValue::as_str) == Some("stringTypeNode")
1357            && o.get("encoding").and_then(JsonValue::as_str) == Some("utf8")
1358    });
1359    let prefix_ok = prefix.is_some_and(|o| {
1360        o.get("kind").and_then(JsonValue::as_str) == Some("numberTypeNode")
1361            && o.get("format").and_then(JsonValue::as_str) == Some("u32")
1362    });
1363    inner_ok && prefix_ok
1364}
1365
1366// ---------------------------------------------------------------------------
1367// Small helpers.
1368// ---------------------------------------------------------------------------
1369
1370fn discriminator_bytes(node: &JsonValue) -> Result<Vec<u8>> {
1371    let arr = node
1372        .get("discriminator")
1373        .and_then(JsonValue::as_array)
1374        .ok_or_else(|| anyhow!("Missing `discriminator`"))?;
1375    arr.iter()
1376        .map(|b| {
1377            b.as_u64()
1378                .and_then(|n| u8::try_from(n).ok())
1379                .ok_or_else(|| anyhow!("Discriminator byte must be 0..=255"))
1380        })
1381        .collect()
1382}
1383
1384fn discriminator_value(bytes: &[u8]) -> JsonValue {
1385    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
1386    json!({
1387        "kind": "bytesValueNode",
1388        "encoding": "base16",
1389        "data": hex,
1390    })
1391}
1392
1393fn number_node(format: &str) -> JsonValue {
1394    json!({ "kind": "numberTypeNode", "format": format, "endian": "le" })
1395}
1396
1397fn bytes_node() -> JsonValue {
1398    json!({ "kind": "bytesTypeNode" })
1399}
1400
1401fn string_node(encoding: &str) -> JsonValue {
1402    json!({ "kind": "stringTypeNode", "encoding": encoding })
1403}
1404
1405fn size_prefix_node(ty: JsonValue, prefix: JsonValue) -> JsonValue {
1406    json!({ "kind": "sizePrefixTypeNode", "type": ty, "prefix": prefix })
1407}
1408
1409fn docs(value: Option<&JsonValue>) -> JsonValue {
1410    match value {
1411        Some(JsonValue::Array(_)) => value.unwrap().clone(),
1412        Some(JsonValue::String(s)) => json!([s]),
1413        _ => json!([]),
1414    }
1415}
1416
1417/// Codama's `camelCase` — split on every non-alphanumeric or before an
1418/// uppercase letter, capitalize each chunk, join, then lowercase the first
1419/// character. Mirrors `@codama/nodes/src/shared/stringCases.ts` so identifiers
1420/// match the JS converter byte-for-byte.
1421fn camel_case(s: &str) -> String {
1422    if s.is_empty() {
1423        return String::new();
1424    }
1425    // Insert a space before each ASCII uppercase letter (replicates JS
1426    // `replace(/([A-Z])/g, ' $1')`).
1427    let mut spaced = String::with_capacity(s.len() + 4);
1428    for c in s.chars() {
1429        if c.is_ascii_uppercase() {
1430            spaced.push(' ');
1431        }
1432        spaced.push(c);
1433    }
1434    // Split on runs of non-alphanumeric (matches `/[^a-zA-Z0-9]+/`).
1435    let words: Vec<String> = spaced
1436        .split(|c: char| !c.is_ascii_alphanumeric())
1437        .filter(|w| !w.is_empty())
1438        .map(capitalize_word)
1439        .collect();
1440    let pascal: String = words.join("");
1441    let mut chars = pascal.chars();
1442    match chars.next() {
1443        None => String::new(),
1444        Some(c) => c.to_ascii_lowercase().to_string() + chars.as_str(),
1445    }
1446}
1447
1448fn capitalize_word(w: &str) -> String {
1449    let mut iter = w.chars();
1450    match iter.next() {
1451        None => String::new(),
1452        Some(c) => {
1453            let mut out = String::with_capacity(w.len());
1454            out.push(c.to_ascii_uppercase());
1455            for r in iter {
1456                out.push(r.to_ascii_lowercase());
1457            }
1458            out
1459        }
1460    }
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465    use super::*;
1466
1467    fn convert_str(input: &str) -> JsonValue {
1468        let idl: JsonValue = serde_json::from_str(input).unwrap();
1469        root_node_from_anchor(&idl).unwrap()
1470    }
1471
1472    #[test]
1473    fn camel_case_basic() {
1474        assert_eq!(camel_case("snake_case_name"), "snakeCaseName");
1475        assert_eq!(camel_case("kebab-case-name"), "kebabCaseName");
1476        assert_eq!(camel_case("PascalCaseName"), "pascalCaseName");
1477        assert_eq!(camel_case("alreadyCamel"), "alreadyCamel");
1478        assert_eq!(camel_case("u8"), "u8");
1479        assert_eq!(camel_case(""), "");
1480        // Matches Codama JS: every capital letter splits a word, then we
1481        // capitalize each chunk and join, so consecutive caps stay capitalized.
1482        assert_eq!(camel_case("MyABCThing"), "myABCThing");
1483    }
1484
1485    #[test]
1486    fn empty_idl_yields_root() {
1487        let idl = json!({
1488            "address": "11111111111111111111111111111111",
1489            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1490            "instructions": [],
1491        });
1492        let root = root_node_from_anchor(&idl).unwrap();
1493        assert_eq!(root["kind"], "rootNode");
1494        assert_eq!(root["standard"], "codama");
1495        let prog = &root["program"];
1496        assert_eq!(prog["kind"], "programNode");
1497        assert_eq!(prog["name"], "demo");
1498        assert_eq!(prog["origin"], "anchor");
1499        assert_eq!(prog["publicKey"], "11111111111111111111111111111111");
1500        assert_eq!(prog["instructions"].as_array().unwrap().len(), 0);
1501    }
1502
1503    #[test]
1504    fn instruction_with_primitives_and_discriminator() {
1505        let idl = json!({
1506            "address": "11111111111111111111111111111111",
1507            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1508            "instructions": [{
1509                "name": "do_thing",
1510                "discriminator": [1,2,3,4,5,6,7,8],
1511                "accounts": [
1512                    { "name": "payer", "writable": true, "signer": true }
1513                ],
1514                "args": [
1515                    { "name": "amount", "type": "u64" },
1516                    { "name": "label", "type": "string" },
1517                    { "name": "data", "type": "bytes" }
1518                ]
1519            }],
1520        });
1521        let root = root_node_from_anchor(&idl).unwrap();
1522        let ix = &root["program"]["instructions"][0];
1523        assert_eq!(ix["name"], "doThing");
1524        let args = ix["arguments"].as_array().unwrap();
1525        // discriminator + 3 user args
1526        assert_eq!(args.len(), 4);
1527        assert_eq!(args[0]["name"], "discriminator");
1528        assert_eq!(args[0]["defaultValue"]["data"], "0102030405060708");
1529        assert_eq!(args[1]["name"], "amount");
1530        assert_eq!(args[1]["type"]["format"], "u64");
1531        // string -> sizePrefix(string('utf8'), u32)
1532        assert_eq!(args[2]["type"]["kind"], "sizePrefixTypeNode");
1533        assert_eq!(args[2]["type"]["type"]["kind"], "stringTypeNode");
1534        assert_eq!(args[2]["type"]["prefix"]["format"], "u32");
1535        // bytes -> sizePrefix(bytes, u32)
1536        assert_eq!(args[3]["type"]["type"]["kind"], "bytesTypeNode");
1537
1538        let accounts = ix["accounts"].as_array().unwrap();
1539        assert_eq!(accounts[0]["name"], "payer");
1540        assert_eq!(accounts[0]["isWritable"], true);
1541        assert_eq!(accounts[0]["isSigner"], true);
1542        assert_eq!(accounts[0]["isOptional"], false);
1543    }
1544
1545    #[test]
1546    fn account_has_discriminator_field_prepended() {
1547        let idl = json!({
1548            "address": "11111111111111111111111111111111",
1549            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1550            "instructions": [],
1551            "accounts": [
1552                { "name": "Counter", "discriminator": [9,8,7,6,5,4,3,2] }
1553            ],
1554            "types": [
1555                {
1556                    "name": "Counter",
1557                    "type": {
1558                        "kind": "struct",
1559                        "fields": [{ "name": "count", "type": "u64" }]
1560                    }
1561                }
1562            ]
1563        });
1564        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1565        let acc = &root["program"]["accounts"][0];
1566        assert_eq!(acc["kind"], "accountNode");
1567        assert_eq!(acc["name"], "counter");
1568        let fields = acc["data"]["fields"].as_array().unwrap();
1569        assert_eq!(fields.len(), 2);
1570        assert_eq!(fields[0]["name"], "discriminator");
1571        assert_eq!(fields[0]["defaultValueStrategy"], "omitted");
1572        assert_eq!(fields[1]["name"], "count");
1573        // Backing struct is filtered out of `definedTypes`.
1574        assert_eq!(root["program"]["definedTypes"].as_array().unwrap().len(), 0);
1575    }
1576
1577    #[test]
1578    fn event_uses_hidden_prefix_and_constant_discriminator() {
1579        let idl = json!({
1580            "address": "11111111111111111111111111111111",
1581            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1582            "instructions": [],
1583            "events": [{ "name": "Tick", "discriminator": [1,1,1,1,1,1,1,1] }],
1584            "types": [{
1585                "name": "Tick",
1586                "type": { "kind": "struct", "fields": [{ "name": "n", "type": "u32" }] }
1587            }]
1588        });
1589        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1590        let ev = &root["program"]["events"][0];
1591        assert_eq!(ev["data"]["kind"], "hiddenPrefixTypeNode");
1592        assert_eq!(ev["discriminators"][0]["kind"], "constantDiscriminatorNode");
1593    }
1594
1595    #[test]
1596    fn errors_format_docs_as_name_colon_msg() {
1597        let idl = json!({
1598            "address": "11111111111111111111111111111111",
1599            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1600            "instructions": [],
1601            "errors": [{ "code": 6000, "name": "Boom", "msg": "Kaboom!" }]
1602        });
1603        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1604        let e = &root["program"]["errors"][0];
1605        assert_eq!(e["code"], 6000);
1606        assert_eq!(e["name"], "boom");
1607        assert_eq!(e["docs"][0], "Boom: Kaboom!");
1608    }
1609
1610    #[test]
1611    fn enum_variants_struct_tuple_unit() {
1612        let idl = json!({
1613            "address": "11111111111111111111111111111111",
1614            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1615            "instructions": [],
1616            "types": [{
1617                "name": "E",
1618                "type": {
1619                    "kind": "enum",
1620                    "variants": [
1621                        { "name": "Empty" },
1622                        { "name": "Tup", "fields": ["u8", "u16"] },
1623                        { "name": "Stru", "fields": [{ "name": "x", "type": "bool" }] }
1624                    ]
1625                }
1626            }]
1627        });
1628        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1629        let variants = root["program"]["definedTypes"][0]["type"]["variants"]
1630            .as_array()
1631            .unwrap();
1632        assert_eq!(variants[0]["kind"], "enumEmptyVariantTypeNode");
1633        assert_eq!(variants[1]["kind"], "enumTupleVariantTypeNode");
1634        assert_eq!(variants[1]["tuple"]["items"][0]["format"], "u8");
1635        assert_eq!(variants[2]["kind"], "enumStructVariantTypeNode");
1636    }
1637
1638    #[test]
1639    fn vec_array_option_coption() {
1640        let idl = json!({
1641            "address": "11111111111111111111111111111111",
1642            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1643            "instructions": [{
1644                "name": "f",
1645                "discriminator": [0,0,0,0,0,0,0,0],
1646                "accounts": [],
1647                "args": [
1648                    { "name": "v", "type": { "vec": "u8" } },
1649                    { "name": "a", "type": { "array": ["u8", 4] } },
1650                    { "name": "o", "type": { "option": "u64" } },
1651                    { "name": "co", "type": { "coption": "u64" } }
1652                ]
1653            }]
1654        });
1655        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1656        let args = root["program"]["instructions"][0]["arguments"]
1657            .as_array()
1658            .unwrap();
1659        // [0]=discriminator, [1..]=user args
1660        assert_eq!(args[1]["type"]["count"]["kind"], "prefixedCountNode");
1661        assert_eq!(args[2]["type"]["count"]["kind"], "fixedCountNode");
1662        assert_eq!(args[2]["type"]["count"]["value"], 4);
1663        assert_eq!(args[3]["type"]["kind"], "optionTypeNode");
1664        assert_eq!(args[3]["type"]["fixed"], false);
1665        assert_eq!(args[3]["type"]["prefix"]["format"], "u8");
1666        assert_eq!(args[4]["type"]["fixed"], true);
1667        assert_eq!(args[4]["type"]["prefix"]["format"], "u32");
1668    }
1669
1670    #[test]
1671    fn generics_unwrap_value_and_const() {
1672        let idl = json!({
1673            "address": "11111111111111111111111111111111",
1674            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1675            "instructions": [{
1676                "name": "f",
1677                "discriminator": [0,0,0,0,0,0,0,0],
1678                "accounts": [],
1679                "args": [
1680                    { "name": "x", "type": {
1681                        "defined": {
1682                            "name": "Wrap",
1683                            "generics": [
1684                                { "kind": "type", "type": "u64" },
1685                                { "kind": "const", "value": "3" }
1686                            ]
1687                        }
1688                    }}
1689                ]
1690            }],
1691            "types": [{
1692                "name": "Wrap",
1693                "generics": [
1694                    { "kind": "type", "name": "T" },
1695                    { "kind": "const", "name": "N", "type": "usize" }
1696                ],
1697                "type": {
1698                    "kind": "struct",
1699                    "fields": [
1700                        { "name": "items", "type": { "array": [{ "generic": "T" }, { "generic": "N" }] } }
1701                    ]
1702                }
1703            }]
1704        });
1705        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1706        let arg = &root["program"]["instructions"][0]["arguments"][1];
1707        // Wrap<u64, 3> -> struct { items: [u64; 3] }
1708        assert_eq!(arg["type"]["kind"], "structTypeNode");
1709        let items_field = &arg["type"]["fields"][0];
1710        assert_eq!(items_field["name"], "items");
1711        assert_eq!(items_field["type"]["item"]["format"], "u64");
1712        assert_eq!(items_field["type"]["count"]["value"], 3);
1713    }
1714
1715    #[test]
1716    fn pda_seeds_const_account_arg() {
1717        let idl = json!({
1718            "address": "11111111111111111111111111111111",
1719            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1720            "instructions": [{
1721                "name": "f",
1722                "discriminator": [0,0,0,0,0,0,0,0],
1723                "accounts": [
1724                    {
1725                        "name": "vault",
1726                        "pda": {
1727                            "seeds": [
1728                                { "kind": "const", "value": [118, 97, 117, 108, 116] },
1729                                { "kind": "account", "path": "owner" },
1730                                { "kind": "arg", "path": "id" }
1731                            ]
1732                        }
1733                    },
1734                    { "name": "owner", "signer": true }
1735                ],
1736                "args": [
1737                    { "name": "id", "type": "u64" }
1738                ]
1739            }]
1740        });
1741        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1742        let acc = &root["program"]["instructions"][0]["accounts"][0];
1743        assert_eq!(acc["name"], "vault");
1744        let dv = &acc["defaultValue"];
1745        assert_eq!(dv["kind"], "pdaValueNode");
1746        let seeds = dv["pda"]["seeds"].as_array().unwrap();
1747        assert_eq!(seeds[0]["kind"], "constantPdaSeedNode");
1748        // "vault" UTF-8 bytes encoded as base58.
1749        assert_eq!(seeds[0]["value"]["data"], "EMeDBmd");
1750        assert_eq!(seeds[1]["kind"], "variablePdaSeedNode");
1751        assert_eq!(seeds[1]["name"], "owner");
1752        assert_eq!(seeds[2]["name"], "id");
1753        assert_eq!(seeds[2]["type"]["format"], "u64");
1754        let values = dv["seeds"].as_array().unwrap();
1755        assert_eq!(values.len(), 2); // const seed has no value
1756        assert_eq!(values[0]["value"]["kind"], "accountValueNode");
1757        assert_eq!(values[1]["value"]["kind"], "argumentValueNode");
1758    }
1759
1760    #[test]
1761    fn pda_arg_string_seed_unwraps_borsh_prefix() {
1762        let idl = json!({
1763            "address": "11111111111111111111111111111111",
1764            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1765            "instructions": [{
1766                "name": "f",
1767                "discriminator": [0,0,0,0,0,0,0,0],
1768                "accounts": [{
1769                    "name": "vault",
1770                    "pda": { "seeds": [{ "kind": "arg", "path": "label" }] }
1771                }],
1772                "args": [{ "name": "label", "type": "string" }]
1773            }]
1774        });
1775        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1776        let acc = &root["program"]["instructions"][0]["accounts"][0];
1777        let seed = &acc["defaultValue"]["pda"]["seeds"][0];
1778        assert_eq!(seed["type"]["kind"], "stringTypeNode");
1779        assert_eq!(seed["type"]["encoding"], "utf8");
1780    }
1781
1782    #[test]
1783    fn composite_accounts_get_prefixed_on_collision() {
1784        let idl = json!({
1785            "address": "11111111111111111111111111111111",
1786            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1787            "instructions": [{
1788                "name": "f",
1789                "discriminator": [0,0,0,0,0,0,0,0],
1790                "args": [],
1791                "accounts": [
1792                    { "name": "a", "accounts": [
1793                        { "name": "user", "writable": true }
1794                    ]},
1795                    { "name": "b", "accounts": [
1796                        { "name": "user", "writable": false }
1797                    ]}
1798                ]
1799            }]
1800        });
1801        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1802        let accs = root["program"]["instructions"][0]["accounts"]
1803            .as_array()
1804            .unwrap();
1805        let names: Vec<&str> = accs.iter().map(|a| a["name"].as_str().unwrap()).collect();
1806        assert_eq!(names, vec!["aUser", "bUser"]);
1807    }
1808
1809    #[test]
1810    fn pda_with_nested_path_drops_default_value() {
1811        let idl = json!({
1812            "address": "11111111111111111111111111111111",
1813            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1814            "instructions": [{
1815                "name": "f",
1816                "discriminator": [0,0,0,0,0,0,0,0],
1817                "accounts": [{
1818                    "name": "child",
1819                    "pda": { "seeds": [{ "kind": "account", "path": "parent.field" }] }
1820                }],
1821                "args": []
1822            }]
1823        });
1824        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1825        let acc = &root["program"]["instructions"][0]["accounts"][0];
1826        assert!(acc.get("defaultValue").is_none());
1827    }
1828
1829    #[test]
1830    fn language_id_and_renderer_package_are_stable() {
1831        // The script name doubles as the per-language output subdirectory, so
1832        // changing it would silently move users' generated clients.
1833        assert_eq!(Language::Js.id(), "js");
1834        assert_eq!(Language::JsUmi.id(), "js-umi");
1835        assert_eq!(Language::Rust.id(), "rust");
1836        assert_eq!(Language::Go.id(), "go");
1837        assert_eq!(Language::Js.renderer_package(), "@codama/renderers-js");
1838        assert_eq!(
1839            Language::JsUmi.renderer_package(),
1840            "@codama/renderers-js-umi"
1841        );
1842        assert_eq!(Language::Rust.renderer_package(), "@codama/renderers-rust");
1843        assert_eq!(Language::Go.renderer_package(), "@codama/renderers-go");
1844    }
1845
1846    #[test]
1847    fn generate_cli_parses_repeated_and_comma_separated_languages() {
1848        use clap::Parser;
1849        // Sanity-check the flag plumbing: `-l go,js -l rust` should yield three
1850        // distinct languages, in the order they were supplied.
1851        let parsed = CodamaCommand::try_parse_from([
1852            "codama", "generate", "-l", "go,js", "-l", "rust", "-p", "out", "idl.json",
1853        ])
1854        .expect("flags parse");
1855        match parsed {
1856            CodamaCommand::Generate {
1857                language,
1858                path,
1859                idl,
1860            } => {
1861                assert_eq!(language, vec![Language::Go, Language::Js, Language::Rust]);
1862                assert_eq!(path, "out");
1863                assert_eq!(idl, "idl.json");
1864            }
1865            other => panic!("expected Generate, got {other:?}"),
1866        }
1867    }
1868
1869    #[test]
1870    fn language_from_id_is_inverse_of_id() {
1871        for lang in [Language::Js, Language::JsUmi, Language::Rust, Language::Go] {
1872            assert_eq!(Language::from_id(lang.id()), Some(lang));
1873        }
1874        assert_eq!(Language::from_id("python"), None);
1875    }
1876
1877    #[test]
1878    fn auto_generate_noops_when_auto_disabled() {
1879        // `auto = false` (the default) must not spawn Codama even when a
1880        // language is enabled and an IDL is present — otherwise a workspace
1881        // that just declared `[clients]` for documentation purposes would
1882        // start triggering downloads on every `anchor build`.
1883        use crate::config::{ClientLanguageConfig, ClientsConfig};
1884        let cfg = ClientsConfig {
1885            auto: false,
1886            rust: Some(ClientLanguageConfig::Enabled(true)),
1887            ..Default::default()
1888        };
1889        let tmp = std::env::temp_dir().join("anchor_codama_auto_disabled");
1890        let _ = fs::remove_dir_all(&tmp);
1891        fs::create_dir_all(&tmp).unwrap();
1892        let idl = tmp.join("p.json");
1893        fs::write(&idl, "{}").unwrap();
1894        // If Codama were spawned this would fail (no `npx`/`codama` in test
1895        // env, no real IDL); it returns Ok(()) because we short-circuit.
1896        auto_generate_for_workspace(&cfg, &tmp, &[idl]).unwrap();
1897        fs::remove_dir_all(&tmp).ok();
1898    }
1899
1900    #[test]
1901    fn auto_generate_warns_when_no_languages_enabled() {
1902        use crate::config::ClientsConfig;
1903        let cfg = ClientsConfig {
1904            auto: true,
1905            ..Default::default()
1906        };
1907        let tmp = std::env::temp_dir().join("anchor_codama_no_langs");
1908        let _ = fs::remove_dir_all(&tmp);
1909        fs::create_dir_all(&tmp).unwrap();
1910        // `auto = true` but every language is `None` → the function logs a
1911        // warning and exits cleanly without invoking Codama.
1912        auto_generate_for_workspace(&cfg, &tmp, &[]).unwrap();
1913        fs::remove_dir_all(&tmp).ok();
1914    }
1915
1916    #[test]
1917    fn defined_link_is_emitted_for_non_generic_reference() {
1918        let idl = json!({
1919            "address": "11111111111111111111111111111111",
1920            "metadata": { "name": "demo", "version": "0.1.0", "spec": "0.1.0" },
1921            "instructions": [{
1922                "name": "f",
1923                "discriminator": [0,0,0,0,0,0,0,0],
1924                "accounts": [],
1925                "args": [
1926                    { "name": "s", "type": { "defined": { "name": "MyStruct" } } }
1927                ]
1928            }],
1929            "types": [{
1930                "name": "MyStruct",
1931                "type": { "kind": "struct", "fields": [] }
1932            }]
1933        });
1934        let root = convert_str(&serde_json::to_string(&idl).unwrap());
1935        let arg = &root["program"]["instructions"][0]["arguments"][1];
1936        assert_eq!(arg["type"]["kind"], "definedTypeLinkNode");
1937        assert_eq!(arg["type"]["name"], "myStruct");
1938    }
1939}