alef 0.82.1

Opinionated polyglot binding generator for Rust libraries
Documentation
use crate::e2e::config::E2eConfig;
use crate::e2e::fixture::FixtureGroup;

pub(super) fn render_pubspec(
    pkg_name: &str,
    pkg_path: &str,
    pkg_version: &str,
    dep_mode: crate::e2e::config::DependencyMode,
) -> String {
    let test_ver = crate::core::template_versions::pub_dev::TEST_PACKAGE;
    let http_ver = crate::core::template_versions::pub_dev::HTTP_PACKAGE;
    let ffi_ver = crate::core::template_versions::pub_dev::FFI_PACKAGE;

    let dep_block = match dep_mode {
        // This manifest exists to verify one specific published artifact, so pin it
        // exactly rather than a floating range — a range would let a later publish
        // silently swap what's under test. If alef.toml already supplies a version
        // constraint operator (`^`, `~`, `>=`, etc.) that's an explicit escape hatch
        // and passes through unchanged, since we never strip it. ~keep
        crate::e2e::config::DependencyMode::Registry => {
            format!("  {pkg_name}: {pkg_version}")
        }
        crate::e2e::config::DependencyMode::Local => {
            format!("  {pkg_name}:\n    path: {pkg_path}")
        }
    };

    let sdk = crate::core::template_versions::toolchain::DART_SDK_CONSTRAINT;
    format!(
        r#"name: e2e_dart
version: 0.1.0
publish_to: none

environment:
  sdk: "{sdk}"

dependencies:
{dep_block}
  ffi: {ffi_ver}

dev_dependencies:
  test: {test_ver}
  http: {http_ver}
"#
    )
}

