Developer-Dashboard

 view release on metacpan or  search on metacpan

lib/Developer/Dashboard/SkillDispatcher.pm  view on Meta::CPAN

    }

    for my $item ( @{$right} ) {
        if ( ref($item) eq 'HASH' ) {
            my $identity = $item->{$field};
            if ( defined $identity && $identity ne '' ) {
                if ( exists $positions{$identity} ) {
                    $combined[ $positions{$identity} ] = $item;
                    next;
                }
                $positions{$identity} = scalar @combined;
            }
        }
        push @combined, $item;
    }

    return \@combined;
}

# _defined_or_default($value, $default)
# Supplies a default scalar only when the candidate value is undef.
# Input: candidate scalar and fallback scalar.
# Output: original scalar when defined, otherwise the fallback scalar.
sub _defined_or_default {
    my ( $self, $value, $default ) = @_;
    return defined $value ? $value : $default;
}

# get_skill_config($skill_name)
# Reads and returns a skill's configuration.
# Input: skill repo name.
# Output: hash ref with config or empty hash.
sub get_skill_config {
    my ( $self, $skill_name ) = @_;
    
    return {} if !$skill_name;

    my @skill_layers = $self->_skill_layers($skill_name);
    return {} if !@skill_layers;

    my $merged = {};
    for my $skill_path (@skill_layers) {
        my $config_file = File::Spec->catfile( $skill_path, 'config', 'config.json' );
        next if !-f $config_file;

        open( my $fh, '<', $config_file ) or return {};
        my $json_text = do { local $/; <$fh> };
        close($fh);

        my $config = eval { decode_json($json_text) } || {};
        return {} if ref($config) ne 'HASH';
        $merged = $self->_merge_skill_hashes( $merged, $config );
    }

    return $merged;
}

# config_fragment($skill_name)
# Wraps one installed skill config under its underscored runtime key for merged config use.
# Input: skill repo name.
# Output: hash ref containing one underscored skill key and its decoded config.
sub config_fragment {
    my ( $self, $skill_name ) = @_;
    return {} if !$skill_name;
    my $config = $self->get_skill_config($skill_name);
    return {} if ref($config) ne 'HASH' || !%{$config};
    return { '_' . $skill_name => $config };
}

# get_skill_path($skill_name)
# Returns the path to an installed skill.
# Input: skill repo name.
# Output: skill path string or undef.
sub get_skill_path {
    my ( $self, $skill_name ) = @_;
    
    return if !$skill_name;
    return $self->{manager}->get_skill_path($skill_name);
}

# command_path($skill_name, $command)
# Resolves one executable command within the isolated skill CLI tree.
# Input: skill repo name and command name.
# Output: executable file path string or undef.
sub command_path {
    my ( $self, $skill_name, $command ) = @_;
    return if !$skill_name || !$command;
    my $command_spec = $self->_command_spec( $skill_name, $command );
    return $command_spec ? $command_spec->{cmd_path} : undef;
}

# command_spec($skill_name, $command)
# Resolves one dotted skill command and returns the internal command
# specification used for dispatch.
# Input: skill repo name and command name.
# Output: hash reference containing cmd_path, skill_path, skill_layers, and
# command_name, or undef when the command cannot be resolved.
sub command_spec {
    my ( $self, $skill_name, $command ) = @_;
    return if !$skill_name || !$command;
    return $self->_command_spec( $skill_name, $command );
}

