Module: Mutineer::Pairing

Defined in:
lib/mutineer/pairing.rb

Overview

Source -> test pairing by path convention (#11). Pure stdlib path logic: no Rails, no class loading, no process. Two jobs:

* expand_sources — a directory argument becomes its sorted **/*.rb files.
* infer_test     — a source's test file by convention (app/ and lib/
                sources map to test/.../_test.rb, test/.../test_*.rb
                or spec/.../_spec.rb), preserving namespaced
                subdirectories. First EXISTING candidate wins.

Independently unit-testable: every method is pure in/out over the filesystem, so the pairing contract is exercised with plain fixtures, no Rails, no fork.

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.candidates(base, lib, prefer) ⇒ Array<String>

Ordered candidate test paths: test.rb, then Minitest's test*.rb, then _spec.rb. lib/ sources also get the test/lib/... and spec/lib/... layouts.

Parameters:

  • base (String) —

    logical source path without extension.

  • lib (Boolean) —

    whether the source originated from lib/.

  • prefer (String) —

    preferred framework name.

Returns:

  • (Array<String>) —

    candidate test paths in preferred order.



78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/mutineer/pairing.rb', line 78

def candidates(base, lib, prefer)
  minitest = ["test/#{base}_test.rb"]
  minitest << "test/lib/#{base}_test.rb" if lib
  unless File.basename(base) == "helper" # test/test_helper.rb is Minitest's support file, not a test
    prefixed = base.sub(%r{[^/]+\z}) { |name| "test_#{name}" }
    minitest << "test/#{prefixed}.rb"
    minitest << "test/lib/#{prefixed}.rb" if lib
  end
  rspec = ["spec/#{base}_spec.rb"]
  rspec << "spec/lib/#{base}_spec.rb" if lib
  prefer == "rspec" ? rspec + minitest : minitest + rspec
end

.expand_sources(args, project_root:) ⇒ Array<String>

Expand each positional source: a directory -> its sorted **/*.rb files (relative to project_root); a file (or glob, or anything non-directory) -> itself. Flattened, deduped, order-stable.

Parameters:

  • args (Array<String>) —

    source paths or directories.

  • project_root (String) —

    repository root for relative expansion.

Returns:

  • (Array<String>) —

    flattened, deduped source file list.



25
26
27
28
29
30
31
32
33
34
35
# File 'lib/mutineer/pairing.rb', line 25

def expand_sources(args, project_root:)
  root = File.expand_path(project_root)
  Array(args).flat_map do |arg|
    abs = File.expand_path(arg, root)
    if File.directory?(abs)
      Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| f.delete_prefix("#{root}/") }
    else
      [arg]
    end
  end.uniq
end

.infer_test(source_rel, project_root:, prefer: "minitest") ⇒ String?

The first EXISTING candidate test path for a source (relative to project_root), or nil. prefer is the resolved framework ("minitest" | "rspec"): its candidates are tried first, the other framework's as fallback, so a minitest default still finds a spec and vice-versa.

Parameters:

  • source_rel (String) —

    relative source path.

  • project_root (String) —

    repository root for existence checks.

  • prefer (String) (defaults to: "minitest") —

    preferred framework name.

Returns:

  • (String, nil) —

    existing test path or nil.



46
47
48
49
50
51
# File 'lib/mutineer/pairing.rb', line 46

def infer_test(source_rel, project_root:, prefer: "minitest")
  base, lib = logical_path(source_rel)
  candidates(base, lib, prefer).find do |rel|
    File.exist?(File.expand_path(rel, project_root))
  end
end

.logical_path(source_rel) ⇒ Array(String, Boolean)

Strip the source root to a logical path (no ".rb") and flag lib/ sources. app/foo/bar.rb and lib/foo/bar.rb both -> "foo/bar"; anything else -> the path minus ".rb" (still attempted). Namespaced subdirs are preserved verbatim — structural, never constant resolution.

Parameters:

  • source_rel (String) —

    relative source path.

Returns:

  • (Array(String, Boolean)) —

    logical base path and whether it came from lib/.



60
61
62
63
64
65
66
67
68
69
# File 'lib/mutineer/pairing.rb', line 60