/// Shared e2e test support: the standalone-mock-server spawn (`startMockServer`) used
/// by `render_dart_sut_spawn`'s non-server-pattern branch, plus `useTestDocumentsCwd`
/// (unused by generated tests today, kept for parity with the historical helper and
/// available for a future caller).
///
/// ~keep Both `_findRepoRoot`/`_findTestDocumentsDir` walk up from `Directory.current`
/// rather than resolving a path relative to it. Per-test-file code used to derive the
/// mock-server binary/manifest paths with `Directory.current.uri.resolve('../rust/...')`,
/// which only resolves correctly when `Directory.current` is `e2e/dart/` -- the process
/// invoking `dart test` actually leaves `Directory.current` at the repo root, so that
/// resolved to `<repo_root>/../rust/Cargo.toml` and every standalone-mock-server suite
/// failed in `setUpAll` with `Bad state: mock-server build failed: error: manifest path
/// ... does not exist`, zero fixture assertions run. Centralizing the walk-up here means
/// every language-generated Dart test file shares one correct implementation instead of
/// each re-deriving (and each potentially re-breaking) the same resolution.
pub(super) fn render_e2e_helpers() -> String {
    r#"// Generated by alef — DO NOT EDIT.
// Shared e2e test support: standalone mock-server spawn + repo-root-relative path
// resolution. Imported by generated `*_test.dart` files that spawn the standalone
// mock-server (see `render_dart_sut_spawn`'s non-server-pattern branch).
import 'dart:async';
import 'dart:convert';
import 'dart:io';

class MockServerHandle {
  MockServerHandle._(this._process, this.url, this.fixtureUrls);

  final Process? _process;
  final String url;
  final Map<String, String> fixtureUrls;

  Future<void> stop() async {
    final process = _process;
    if (process == null) return;

    try {
      await process.stdin.close();
    } catch (_) {}

    process.kill(ProcessSignal.sigterm);
    try {
      await process.exitCode.timeout(const Duration(seconds: 5));
    } on TimeoutException {
      process.kill(ProcessSignal.sigkill);
      await process.exitCode;
    }
  }
}

class _MockServerStartup {
  _MockServerStartup(this.url, this.fixtureUrls);

  final String url;
  final Map<String, String> fixtureUrls;
}

void useTestDocumentsCwd() {
  final testDocuments = _findTestDocumentsDir();
  Directory.current = testDocuments.path;
}

Future<MockServerHandle> startMockServer() async {
  final presetUrl = Platform.environment['MOCK_SERVER_URL'];
  if (presetUrl != null && presetUrl.isNotEmpty) {
    return MockServerHandle._(null, presetUrl, _fixtureUrlsFromEnvironment());
  }

  final repoRoot = _findRepoRoot();
  final mockServer = File(
    '${repoRoot.path}/e2e/rust/target/release/mock-server',
  );
  if (!mockServer.existsSync()) {
    final manifestPath = '${repoRoot.path}/e2e/rust/Cargo.toml';
    final build = await Process.run('cargo', [
      'build',
      '--release',
      '--manifest-path',
      manifestPath,
      '--bin',
      'mock-server',
    ]);
    if (build.exitCode != 0) {
      throw StateError('mock-server build failed: ${build.stderr}');
    }
  }

  final process = await Process.start(mockServer.path, [
    '${repoRoot.path}/fixtures',
  ]);
  process.stderr.transform(utf8.decoder).listen(stderr.write);

  final completer = Completer<_MockServerStartup>();
  StreamSubscription<String>? subscription;
  var collectedUrl = '';
  var collectedFixtureUrls = <String, String>{};

  subscription = process.stdout
  .transform(utf8.decoder)
  .transform(const LineSplitter())
  .listen((line) {
    final trimmed = line.trim();
    if (trimmed.startsWith('MOCK_SERVER_URL=')) {
      collectedUrl = trimmed.substring('MOCK_SERVER_URL='.length);
      return;
    }
    if (trimmed.startsWith('MOCK_SERVERS=')) {
      final rawJson = trimmed.substring('MOCK_SERVERS='.length);
      final decoded = jsonDecode(rawJson) as Map<String, dynamic>;
      collectedFixtureUrls = decoded.map(
        (key, value) => MapEntry(key, value as String),
      );
      if (collectedUrl.isNotEmpty && !completer.isCompleted) {
        completer.complete(
          _MockServerStartup(collectedUrl, collectedFixtureUrls),
        );
        subscription?.cancel();
      }
    }
  }, onError: completer.completeError);

  try {
    final startup = await completer.future.timeout(const Duration(seconds: 30));
    return MockServerHandle._(process, startup.url, startup.fixtureUrls);
  } on TimeoutException {
    process.kill(ProcessSignal.sigkill);
    throw StateError('mock-server startup timeout');
  }
}

Directory _findRepoRoot() {
  var current = Directory.current.absolute;
  for (var i = 0; i < 16; i++) {
    if (File('${current.path}/Cargo.toml').existsSync() &&
      Directory('${current.path}/test_documents').existsSync()) {
      return current;
    }

    final parent = current.parent;
    if (parent.path == current.path) break;
    current = parent;
  }

  throw StateError(
    'could not locate repository root from ${Directory.current.path}',
  );
}

Directory _findTestDocumentsDir() {
  var current = Directory.current.absolute;
  for (var i = 0; i < 16; i++) {
    final candidate = Directory('${current.path}/test_documents');
    if (candidate.existsSync()) return candidate;

    final parent = current.parent;
    if (parent.path == current.path) break;
    current = parent;
  }

  throw StateError(
    'could not locate test_documents from ${Directory.current.path}',
  );
}

Map<String, String> _fixtureUrlsFromEnvironment() {
  final result = <String, String>{};
  for (final entry in Platform.environment.entries) {
    if (!entry.key.startsWith('MOCK_SERVER_') || entry.key == 'MOCK_SERVER_URL') {
      continue;
    }
    result[entry.key.substring('MOCK_SERVER_'.length).toLowerCase()] =
        entry.value;
  }
  return result;
}
"#
    .to_string()
}

