alef 0.62.12

Opinionated polyglot binding generator for Rust libraries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
use crate::core::hash::{self, CommentStyle};
use crate::core::template_versions as tv;
use crate::core::version::to_rubygems_prerelease;
use crate::e2e::config::E2eConfig;
use crate::e2e::escape::ruby_string_literal;
use crate::e2e::fixture::FixtureGroup;
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;

/// Render environment variable setup lines for spec_helper.rb.
/// Returns empty string if env is empty, otherwise returns alphabetically-sorted
/// ENV[k] ||= v assignments, each on its own line.
pub(super) fn render_env_setup(env: &HashMap<String, String>) -> String {
    if env.is_empty() {
        return String::new();
    }

    let mut out = String::new();
    let mut sorted_keys: Vec<_> = env.keys().collect();
    sorted_keys.sort();

    for key in sorted_keys {
        let value = &env[key];
        let _ = writeln!(out, "ENV[{key:?}] ||= {value:?}");
    }

    out
}

/// Build a Ruby-native middleware value for app harness fixtures.
///
/// Server-pattern app_harness generation moved to a consumer extension;
/// retained for alef's own ruby codegen tests.
#[allow(dead_code)]
pub(super) fn build_middleware_value(middleware: &Option<crate::e2e::fixture::HttpMiddleware>) -> serde_json::Value {
    let Some(mw) = middleware else {
        return serde_json::Value::Null;
    };

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

    // --- cors ---
    if let Some(cors) = &mw.cors {
        let mut cors_map = serde_json::Map::new();
        cors_map.insert("allowed_origins".to_string(), serde_json::json!(cors.allow_origins));
        cors_map.insert("allowed_methods".to_string(), serde_json::json!(cors.allow_methods));
        cors_map.insert("allowed_headers".to_string(), serde_json::json!(cors.allow_headers));
        if !cors.expose_headers.is_empty() {
            cors_map.insert("expose_headers".to_string(), serde_json::json!(cors.expose_headers));
        }
        if let Some(max_age) = cors.max_age {
            cors_map.insert("max_age".to_string(), serde_json::json!(max_age));
        }
        if cors.allow_credentials {
            cors_map.insert("allow_credentials".to_string(), serde_json::json!(true));
        }
        map.insert("cors".to_string(), serde_json::Value::Object(cors_map));
    }

    if map.is_empty() {
        serde_json::Value::Null
    } else {
        serde_json::Value::Object(map)
    }
}

/// Server-pattern app_harness generation moved to a consumer extension;
/// retained for alef's own ruby codegen tests.
#[allow(dead_code)]
pub(super) fn render_app_harness(e2e_config: &E2eConfig, groups: &[FixtureGroup]) -> 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 fixture.http.is_none() {
                continue;
            }
            // Convert the fixture to JSON for the harness to load.
            let http_data = &fixture.http.as_ref().unwrap();
            let middleware_value = build_middleware_value(&http_data.handler.middleware);
            let fixture_json = serde_json::json!({
                "http": {
                    "handler": {
                        "route": &http_data.handler.route,
                        "method": &http_data.handler.method,
                        "body_schema": http_data.handler.body_schema.clone(),
                        "middleware": middleware_value,
                    },
                    "request": {
                        "path": &http_data.request.path,
                    },
                    "expected_response": {
                        "status_code": http_data.expected_response.status_code,
                        "body": &http_data.expected_response.body,
                        "headers": &http_data.expected_response.headers,
                    }
                }
            });
            fixtures_map.insert(fixture.id.clone(), fixture_json);
        }
    }

    let fixtures_json_raw = serde_json::to_string(&fixtures_map).unwrap_or_default();
    // Escape the JSON for safe embedding in a Ruby string literal
    let fixtures_json = ruby_string_literal(&fixtures_json_raw);

    // Apply language-specific overrides for Ruby
    let imports = e2e_config
        .harness
        .imports_for_lang("ruby")
        .into_iter()
        .collect::<Vec<_>>();
    let imports_ref = if !imports.is_empty() {
        &imports
    } else {
        &e2e_config.harness.imports
    };

    let app_class_override = e2e_config.harness.app_class_for_lang("ruby");
    let app_class_str = if let Some(ref ac) = app_class_override {
        ac.as_str()
    } else if let Some(ref ac) = e2e_config.harness.app_class {
        ac.as_str()
    } else {
        ""
    };

    // Ruby method names are snake_case by convention. `register_method_idiomatic`
    // preserves snake_case verbatim for ruby and applies any per-language override.
    let register_route_method = e2e_config
        .harness
        .register_method_idiomatic("ruby")
        .unwrap_or_else(|| "register_route".to_string());
    let register_route_method_str = register_route_method.as_str();

    let body_schema_setter = &e2e_config.harness.body_schema_setter;
    let method_enum = &e2e_config.harness.method_enum;

    let run_method_override = e2e_config.harness.run_method_for_lang("ruby");
    let run_method_str = if let Some(ref rm) = run_method_override {
        rm.as_str()
    } else if let Some(ref rm) = e2e_config.harness.run_method {
        rm.as_str()
    } else {
        "run"
    };
    let host = &e2e_config.harness.host;
    let port = e2e_config.harness.port;

    let header = hash::header(CommentStyle::Hash);

    // Derive Ruby-namespaced class names from imports[0] when explicit values are not configured.
    // E.g. imports[0] = "my_pkg" → module prefix "MyPkg::" → "MyPkg::Method", "MyPkg::App", etc.
    let module_prefix = if !imports_ref.is_empty() {
        format!("{}::", super::values::ruby_module_name(&imports_ref[0]))
    } else {
        String::new()
    };
    let method_enum_module = method_enum
        .as_deref()
        .map(str::to_string)
        .unwrap_or_else(|| format!("{module_prefix}Method"));
    let derived_app_class = format!("{module_prefix}App");
    let derived_route_builder_class = format!("{module_prefix}RouteBuilder");
    let derived_server_config_class = format!("{module_prefix}ServerConfig");

    let ctx = minijinja::context! {
        header => header,
        imports => imports_ref,
        app_class => if !app_class_str.is_empty() { app_class_str } else { derived_app_class.as_str() },
        route_builder_class => derived_route_builder_class.as_str(),
        server_config_class => derived_server_config_class.as_str(),
        route_builder_schema_setter => body_schema_setter.as_deref().unwrap_or("request_schema_json"),
        method_enum_module => method_enum_module,
        register_route_method => register_route_method_str,
        run_method => run_method_str,
        response_body_field => e2e_config.harness.response_body_field.as_str(),
        host => host,
        port => port,
        fixtures_json => fixtures_json,
    };

    crate::e2e::template_env::render("ruby/app_harness.rb.jinja", ctx)
}