def logical_path(source_rel)
  no_ext = source_rel.sub(/\.rb\z/, "")
  if no_ext.start_with?("app/")
    [no_ext.sub(%r{\Aapp/}, ""), false]
  elsif no_ext.start_with?("lib/")
    [no_ext.sub(%r{\Alib/}, ""), true]
  else
    [no_ext, false]
  end
end

Instance Method Details

#candidates(base, lib, prefer) ⇒ Array<String> (private)

Ordered candidate test paths: test.rb, then Minitest's test*.rb, then _spec.rb. lib/ sources also get the test/lib/... and spec/lib/... layouts.

Parameters:

  • base (String) —

    logical source path without extension.

  • lib (Boolean) —

    whether the source originated from lib/.

  • prefer (String) —

    preferred framework name.

Returns:

  • (Array<String>) —

    candidate test paths in preferred order.



78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/mutineer/pairing.rb', line 78

def candidates(base, lib, prefer)
  minitest = ["test/#{base}_test.rb"]
  minitest << "test/lib/#{base}_test.rb" if lib
  unless File.basename(base) == "helper" # test/test_helper.rb is Minitest's support file, not a test
    prefixed = base.sub(%r{[^/]+\z}) { |name| "test_#{name}" }
    minitest << "test/#{prefixed}.rb"
    minitest << "test/lib/#{prefixed}.rb" if lib
  end
  rspec = ["spec/#{base}_spec.rb"]
  rspec << "spec/lib/#{base}_spec.rb" if lib
  prefer == "rspec" ? rspec + minitest : minitest + rspec
end

#expand_sources(args, project_root:) ⇒ Array<String> (private)

Expand each positional source: a directory -> its sorted **/*.rb files (relative to project_root); a file (or glob, or anything non-directory) -> itself. Flattened, deduped, order-stable.

Parameters:

  • args (Array<String>) —

    source paths or directories.

  • project_root (String) —

    repository root for relative expansion.

Returns:

  • (Array<String>) —

    flattened, deduped source file list.



25
26
27
28
29
30
31
32
33
34
35
# File 'lib/mutineer/pairing.rb', line 25

def expand_sources(args, project_root:)
  root = File.expand_path(project_root)
  Array(args).flat_map do |arg|
    abs = File.expand_path(arg, root)
    if File.directory?(abs)
      Dir.glob(File.join(abs, "**", "*.rb")).sort.map { |f| f.delete_prefix("#{root}/") }
    else
      [arg]
    end
  end.uniq
end

#infer_test(source_rel, project_root:, prefer: "minitest") ⇒ String? (private)

The first EXISTING candidate test path for a source (relative to project_root), or nil. prefer is the resolved framework ("minitest" | "rspec"): its candidates are tried first, the other framework's as fallback, so a minitest default still finds a spec and vice-versa.

Parameters:

  • source_rel (String) —

    relative source path.

  • project_root (String) —

    repository root for existence checks.

  • prefer (String) (defaults to: "minitest") —

    preferred framework name.

Returns:

  • (String, nil) —

    existing test path or nil.



46
47
48
49
50
51
# File 'lib/mutineer/pairing.rb', line 46

def infer_test(source_rel, project_root:, prefer: "minitest")
  base, lib = logical_path(source_rel)
  candidates(base, lib, prefer).find do |rel|
    File.exist?(File.expand_path(rel, project_root))
  end
end

#logical_path(source_rel) ⇒ Array(String, Boolean) (private)

Strip the source root to a logical path (no ".rb") and flag lib/ sources. app/foo/bar.rb and lib/foo/bar.rb both -> "foo/bar"; anything else -> the path minus ".rb" (still attempted). Namespaced subdirs are preserved verbatim — structural, never constant resolution.

Parameters:

  • source_rel (String) —

    relative source path.

Returns:

  • (Array(String, Boolean)) —

    logical base path and whether it came from lib/.



60
61
62
63
64
65
66
67
68
69
# File 'lib/mutineer/pairing.rb', line 60

def logical_path(source_rel)
  no_ext = source_rel.sub(/\.rb\z/, "")
  if no_ext.start_with?("app/")
    [no_ext.sub(%r{\Aapp/}, ""), false]
  elsif no_ext.start_with?("lib/")
    [no_ext.sub(%r{\Alib/}, ""), true]
  else
    [no_ext, false]
  end
end