// The server-pattern `app_harness.dart` is now emitted by a consumer extension via
// `Extension::emit_e2e`; alef no longer emits it. Retained pending the dead-code
// sweep so the migration diff stays minimal.
#[allow(dead_code)]
pub(super) fn render_app_harness(groups: &[FixtureGroup], e2e_config: &E2eConfig, pkg_name: &str) -> String {
    // Collect all HTTP fixtures from all groups.
    let mut fixtures_map = serde_json::Map::new();

    for group in groups {
        for fixture in &group.fixtures {
            if let Some(http) = &fixture.http {
                let mut fixture_obj = serde_json::Map::new();

                let mut http_obj = serde_json::Map::new();

                // handler: route, method, body_schema
                let mut handler_obj = serde_json::Map::new();
                handler_obj.insert("route".to_string(), serde_json::json!(http.handler.route));
                handler_obj.insert("method".to_string(), serde_json::json!(http.handler.method.as_str()));
                if let Some(body_schema) = &http.handler.body_schema {
                    handler_obj.insert("body_schema".to_string(), body_schema.clone());
                } else {
                    handler_obj.insert("body_schema".to_string(), serde_json::Value::Null);
                }
                http_obj.insert("handler".to_string(), serde_json::Value::Object(handler_obj));

                // expected_response: status_code, body, headers
                let mut response_obj = serde_json::Map::new();
                response_obj.insert(
                    "status_code".to_string(),
                    serde_json::json!(http.expected_response.status_code),
                );
                if let Some(body) = &http.expected_response.body {
                    response_obj.insert("body".to_string(), body.clone());
                } else {
                    response_obj.insert("body".to_string(), serde_json::Value::Null);
                }

                let headers: serde_json::Map<String, serde_json::Value> = http
                    .expected_response
                    .headers
                    .iter()
                    .map(|(k, v)| (k.clone(), serde_json::json!(v)))
                    .collect();
                response_obj.insert("headers".to_string(), serde_json::Value::Object(headers));

                http_obj.insert("expected_response".to_string(), serde_json::Value::Object(response_obj));

                fixture_obj.insert("http".to_string(), serde_json::Value::Object(http_obj));
                fixtures_map.insert(fixture.id.clone(), serde_json::Value::Object(fixture_obj));
            }
        }
    }

    let fixtures_json = serde_json::to_string(&fixtures_map).unwrap_or_else(|_| "{}".to_string());

    // Derive the bridge module name from the package name:
    // e.g. "my_pkg" → "my_pkg_bridge_generated"
    let bridge_module = format!("{pkg_name}_bridge_generated");

    // Render using the Jinja template.
    let ctx = minijinja::context! {
        fixtures_json => fixtures_json,
        pkg_name => pkg_name,
        bridge_module => bridge_module,
        host => &e2e_config.harness.host,
        port => e2e_config.harness.port,
    };
    crate::e2e::template_env::render("dart/app_harness.dart.jinja", ctx)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::e2e::config::DependencyMode;

    #[test]
    fn render_pubspec_registry_uses_exact_pin() {
        let out = render_pubspec("my_pkg", "", "1.2.3", DependencyMode::Registry);
        assert!(out.contains("my_pkg: 1.2.3"), "got: {out}");
        assert!(
            !out.contains("my_pkg: ^1.2.3"),
            "must not add a caret range, got: {out}"
        );
    }

    #[test]
    fn render_pubspec_registry_already_prefixed_passes_through() {
        let out = render_pubspec("my_pkg", "", "^1.2.3", DependencyMode::Registry);
        assert!(out.contains("my_pkg: ^1.2.3"), "got: {out}");
        assert!(!out.contains("^^"), "must not double the prefix, got: {out}");
    }

    #[test]
    fn render_pubspec_local_uses_path_dependency() {
        let out = render_pubspec("my_pkg", "../my_pkg", "1.2.3", DependencyMode::Local);
        assert!(out.contains("my_pkg:\n    path: ../my_pkg"), "got: {out}");
    }
}