# command_hook_paths($skill_name, $command)
# Enumerates the skill-local hook files that would execute before one resolved
# skill command across every participating skill layer.
# Input: skill repo name and command name.
# Output: ordered list of absolute hook file paths.
sub command_hook_paths {
    my ( $self, $skill_name, $command ) = @_;
    return () if !$skill_name || !$command;
    my $command_spec = $self->_command_spec( $skill_name, $command );
    return () if !$command_spec;

    my @hooks;
    my $resolved_command = $command_spec->{command_name} || '';
    return () if $resolved_command eq '';

    for my $layer_path ( @{ $command_spec->{skill_layers} || [] } ) {
        my $hooks_dir = File::Spec->catdir( $layer_path, 'cli', "$resolved_command.d" );
        next if !-d $hooks_dir;

lib/Developer/Dashboard/SkillDispatcher.pm  view on Meta::CPAN


# resolve_ajax_route_path($path)
# Resolves one canonical or alias custom ajax route path across installed skills.
# Input: absolute request path string.
# Output: normalized route spec hash reference or undef when no custom ajax route matches.
sub resolve_ajax_route_path {
    my ( $self, $path ) = @_;
    my $spec = $self->resolve_custom_route_path($path);
    return if !$spec || ( $spec->{kind} || '' ) ne 'ajax';
    return $spec;
}

# _skill_routes_for($skill_name, $kind)
# Loads and merges config/routes.json metadata for one layered skill and route kind.
# Input: skill repository name string and route kind string.
# Output: hash reference keyed by relative route target with normalized route specs.
sub _skill_routes_for {
    my ( $self, $skill_name, $kind ) = @_;
    return {} if !$skill_name || !$kind;
    my %routes;
    my %claimed_paths;

    for my $skill_path ( $self->_skill_lookup_roots($skill_name) ) {
        my $routes_file = File::Spec->catfile( $skill_path, 'config', 'routes.json' );
        next if !-f $routes_file;
        my $payload = $self->_load_skill_routes_file($routes_file);
        my $kind_routes = $payload->{$kind} || {};
        for my $target ( sort keys %{$kind_routes} ) {
            next if exists $routes{$target};
            my $spec = $self->_normalize_skill_route_spec(
                kind       => $kind,
                skill_name => $skill_name,
                target     => $target,
                routes_file => $routes_file,
                spec       => $kind_routes->{$target},
            );
            for my $route_path ( $spec->{path}, @{ $spec->{aliases} || [] } ) {
                next if !defined $route_path || $route_path eq '';
                die "Duplicate $kind route path '$route_path' in skill '$skill_name'"
                  if $claimed_paths{$route_path}++;
            }
            $routes{$target} = $spec;
        }
    }

    return \%routes;
}

# _skill_ajax_routes_for($skill_name)
# Backward-compatible wrapper for the ajax-specific custom route map loader.
# Input: skill repository name string.
# Output: hash reference keyed by ajax file path.
sub _skill_ajax_routes_for {
    my ( $self, $skill_name ) = @_;
    return $self->_skill_routes_for( $skill_name, 'ajax' );
}

# _load_skill_routes_file($routes_file)
# Parses one config/routes.json file and validates the top-level schema.
# Input: absolute routes.json file path.
# Output: decoded hash reference.
sub _load_skill_routes_file {
    my ( $self, $routes_file ) = @_;
    open my $fh, '<', $routes_file or die "Unable to read $routes_file: $!";
    local $/;
    my $json_text = <$fh>;
    close $fh;
    my $payload = eval { decode_json($json_text) };
    die "Invalid JSON in $routes_file: $@" if $@;
    die "$routes_file must contain a JSON object" if ref($payload) ne 'HASH';
    my @non_version_keys = grep { $_ ne 'version' } keys %{$payload};
    my $has_flat_keys = grep { m{\A/} } @non_version_keys;
    my $has_typed_keys = grep { /^(?:app|ajax|js|css|others)$/ } @non_version_keys;
    die "$routes_file must not mix flat custom-path routes with typed route sections"
      if $has_flat_keys && $has_typed_keys;
    if ($has_flat_keys) {
        my @invalid = grep { $_ !~ m{\A/} } @non_version_keys;
        die "$routes_file flat routes must use absolute custom-path keys"
          if @invalid;
        return $self->_expand_flat_skill_routes_payload( $routes_file, $payload );
    }
    my @unknown = grep { $_ !~ /^(?:app|ajax|js|css|others)$/ } @non_version_keys;
    die "$routes_file contains unsupported top-level keys: @unknown"
      if @unknown;
    die "$routes_file version must be 1" if exists $payload->{version} && ( $payload->{version} || 0 ) != 1;
    for my $kind (qw(app ajax js css others)) {
        if ( exists $payload->{$kind} ) {
            die "$routes_file $kind must be a JSON object" if ref( $payload->{$kind} ) ne 'HASH';
        }
        else {
            $payload->{$kind} = {};
        }
    }
    return $payload;
}

# _expand_flat_skill_routes_payload($routes_file, $payload)
# Converts the flat custom-path config/routes.json schema into the internal typed route map.
# Input: absolute routes.json file path and decoded payload hash reference.
# Output: normalized payload hash reference with app/ajax/js/css/others maps.
sub _expand_flat_skill_routes_payload {
    my ( $self, $routes_file, $payload ) = @_;
    my %expanded = map { $_ => {} } qw(app ajax js css others);
    my %claimed_targets;
    for my $route_path ( sort grep { $_ ne 'version' } keys %{$payload} ) {
        die "$routes_file route path '$route_path' must start with /"
          if $route_path !~ m{\A/};
        my $route = $payload->{$route_path};
        my ( $to, $type );
        if ( !ref($route) ) {
            $to = $route;
        }
        elsif ( ref($route) eq 'HASH' ) {
            my @unknown = grep { $_ ne 'to' && $_ ne 'type' } keys %{$route};
            die "$routes_file route path '$route_path' contains unsupported keys: @unknown"
              if @unknown;
            $to   = $route->{to};
            $type = $route->{type};
        }
        else {
            die "$routes_file route path '$route_path' must map to a string or JSON object";
        }
        die "$routes_file route path '$route_path' must map to a non-empty route target"
          if !defined $to || ref($to) || $to eq '';
        my ( $kind, $target ) = $to =~ m{\A/(ajax|app|js|css|others)/(.*)\z};
        die "$routes_file route path '$route_path' must map to /ajax/, /app/, /js/, /css/, or /others/"
          if !$kind;
        die "$routes_file route path '$route_path' target must not be empty"
          if !defined $target || $target eq '';
        die "$routes_file route path '$route_path' type must be a scalar"
          if defined $type && ref($type);
        die "$routes_file route path '$route_path' type must not be empty"
          if defined $type && $type eq '';
        die "$routes_file route path '$route_path' does not allow type for /app targets"
          if defined $type && $kind eq 'app';
        die "$routes_file route path '$route_path' does not allow type for /js targets"
          if defined $type && $kind eq 'js';
        die "$routes_file route path '$route_path' does not allow type for /css targets"
          if defined $type && $kind eq 'css';
        die "Duplicate $kind route target '/$kind/$target' in $routes_file"
          if $claimed_targets{"$kind:$target"}++;
        $type = 'json' if !defined $type && $kind eq 'ajax';
        $expanded{$kind}{$target} = {
            path => $route_path,
            ( defined $type ? ( type => $type ) : () ),
        };
    }
    return \%expanded;
}

# _normalize_skill_route_spec(%args)
# Validates and normalizes one config/routes.json route entry.
# Input: kind, skill_name, target, routes_file, and raw spec hash reference.
# Output: normalized route spec hash reference.
sub _normalize_skill_route_spec {
    my ( $self, %args ) = @_;
    my $kind = $args{kind} || die 'Missing kind';
    my $skill_name = $args{skill_name};
    my $target = $args{target} || die 'Missing target';



( run in 0.543 second using v1.01-cache-2.11-cpan-600a1bdf6e4 )