pub(super) fn render_gemfile(
    gem_name: &str,
    gem_path: &str,
    gem_version: &str,
    dep_mode: crate::e2e::config::DependencyMode,
) -> String {
    let gem_line = match dep_mode {
        crate::e2e::config::DependencyMode::Registry => {
            // This manifest exists to verify one specific published artifact, so pin
            // it exactly rather than a pessimistic `~>` range — a range would let a
            // later publish silently swap what's under test. A bare version string in
            // a Gemfile is already an exact RubyGems requirement (`= x.y.z`), so no
            // operator is needed. If alef.toml already supplies a rubygems operator
            // (`~>`, `>=`, `==`, etc.) that's an explicit escape hatch and passes
            // through unchanged. The dash-form prerelease renderer still runs either
            // way, since RubyGems rejects cargo's `-rc.2` syntax outright. ~keep
            let trimmed = gem_version.trim_start();
            let constraint = if trimmed.starts_with(['~', '>', '<', '=', '!']) {
                gem_version.to_string()
            } else {
                to_rubygems_prerelease(gem_version)
            };
            format!("gem '{gem_name}', '{constraint}'")
        }
        crate::e2e::config::DependencyMode::Local => format!("gem '{gem_name}', path: '{gem_path}'"),
    };
    crate::e2e::template_env::render(
        "ruby/Gemfile.jinja",
        minijinja::context! {
            gem_line => gem_line,
            rspec => tv::gem::RSPEC_E2E,
            rubocop => tv::gem::RUBOCOP_E2E,
            rubocop_rspec => tv::gem::RUBOCOP_RSPEC_E2E,
            faraday => tv::gem::FARADAY,
        },
    )
}

