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