import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { extractRustWorkspaceLinks } from '../../../src/core/group/extractors/rust-workspace-extractor.js'; import { _captureLogger } from '../../../src/core/logger.js'; describe('RustWorkspaceExtractor', () => { let tmpDir: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-rust-ws-')); }); afterEach(async () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); async function writeFile(relPath: string, content: string) { const absPath = path.join(tmpDir, relPath); await fs.mkdir(path.dirname(absPath), { recursive: true }); await fs.writeFile(absPath, content, 'utf-8'); } it('discovers cross-crate imports from workspace dependencies', async () => { // Crate A: defines Expression await writeFile( 'crate-a/Cargo.toml', `[package]\nname = "mathlex"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('crate-a/src/lib.rs', 'pub struct Expression {}\npub struct Token {}\n'); // Crate B: depends on A via workspace, imports Expression await writeFile( 'crate-b/Cargo.toml', `[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathlex = { workspace = true }\n`, ); await writeFile('crate-b/src/main.rs', 'use mathlex::Expression;\nfn eval(e: Expression) {}\n'); const repos = { 'parser/mathlex': 'mathlex', 'engine/thales': 'thales', }; const repoPaths = new Map([ ['parser/mathlex', path.join(tmpDir, 'crate-a')], ['engine/thales', path.join(tmpDir, 'crate-b')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(1); expect(result.links[0]).toEqual({ from: 'parser/mathlex', to: 'engine/thales', type: 'custom', contract: 'mathlex::Expression', role: 'provider', }); }); it('handles hyphenated crate names (converted to underscores in use statements)', async () => { await writeFile( 'units/Cargo.toml', `[package]\nname = "mathcore-units"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('units/src/lib.rs', 'pub struct Unit {}\npub struct Dimension {}\n'); await writeFile( 'engine/Cargo.toml', `[package]\nname = "thales"\nversion = "0.1.0"\n\n[dependencies]\nmathcore-units = { workspace = true }\n`, ); await writeFile( 'engine/src/main.rs', 'use mathcore_units::Unit;\nuse mathcore_units::Dimension;\n', ); const repos = { 'core/units': 'mathcore-units', 'engine/thales': 'thales', }; const repoPaths = new Map([ ['core/units', path.join(tmpDir, 'units')], ['engine/thales', path.join(tmpDir, 'engine')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(2); const contracts = result.links.map((l) => l.contract).sort(); expect(contracts).toEqual(['mathcore-units::Dimension', 'mathcore-units::Unit']); }); it('handles grouped imports (use crate::{Type1, Type2})', async () => { await writeFile( 'lib/Cargo.toml', `[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('lib/src/lib.rs', 'pub struct Foo {}\npub struct Bar {}\n'); await writeFile( 'app/Cargo.toml', `[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`, ); await writeFile('app/src/main.rs', 'use shared::{Foo, Bar};\n'); const repos = { lib: 'shared', app: 'myapp' }; const repoPaths = new Map([ ['lib', path.join(tmpDir, 'lib')], ['app', path.join(tmpDir, 'app')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(2); const contracts = result.links.map((l) => l.contract).sort(); expect(contracts).toEqual(['shared::Bar', 'shared::Foo']); }); it('ignores snake_case imports (functions/modules, not types)', async () => { await writeFile( 'lib/Cargo.toml', `[package]\nname = "utils"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('lib/src/lib.rs', 'pub fn helper() {}\npub struct Config {}\n'); await writeFile( 'app/Cargo.toml', `[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nutils = { workspace = true }\n`, ); await writeFile('app/src/main.rs', 'use utils::helper;\nuse utils::Config;\n'); const repos = { lib: 'utils', app: 'myapp' }; const repoPaths = new Map([ ['lib', path.join(tmpDir, 'lib')], ['app', path.join(tmpDir, 'app')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(1); expect(result.links[0].contract).toBe('utils::Config'); }); it('skips repos without Cargo.toml', async () => { await writeFile('js-app/package.json', '{"name": "js-app"}'); await writeFile('js-app/src/index.ts', 'export const x = 1;'); const repos = { app: 'js-app' }; const repoPaths = new Map([['app', path.join(tmpDir, 'js-app')]]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(0); expect(result.discoveredCrates.size).toBe(0); }); it('deduplicates identical imports from multiple files', async () => { await writeFile( 'lib/Cargo.toml', `[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('lib/src/lib.rs', 'pub struct Config {}\n'); await writeFile( 'app/Cargo.toml', `[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`, ); await writeFile('app/src/main.rs', 'use shared::Config;\n'); await writeFile('app/src/other.rs', 'use shared::Config;\n'); const repos = { lib: 'shared', app: 'myapp' }; const repoPaths = new Map([ ['lib', path.join(tmpDir, 'lib')], ['app', path.join(tmpDir, 'app')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(1); }); it('handles path dependencies alongside workspace deps', async () => { await writeFile( 'lib/Cargo.toml', `[package]\nname = "mylib"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('lib/src/lib.rs', 'pub trait Handler {}\n'); await writeFile( 'app/Cargo.toml', `[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nmylib = { path = "../lib" }\n`, ); await writeFile('app/src/main.rs', 'use mylib::Handler;\n'); const repos = { lib: 'mylib', app: 'myapp' }; const repoPaths = new Map([ ['lib', path.join(tmpDir, 'lib')], ['app', path.join(tmpDir, 'app')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(1); expect(result.links[0].contract).toBe('mylib::Handler'); }); it('warns and skips duplicate crate names', async () => { await writeFile( 'repo-a/Cargo.toml', `[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('repo-a/src/lib.rs', 'pub struct Alpha {}\n'); await writeFile( 'repo-b/Cargo.toml', `[package]\nname = "shared"\nversion = "0.2.0"\n\n[dependencies]\n`, ); await writeFile('repo-b/src/lib.rs', 'pub struct Beta {}\n'); await writeFile( 'consumer/Cargo.toml', `[package]\nname = "consumer"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`, ); await writeFile('consumer/src/main.rs', 'use shared::Alpha;\n'); const repos = { a: 'shared-a', b: 'shared-b', consumer: 'consumer' }; const repoPaths = new Map([ ['a', path.join(tmpDir, 'repo-a')], ['b', path.join(tmpDir, 'repo-b')], ['consumer', path.join(tmpDir, 'consumer')], ]); const cap = _captureLogger(); try { const result = await extractRustWorkspaceLinks(repos, repoPaths); expect( cap.records().some((r) => String(r.msg ?? '').includes('duplicate crate name "shared"')), ).toBe(true); expect(result.links).toHaveLength(1); expect(result.links[0].from).toBe('a'); } finally { cap.restore(); } }); it('produces distinct contracts when two crates export same symbol name', async () => { await writeFile( 'lib-a/Cargo.toml', `[package]\nname = "alpha"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('lib-a/src/lib.rs', 'pub struct Config {}\n'); await writeFile( 'lib-b/Cargo.toml', `[package]\nname = "beta"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('lib-b/src/lib.rs', 'pub struct Config {}\n'); await writeFile( 'app/Cargo.toml', `[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nalpha = { workspace = true }\nbeta = { workspace = true }\n`, ); await writeFile('app/src/main.rs', 'use alpha::Config;\nuse beta::Config;\n'); const repos = { alpha: 'alpha', beta: 'beta', app: 'myapp' }; const repoPaths = new Map([ ['alpha', path.join(tmpDir, 'lib-a')], ['beta', path.join(tmpDir, 'lib-b')], ['app', path.join(tmpDir, 'app')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(2); const contracts = result.links.map((l) => l.contract).sort(); expect(contracts).toEqual(['alpha::Config', 'beta::Config']); }); it('respects .gitnexusignore patterns', async () => { await writeFile( 'lib/Cargo.toml', `[package]\nname = "mylib"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('lib/src/lib.rs', 'pub struct Real {}\n'); await writeFile('lib/generated/gen.rs', 'pub struct Fake {}\n'); await writeFile('lib/.gitnexusignore', 'generated/\n'); await writeFile( 'app/Cargo.toml', `[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nmylib = { workspace = true }\n`, ); await writeFile('app/src/main.rs', 'use mylib::Real;\n'); await writeFile('app/generated/gen.rs', 'use mylib::Fake;\n'); await writeFile('app/.gitnexusignore', 'generated/\n'); const repos = { lib: 'mylib', app: 'myapp' }; const repoPaths = new Map([ ['lib', path.join(tmpDir, 'lib')], ['app', path.join(tmpDir, 'app')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(1); expect(result.links[0].contract).toBe('mylib::Real'); }); it('handles nested module imports (use crate::module::Type)', async () => { await writeFile( 'lib/Cargo.toml', `[package]\nname = "shared"\nversion = "0.1.0"\n\n[dependencies]\n`, ); await writeFile('lib/src/lib.rs', ''); await writeFile( 'app/Cargo.toml', `[package]\nname = "myapp"\nversion = "0.1.0"\n\n[dependencies]\nshared = { workspace = true }\n`, ); await writeFile('app/src/main.rs', 'use shared::models::User;\nuse shared::auth::Token;\n'); const repos = { lib: 'shared', app: 'myapp' }; const repoPaths = new Map([ ['lib', path.join(tmpDir, 'lib')], ['app', path.join(tmpDir, 'app')], ]); const result = await extractRustWorkspaceLinks(repos, repoPaths); expect(result.links).toHaveLength(2); const contracts = result.links.map((l) => l.contract).sort(); expect(contracts).toEqual(['shared::Token', 'shared::User']); }); });