File Coverage

lib/Mojolicious/Plugin/Fondation/Resolver.pm
Criterion Covered Total %
statement 54 56 96.4
branch 14 16 87.5
condition 13 19 68.4
subroutine 5 5 100.0
pod 0 1 0.0
total 86 97 88.6


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::Fondation::Resolver;
2             $Mojolicious::Plugin::Fondation::Resolver::VERSION = '0.03';
3             # ABSTRACT: Dependency graph resolver with cycle detection and topological sort
4              
5 14     14   239264 use Mojo::Base -base, -signatures;
  14         9060  
  14         98  
6 14     14   11768 use Mojolicious::Plugin::Fondation::Utils qw(long_name short_name merge);
  14         61  
  14         10884  
7              
8             has 'app';
9             has 'states' => sub { {} }; # $long_name => 'visiting' | 'visited'
10             has 'result' => sub { [] }; # topologically sorted specs
11              
12             # ---------------------------------------------------------------------------
13             # resolve -- entry point
14             #
15             # Returns an arrayref of plugin specs in topological order (deps first).
16             # Each spec: { long => $long, short => $short, config => $merged, meta => $meta }
17             #
18             # Dies on dependency cycles with a clear message.
19             # ---------------------------------------------------------------------------
20 77     77 0 394379 sub resolve ($self, $name_or_short, $direct_conf = {}) {
  77         126  
  77         138  
  77         141  
  77         105  
21 77         277 my $long = long_name($name_or_short);
22              
23             # Reset internal state for fresh resolution
24 77         279 $self->{states} = {};
25 77         260 $self->{result} = [];
26              
27 77         279 $self->_visit($long, $direct_conf);
28              
29 72         469 return $self->{result};
30             }
31              
32             # ---------------------------------------------------------------------------
33             # _visit -- DFS traversal with 3-state cycle detection
34             #
35             # State machine:
36             # undef -> unvisited, recurse
37             # 'visiting' -> currently in DFS path -> CYCLE DETECTED
38             # 'visited' -> already resolved, skip
39             #
40             # On cycle: dies with a message naming the plugin causing the cycle.
41             # ---------------------------------------------------------------------------
42 206     206   258 sub _visit ($self, $long, $direct_conf) {
  206         260  
  206         376  
  206         282  
  206         269  
43 206         474 my $state = $self->states->{$long};
44              
45 206 100 100     978 if (defined $state && $state eq 'visiting') {
46 5         77 die "Dependency cycle detected: $long is part of a circular dependency chain.\n";
47             }
48              
49 201 100 66     440 return if defined $state && $state eq 'visited';
50              
51             # Mark as visiting -- if we encounter it again during this DFS, it's a cycle
52 180         324 $self->states->{$long} = 'visiting';
53              
54 180         765 my $meta = $self->_discover_meta($long);
55 180         1066 my $short = short_name($long);
56             my $merged = merge(
57             $direct_conf,
58             $self->app->config->{$short} // {},
59             $meta->{defaults} // {}
60 180   100     558 );
      50        
61              
62             # Resolve dependencies first (depth-first = deps before dependant)
63 180   66     15785 my $deps = $merged->{dependencies} // $meta->{dependencies} // [];
      50        
64 180         371 for my $dep_spec (@$deps) {
65 129         420 my ($dep_name, $dep_conf);
66 129 100       357 if (ref $dep_spec eq 'HASH') {
    100          
67 11         24 ($dep_name) = keys %$dep_spec;
68 11   50     32 $dep_conf = $dep_spec->{$dep_name} // {};
69             }
70             elsif (ref $dep_spec eq 'ARRAY') {
71 42         76 ($dep_name, $dep_conf) = @$dep_spec;
72 42   50     74 $dep_conf //= {};
73             }
74             else {
75 76         111 $dep_name = $dep_spec;
76 76         106 $dep_conf = {};
77             }
78 129         279 $self->_visit(long_name($dep_name), $dep_conf);
79             }
80              
81             # Mark as visited + add to result (post-order = deps added first)
82 169         672 $self->states->{$long} = 'visited';
83 169         594 push @{$self->result}, {
  169         333  
84             long => $long,
85             short => $short,
86             config => $merged,
87             meta => $meta,
88             };
89             }
90              
91             # ---------------------------------------------------------------------------
92             # _discover_meta -- load fondation_meta from a plugin class without
93             # instantiating it (i.e. without calling register).
94             # ---------------------------------------------------------------------------
95 246     246   825 sub _discover_meta ($self, $long_name) {
  246         302  
  246         312  
  246         330  
96 246         319 my $class = $long_name;
97              
98             # If the module is already loaded in %INC, do not reload it
99 246         970 my $pm_file = $class =~ s{::}{/}gr . '.pm';
100 246 100       673 if ($INC{$pm_file}) {
101 203 100       2066 return $class->can('fondation_meta')
102             ? $class->fondation_meta
103             : { dependencies => [], defaults => {} };
104             }
105              
106 43         132 my $err = Mojo::Loader::load_class($class);
107 43 50       42228 if ($err) {
108 0         0 $self->app->log->warn("Resolver: could not load $class -- $err");
109 0         0 return { dependencies => [], defaults => {} };
110             }
111              
112 43 50       591 return $class->can('fondation_meta')
113             ? $class->fondation_meta
114             : { dependencies => [], defaults => {} };
115             }
116              
117             1;
118              
119             __END__