Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gold-parts-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@codama/renderers-rust': patch
---

fix: include num-traits in Cargo.toml when num-derive is used
57 changes: 56 additions & 1 deletion src/utils/cargoToml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ type CargoDependencyObject = {
workspace?: boolean;
};

/**
* Proc-macro crates whose generated code depends on runtime crates that
* won't appear in the rendered source text. When a key crate is detected
* as used, all its peer crates are implicitly included.
*
* Example: `#[derive(num_derive::FromPrimitive)]` expands to code that
* references `num_traits::FromPrimitive`, so `num-traits` must be present
* in Cargo.toml whenever `num-derive` is used.
*/
export const PEER_DEPENDENCIES: Record<string, string[]> = {
'num-derive': ['num-traits'],
};

export const DEFAULT_DEPENDENCY_VERSIONS: CargoDependencies = {
'anchor-lang': { optional: true, version: '~0.31' },
borsh: '^0.10',
Expand Down Expand Up @@ -231,6 +244,12 @@ export function getUsedDependencyVersions(
[{} as CargoDependencies, new Set<string>()],
);

// Expand peer dependencies: proc-macro crates like `num-derive` generate
// code that depends on runtime crates like `num-traits` at compile time.
// Since these runtime crates never appear in the rendered source text,
// they won't be detected by import scanning — we must add them explicitly.
expandPeerDependencies(usedDependencyVersion, dependencyVersionsWithDefaults, missingDependencies);

if (missingDependencies.size > 0) {
throw new CodamaError(CODAMA_ERROR__RENDERERS__MISSING_DEPENDENCY_VERSIONS, {
dependencies: [...missingDependencies],
Expand All @@ -241,6 +260,42 @@ export function getUsedDependencyVersions(
return usedDependencyVersion;
}

/**
* For each detected dependency, check if it has peer dependencies that must
* also be present (e.g. `num-derive` requires `num-traits` at runtime).
* If a peer is not already in the used set, look it up in the version map
* and add it. If it can't be resolved, add it to missingDependencies so
* the caller can surface the error.
*/
function expandPeerDependencies(
usedDependencyVersion: CargoDependencies,
dependencyVersionsWithDefaults: CargoDependencies,
missingDependencies: Set<string>,
): void {
// Snapshot keys to avoid iterating over newly added entries.
const currentKeys = Object.keys(usedDependencyVersion);

for (const depKey of currentKeys) {
const peers = PEER_DEPENDENCIES[depKey];
if (!peers) continue;

for (const peerCrateName of peers) {
// Skip if already resolved.
const peerImportName = getCargoDependencyImportName(peerCrateName);
const alreadyPresent = findCargoDependencyByImportName(usedDependencyVersion, peerImportName);
if (alreadyPresent) continue;

// Look up the peer in the version map.
const peerDep = findCargoDependencyByImportName(dependencyVersionsWithDefaults, peerImportName);
if (peerDep) {
usedDependencyVersion[peerDep[0]] = peerDep[1];
} else {
missingDependencies.add(peerCrateName);
}
}
}
}

function getUsedImportNames(renderMap: RenderMap<Fragment>, dependencyMap: Record<string, string>): Set<string> {
const fragments = [...renderMap.values()];
const fromImportMap = new ImportMap()
Expand Down Expand Up @@ -366,4 +421,4 @@ function findCargoDependencyByImportName(

function readCargoToml(path: string): CargoToml {
return parse(readFile(path)) as CargoToml;
}
}
72 changes: 72 additions & 0 deletions test/utils/cargoToml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DEFAULT_DEPENDENCY_VERSIONS,
Fragment,
getUsedDependencyVersions,
PEER_DEPENDENCIES,
shouldUpdateRange,
updateExistingCargoToml,
} from '../../src/utils';
Expand Down Expand Up @@ -282,3 +283,74 @@ describe('shouldUpdateRange', () => {
expect(shouldUpdateRange('module', '=1.1.0', '=1.0.0')).toBe(false);
});
});

describe('peer dependencies', () => {
test('it includes num-traits when num-derive is used via derive attribute', () => {
// Simulates generated code containing `#[derive(num_derive::FromPrimitive)]`.
// The content scanner detects `num_derive` but `num_traits` never appears
// in the rendered source — it's only referenced by the proc-macro expansion.
const renderMap = createRenderMap({
'my_enum.rs': fragment(
'#[derive(num_derive::FromPrimitive)]\npub enum MyEnum { A, B, C }',
),
});

const result = getUsedDependencyVersions(renderMap, {}, {});
expect(result).toHaveProperty('num-derive', DEFAULT_DEPENDENCY_VERSIONS['num-derive']);
expect(result).toHaveProperty('num-traits', DEFAULT_DEPENDENCY_VERSIONS['num-traits']);
});

test('it includes num-traits when num-derive is used via ImportMap', () => {
// Simulates the trait renderer adding `num_derive::FromPrimitive` to
// the ImportMap (when useFullyQualifiedName is false).
const renderMap = createRenderMap({
'my_enum.rs': use('num_derive::FromPrimitive'),
});

const result = getUsedDependencyVersions(renderMap, {}, {});
expect(result).toHaveProperty('num-derive', DEFAULT_DEPENDENCY_VERSIONS['num-derive']);
expect(result).toHaveProperty('num-traits', DEFAULT_DEPENDENCY_VERSIONS['num-traits']);
});

test('it does not duplicate num-traits if already explicitly used', () => {
const renderMap = createRenderMap({
'my_enum.rs': {
content: '#[derive(num_derive::FromPrimitive)]\npub enum MyEnum { A }',
imports: new ImportMap().add('num_traits::FromPrimitive'),
},
});

const result = getUsedDependencyVersions(renderMap, {}, {});
expect(result).toHaveProperty('num-derive');
expect(result).toHaveProperty('num-traits');
// Ensure no duplicate keys — Object.keys is sufficient since keys are unique.
const numTraitsEntries = Object.keys(result).filter(k => k === 'num-traits');
expect(numTraitsEntries).toHaveLength(1);
});

test('it respects user-provided dependencyVersions for peer dependencies', () => {
const renderMap = createRenderMap({
'my_enum.rs': fragment('#[derive(num_derive::FromPrimitive)]\npub enum MyEnum { A }'),
});
const customVersions = { 'num-traits': '^0.3' };

const result = getUsedDependencyVersions(renderMap, {}, customVersions);
expect(result['num-traits']).toBe('^0.3');
});

test('it does not include peer dependencies when the parent is not used', () => {
const renderMap = createRenderMap({
'my_struct.rs': use('borsh::BorshSerialize'),
});

const result = getUsedDependencyVersions(renderMap, {}, {});
expect(result).toHaveProperty('borsh');
expect(result).not.toHaveProperty('num-derive');
expect(result).not.toHaveProperty('num-traits');
});

test('PEER_DEPENDENCIES maps num-derive to num-traits', () => {
expect(PEER_DEPENDENCIES).toHaveProperty('num-derive');
expect(PEER_DEPENDENCIES['num-derive']).toContain('num-traits');
});
});