#[allow(clippy::too_many_arguments)]
pub(super) fn render_spec_helper(
    has_file_fixtures: bool,
    has_mock_server_fixtures: bool,
    uses_harness: bool,
    test_documents_path: &str,
    _gem_name: &str,
    module_path: &str,
    harness_host: &str,
    _harness_port: u16,
    env: &HashMap<String, String>,
) -> String {
    let header = hash::header(CommentStyle::Hash);
    let mut out = header;
    out.push_str("# frozen_string_literal: true\n");
    let _module_name = super::values::ruby_module_name(module_path);

    // Environment variables: set at suite start, before any require or spec config
    let env_setup = render_env_setup(env);
    if !env_setup.is_empty() {
        let _ = writeln!(out);
        out.push_str(&env_setup);
    }

    // Note: spec_helper.rb may contain library-specific registry cleanup hooks
    // (e.g., tracking plugin backends, clearing test-prefixed stubs between tests).
    // These are left for the consuming library to add—alef spec_helper is generic
    // and includes only universal setup patterns (file paths, mock servers, harness).

    if has_file_fixtures {
        let _ = writeln!(out);
        let _ = writeln!(
            out,
            "# Change to the configured test-documents directory so that fixture file paths like"
        );
        let _ = writeln!(
            out,
            "# \"pdf/fake_memo.pdf\" resolve correctly when running rspec from e2e/ruby/."
        );
        let _ = writeln!(
            out,
            "# spec_helper.rb lives in e2e/ruby/spec/; the fixtures dir resolves three directories up."
        );
        let _ = writeln!(
            out,
            "_test_documents = File.expand_path('{test_documents_path}', __dir__)"
        );
        let _ = writeln!(out, "Dir.chdir(_test_documents) if Dir.exist?(_test_documents)");
    }

    if uses_harness {
        let _ = writeln!(out);
        let _ = writeln!(out, "require 'socket'");
        let _ = writeln!(out, "require 'open3'");
        let _ = writeln!(out, "require 'timeout'");
        let _ = writeln!(out);
        let harness_setup = format!(
            r#"# Spawn the app harness for server-pattern e2e tests.
# If SUT_URL is already set, a parent process started a shared harness.
# Use it as-is and do NOT spawn our own.
RSpec.configure do |config|
  config.before(:suite) do
    next if ENV['SUT_URL'] && !ENV['SUT_URL'].empty?
    harness_bin = File.expand_path('../app_harness.rb', __dir__)
    unless File.exist?(harness_bin)
      raise "app_harness.rb not found at #{{harness_bin}}"
    end
    # Spawn the harness and read its stdout to extract the dynamic port.
    @_harness_stdin, @_harness_stdout, @_harness_stderr, @_harness_thread = Open3.popen3('ruby', harness_bin)
    @_harness_pid = @_harness_thread.pid
    harness_port = nil
    deadline = Time.now + 15.0
    # Read stdout, collecting all HARNESS_PORT lines. The harness retries on bind
    # failure, so we may see multiple ports. Keep the latest one and verify it's reachable.
    latest_port = nil
    while Time.now < deadline
      if @_harness_thread.status.nil?
        # Process died; use the latest port if available
        harness_port = latest_port if latest_port
        break
      end
      begin
        Timeout.timeout(0.1) do
          line = @_harness_stdout.readline
          if line =~ /^HARNESS_PORT=(\d+)/
            latest_port = $1.to_i
          end
        end
      rescue Timeout::Error, EOFError, Errno::EAGAIN
        # Try to verify the latest port if we have one
        if latest_port
          begin
            TCPSocket.new('{}', latest_port).close
            harness_port = latest_port
            break  # Success: port is reachable
          rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
            # Port not yet listening; keep polling
            sleep(0.05)
          end
        else
          sleep(0.05)
        end
      end
    end
    unless harness_port
      Process.kill('TERM', @_harness_pid) rescue nil
      msg = latest_port ? "App harness did not become reachable on {}:#{{latest_port}} within 15s" : "App harness did not report port within 15s"
      raise msg
    end
    url = "http://{}:#{{harness_port}}"
    ENV['SUT_URL'] = url
  end

  config.after(:suite) do
    if @_harness_pid
      Process.kill('TERM', @_harness_pid) rescue nil
      Process.wait(@_harness_pid, 5) rescue nil
    end
  end
end
"#,
            harness_host, harness_host, harness_host,
        );
        out.push_str(&harness_setup);
    } else if has_mock_server_fixtures {
        let mock_server_block =
            crate::e2e::template_env::render("ruby/spec_helper_mock_server.rb.jinja", minijinja::context! {});
        out.push_str(&mock_server_block);
    }

    out
}

pub(super) fn render_rubocop_yaml() -> String {
    crate::e2e::template_env::render("ruby/rubocop.yml.jinja", minijinja::context! {})
}

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

    // `render_gemfile` returns the *whole* rendered Gemfile, including dev
    // dependencies (rspec/rubocop/rubocop-rspec) that legitimately use `~>`
    // pessimistic ranges from `tv::gem::*_E2E`. Scan for the target gem's own
    // line rather than the full output, or those dev-dep pins always trip a
    // whole-file `~>` check regardless of what the target gem's line says. ~keep
    fn target_gem_line<'a>(out: &'a str, gem_name: &str) -> &'a str {
        out.lines()
            .find(|line| line.contains(&format!("'{gem_name}'")))
            .unwrap_or_else(|| panic!("no line for gem '{gem_name}' in:\n{out}"))
    }

    #[test]
    fn render_gemfile_registry_uses_exact_pin() {
        let out = render_gemfile("my_gem", "", "1.2.3", DependencyMode::Registry);
        assert_eq!(
            target_gem_line(&out, "my_gem"),
            "gem 'my_gem', '1.2.3'",
            "must not add a pessimistic range, got: {out}"
        );
    }

    #[test]
    fn render_gemfile_registry_prerelease_uses_rubygems_pre_form() {
        let out = render_gemfile("my_gem", "", "1.8.0-rc.2", DependencyMode::Registry);
        assert_eq!(
            target_gem_line(&out, "my_gem"),
            "gem 'my_gem', '1.8.0.pre.rc.2'",
            "must not add a pessimistic range, got: {out}"
        );
    }

    #[test]
    fn render_gemfile_registry_already_prefixed_passes_through() {
        let out = render_gemfile("my_gem", "", "~> 1.2.3", DependencyMode::Registry);
        assert!(out.contains("gem 'my_gem', '~> 1.2.3'"), "got: {out}");
    }

    #[test]
    fn render_gemfile_local_uses_path_dependency() {
        let out = render_gemfile("my_gem", "../my_gem", "1.2.3", DependencyMode::Local);
        assert!(out.contains("gem 'my_gem', path: '../my_gem'"), "got: {out}");
    }
}