initial version with mediawiki produce/parse, three reports, missing update-merger
Robert 'phaylon' Sedlacek [Mon, 23 Jul 2012 18:38:27 +0000 (18:38 +0000)]
19 files changed:
Makefile.PL [new file with mode: 0644]
bin/system-introspector-report [new file with mode: 0755]
lib/System/Introspector/Report.pm [new file with mode: 0644]
lib/System/Introspector/Report/Builder.pm [new file with mode: 0644]
lib/System/Introspector/Report/Builder/Packages/Apt/API.pm [new file with mode: 0644]
lib/System/Introspector/Report/Builder/Packages/Apt/ByHost.pm [new file with mode: 0644]
lib/System/Introspector/Report/Builder/Packages/Apt/ByPackage.pm [new file with mode: 0644]
lib/System/Introspector/Report/Builder/Perls.pm [new file with mode: 0644]
lib/System/Introspector/Report/Config.pm [new file with mode: 0644]
lib/System/Introspector/Report/Publish/API.pm [new file with mode: 0644]
lib/System/Introspector/Report/Publish/MediaWiki.pm [new file with mode: 0644]
lib/System/Introspector/Report/Publish/MediaWiki/Connection.pm [new file with mode: 0644]
lib/System/Introspector/Report/Publish/MediaWiki/Page.pm [new file with mode: 0644]
lib/System/Introspector/Report/Publish/MediaWiki/Parser.pm [new file with mode: 0644]
lib/System/Introspector/Report/Publish/MediaWiki/Producer.pm [new file with mode: 0644]
lib/System/Introspector/Report/Publish/StdOut.pm [new file with mode: 0644]
lib/System/Introspector/Report/Source.pm [new file with mode: 0644]
maint/Makefile.PL.include [new file with mode: 0644]
t/publish_mediawiki_parser.t [new file with mode: 0644]

diff --git a/Makefile.PL b/Makefile.PL
new file mode 100644 (file)
index 0000000..1c30f73
--- /dev/null
@@ -0,0 +1,28 @@
+use strict;
+use warnings FATAL => 'all';
+use ExtUtils::MakeMaker;
+
+(do 'maint/Makefile.PL.include' or die $@) unless -f 'META.yml';
+
+WriteMakefile(
+  NAME => 'System-Introspector-Report',
+  VERSION_FROM => 'lib/System/Introspector/Report.pm',
+  PREREQ_PM => {
+    'Moo'               => '1.000000',
+    'HTML::Zoom'        => '0.009006',
+    'Config::General'   => '2.51',
+    'Module::Runtime'   => '0.013',
+    'IO::All'           => '0.45',
+    'strictures'        => '1',
+    'JSON::PP'          => '2.53',
+    'Object::Remote'    => '0.001001',
+    'MRO::Compat'       => 0,
+    'Class::C3'         => 0,
+    'Getopt::Long'      => 0,
+    'Pod::Usage'        => 0,
+    'aliased'           => 0,
+  },
+  EXE_FILES => [
+    'bin/system-introspector-report',
+  ],
+);
diff --git a/bin/system-introspector-report b/bin/system-introspector-report
new file mode 100755 (executable)
index 0000000..deb074a
--- /dev/null
@@ -0,0 +1,44 @@
+#!/usr/bin/env perl
+use strictures;
+use Getopt::Long;
+use Pod::Usage;
+use System::Introspector::Report::Source;
+use System::Introspector::Report::Config;
+
+GetOptions(
+  's|storage=s' => \my $storage_dir,
+  'c|config=s'  => \my $config_file,
+  'r|report=s'  => \my @report_types,
+  'p|publish=s' => \my @publish_types,
+  'a|all'       => \my $all_reports,
+  'h|help'      => sub { pod2usage(0) },
+) or pod2usage(2);
+
+die "$0 requires --storage (-s) to be specified\n"
+  unless defined $storage_dir;
+
+die "$0 requires --config (-c) to be specified\n"
+  unless defined $storage_dir;
+
+my $config = System::Introspector::Report::Config
+  ->new(config_file => $config_file);
+
+my $source = System::Introspector::Report::Source
+  ->new_from_root($storage_dir);
+
+my @types = $all_reports
+  ? $config->report_types
+  : map [$_, {}], @report_types;
+my @reports = $source->generate(@types);
+
+for my $publisher ($config->publishers(@publish_types)) {
+  $publisher->publish(\@reports);
+}
+
+__END__
+
+=head1 NAME
+
+system-introspector-report - Generate System::Introspector reports
+
+=cut
diff --git a/lib/System/Introspector/Report.pm b/lib/System/Introspector/Report.pm
new file mode 100644 (file)
index 0000000..6dc0610
--- /dev/null
@@ -0,0 +1,33 @@
+package System::Introspector::Report;
+
+our $VERSION = '0.000001'; # 0.0.1
+
+$VERSION = eval $VERSION;
+
+1;
+
+=head1 NAME
+
+System::Introspector::Report - Description goes here
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+=head1 AUTHOR
+
+ Robert Sedlacek <r.sedlacek@shadowcat.co.uk>
+
+=head1 CONTRIBUTORS
+
+None yet - maybe this software is perfect! (ahahahahahahahahaha)
+
+=head1 COPYRIGHT
+
+Copyright (c) 2012 the System::Introspector::Report L</AUTHOR> and L</CONTRIBUTORS>
+as listed above.
+
+=head1 LICENSE
+
+This library is free software and may be distributed under the same terms
+as perl itself.
diff --git a/lib/System/Introspector/Report/Builder.pm b/lib/System/Introspector/Report/Builder.pm
new file mode 100644 (file)
index 0000000..4f69ab4
--- /dev/null
@@ -0,0 +1,14 @@
+package System::Introspector::Report::Builder;
+use Moo;
+
+sub required_data { () }
+
+sub collect_from {
+  die sprintf "Instance '%s' does not provide 'collect_from'\n", shift;
+}
+
+sub render_reports {
+  die sprintf "Instance '%s' does not provide 'render_reports'\n", shift;
+}
+
+1;
diff --git a/lib/System/Introspector/Report/Builder/Packages/Apt/API.pm b/lib/System/Introspector/Report/Builder/Packages/Apt/API.pm
new file mode 100644 (file)
index 0000000..3b498a5
--- /dev/null
@@ -0,0 +1,24 @@
+package System::Introspector::Report::Builder::Packages::Apt::API;
+use Moo::Role;
+
+requires qw(
+  _collect_from_data
+);
+
+sub collect_from {
+  my ($self, $id, $data) = @_;
+  my $host    = $data->{host}{hostname};
+  my $package = $data->{'packages/apt'}{installed}{packages} || {};
+  my $upgrade = $data->{'packages/apt'}{upgradable}{actions}{inst} || {};
+  return $self->_collect_from_data($id, $host, $package, $upgrade, $data);
+}
+
+sub _clear_upgrade {
+  my ($self, $string, $version) = @_;
+  return undef
+    unless defined $string;
+  $string =~ s{^\[\Q$version\E\]\s*\((.+)\)$}{$1};
+  return $string;
+}
+
+1;
diff --git a/lib/System/Introspector/Report/Builder/Packages/Apt/ByHost.pm b/lib/System/Introspector/Report/Builder/Packages/Apt/ByHost.pm
new file mode 100644 (file)
index 0000000..5c9a088
--- /dev/null
@@ -0,0 +1,65 @@
+package System::Introspector::Report::Builder::Packages::Apt::ByHost;
+use Moo;
+
+extends 'System::Introspector::Report::Builder';
+
+has _known_package => (is => 'ro', default => sub { {} });
+has _host_package  => (is => 'ro', default => sub { {} });
+has _host_upgrade  => (is => 'ro', default => sub { {} });
+
+sub required_data {
+  return qw(
+    packages/apt
+    host
+  );
+}
+
+sub _collect_from_data {
+  my ($self, $id, $hostname, $packages, $upgrade, $data) = @_;
+  for my $package (keys %$packages) {
+    $self->_known_package->{$package}++;
+  }
+  $self->_host_package->{$id} = $packages;
+  $self->_host_upgrade->{$id} = $upgrade;
+  return 1;
+}
+
+sub render_reports {
+  my ($self) = @_;
+  my @columns = (
+    { key => 'package', label => 'Package' },
+    { key => 'version', label => 'Version' },
+    { key => 'upgrade', label => 'Upgrade' },
+  );
+  my $host_package = $self->_host_package;
+  my $host_upgrade = $self->_host_upgrade;
+  my @known_packages = sort keys %{$self->_known_package};
+  return map {
+    my $remote = $_;
+    +{
+      columns => \@columns,
+      title => "Packages on $remote",
+      id => ['packages-apt-by-remote', $remote],
+      meta => { remote => $remote },
+      rows => [map {
+        my $package = $_;
+        my $version = $host_package->{$remote}{$package}{version};
+        my $upgrade = $self->_clear_upgrade(
+          $host_upgrade->{$remote}{$package},
+          $version,
+        );
+        +{
+          package => $package,
+          defined($version) ? (version => $version) : (),
+          defined($upgrade) ? (upgrade => $upgrade) : (),
+        };
+      } @known_packages],
+    };
+  } sort keys %{$self->_host_package};
+}
+
+with $_ for qw(
+  System::Introspector::Report::Builder::Packages::Apt::API
+);
+
+1;
diff --git a/lib/System/Introspector/Report/Builder/Packages/Apt/ByPackage.pm b/lib/System/Introspector/Report/Builder/Packages/Apt/ByPackage.pm
new file mode 100644 (file)
index 0000000..12c8b80
--- /dev/null
@@ -0,0 +1,63 @@
+package System::Introspector::Report::Builder::Packages::Apt::ByPackage;
+use Moo;
+
+extends 'System::Introspector::Report::Builder';
+
+has _host_by_remote => (is => 'ro', default => sub { {} });
+has _host_data      => (is => 'ro', default => sub { {} });
+
+sub required_data {
+  return qw(
+    packages/apt
+    host
+  );
+}
+
+sub _collect_from_data {
+  my ($self, $id, $hostname, $packages, $upgrades) = @_;
+  $self->_host_by_remote->{$id} = $hostname;
+  for my $package (keys %$packages) {
+    my $version = $packages->{$package}{version};
+    my $upgrade = $self->_clear_upgrade($upgrades->{$package}, $version);
+    $self->_host_data->{$package}{$id} = {
+      version => $version,
+      defined($upgrade) ? (upgrade => $upgrade) : (),
+    };
+  }
+}
+
+sub render_reports {
+  my ($self) = @_;
+  my @columns = (
+    { key => 'hostname', label => 'Hostname' },
+    { key => 'remote',   label => 'Remote' },
+    { key => 'version',  label => 'Version' },
+    { key => 'upgrade',  label => 'Upgrade' },
+  );
+  my $data = $self->_host_data;
+  my $hbr  = $self->_host_by_remote;
+  return map {
+    my $package = $_;
+    my $hosts   = $data->{$package};
+    +{
+      columns => \@columns,
+      title => "$package Installations",
+      id => ['packages-apt-by-package', $package],
+      meta => { package => $package },
+      rows => [ map {
+        my $remote  = $_;
+        +{
+          remote    => $remote,
+          hostname  => $hbr->{$remote},
+          %{$hosts->{$remote}},
+        };
+      } sort keys %$hosts ],
+    };
+  } sort keys %$data;
+}
+
+with $_ for qw(
+  System::Introspector::Report::Builder::Packages::Apt::API
+);
+
+1;
diff --git a/lib/System/Introspector/Report/Builder/Perls.pm b/lib/System/Introspector/Report/Builder/Perls.pm
new file mode 100644 (file)
index 0000000..041e33a
--- /dev/null
@@ -0,0 +1,60 @@
+package System::Introspector::Report::Builder::Perls;
+use Moo;
+
+extends 'System::Introspector::Report::Builder';
+
+has skip_errors => (is => 'ro', default => sub { 1 });
+
+has _rows => (is => 'ro', default => sub { [] });
+
+sub required_data {
+  return qw(
+    perls
+    host
+  );
+}
+
+sub collect_from {
+  my ($self, $id, $data) = @_;
+  my $perls    = $data->{perls}{perls} || {};
+  my $hostname = $data->{host}{hostname};
+  if (my @perl_keys = sort keys %$perls) {
+    for my $perl_key (@perl_keys) {
+      my $perl = $perls->{$perl_key};
+      next if defined($perl->{__error__}) and $self->skip_errors;
+      push @{$self->_rows}, {
+        hostname => $hostname,
+        remote   => $id,
+        location => $perl->{executable},
+        version  => $perl->{config}{version},
+        defined($perl->{__error__})
+          ? (__error__ => $perl->{__error__})
+          : (),
+      };
+    }
+  }
+  else {
+    push @{$self->_rows}, {
+      hostname => $hostname,
+      remote   => $id,
+    };
+  }
+  return 1;
+}
+
+sub render_reports {
+  my ($self) = @_;
+  return +{
+    title => 'Perl Installations',
+    id => 'perls',
+    columns => [
+      { key => 'remote',   label => 'Remote' },
+      { key => 'hostname', label => 'Hostname' },
+      { key => 'location', label => 'Location' },
+      { key => 'version',  label => 'Version' },
+    ],
+    rows => $self->_rows,
+  };
+}
+
+1;
diff --git a/lib/System/Introspector/Report/Config.pm b/lib/System/Introspector/Report/Config.pm
new file mode 100644 (file)
index 0000000..251cb45
--- /dev/null
@@ -0,0 +1,33 @@
+package System::Introspector::Report::Config;
+use Moo;
+use Config::General;
+use Module::Runtime qw( use_module );
+
+has config_file => (is => 'ro', required => 1);
+has config      => (is => 'lazy');
+
+sub _build_config {
+  my ($self) = @_;
+  return +{
+    Config::General->new($self->config_file)->getall,
+  };
+}
+
+sub report_types {
+  my ($self) = @_;
+  my $reports = $self->config->{report} || {};
+  return map {
+    [$_, $reports->{$_}];
+  } sort keys %$reports;
+}
+
+sub publishers {
+  my ($self, @override) = @_;
+  my $publish = $self->config->{publish} || {};
+  return map {
+    use_module("System::Introspector::Report::Publish::$_")
+      ->new($publish->{$_} || {});
+  } @override ? @override : sort keys %$publish;
+}
+
+1;
diff --git a/lib/System/Introspector/Report/Publish/API.pm b/lib/System/Introspector/Report/Publish/API.pm
new file mode 100644 (file)
index 0000000..12bef42
--- /dev/null
@@ -0,0 +1,29 @@
+package System::Introspector::Report::Publish::API;
+use Moo::Role;
+
+requires qw(
+  publish
+);
+
+sub _prepare_matcher_from {
+  my ($self, $matchers) = @_;
+  my $pattern = join '|', map {
+    my @elements = split m{:}, $_;
+    join qr{\0}, map {
+      ($_ eq '*') ? qr{[^\0]+} : qr{\Q$_\E};
+    } split m{:}, $_;
+  } ref($matchers) ? @$matchers : $matchers;
+  return qr{^(?:$pattern)$};
+}
+
+sub _match_id {
+  my ($self, $report, $match) = @_;
+  $match = $self->_prepare_matcher_from($match)
+    unless ref($match) eq 'Regexp';
+  my $id = join "\0", ref($report->{id})
+    ? @{$report->{id}}
+    : ($report->{id});
+  return $id =~ $match;
+}
+
+1;
diff --git a/lib/System/Introspector/Report/Publish/MediaWiki.pm b/lib/System/Introspector/Report/Publish/MediaWiki.pm
new file mode 100644 (file)
index 0000000..839a2e3
--- /dev/null
@@ -0,0 +1,63 @@
+package System::Introspector::Report::Publish::MediaWiki;
+use Moo;
+use aliased 'System::Introspector::Report::Publish::MediaWiki::Producer';
+use aliased 'System::Introspector::Report::Publish::MediaWiki::Connection';
+
+has page_options => (
+  is => 'ro',
+  default => sub { {} },
+  init_arg => 'page',
+);
+
+has producer => (is => 'lazy', handles => {
+  _render_page => 'render',
+});
+
+has connection => (is => 'ro', lazy => 1, builder => 1, handles => {
+  get_page => 'get',
+  put_page => 'put',
+});
+
+sub _build_producer   { Producer->new }
+sub _build_connection { Connection->new }
+
+sub publish {
+  my ($self, $reports) = @_;
+  my $pages = $self->page_options;
+  for my $page (sort keys %$pages) {
+    $self->_publish_page($reports, $page, $pages->{$page});
+  }
+  return 1;
+}
+
+sub _sort_reports {
+  my ($self, $reports, $included) = @_;
+  my @matchers = map {
+    $self->_prepare_matcher_from($_);
+  } ref($included) ? @{$included} : $included;
+  my @grouped;
+  for my $report (@$reports) {
+    my ($group_idx) = grep {
+      $self->_match_id($report, $matchers[$_]);
+    } 0 .. $#matchers;
+    if (defined $group_idx) {
+      push @{$grouped[$group_idx]}, $report;
+    }
+  }
+  return [ map { (@$_) } @grouped ];
+}
+
+sub _publish_page {
+  my ($self, $reports, $page, $options) = @_;
+  my $sorted = $self->_sort_reports($reports, $options->{report} || []);
+  print $self->_render_page($sorted);
+#  print $self->render_report($_)
+#    for @$sorted;
+  return 1;
+}
+
+with $_ for qw(
+  System::Introspector::Report::Publish::API
+);
+
+1;
diff --git a/lib/System/Introspector/Report/Publish/MediaWiki/Connection.pm b/lib/System/Introspector/Report/Publish/MediaWiki/Connection.pm
new file mode 100644 (file)
index 0000000..e0f19d6
--- /dev/null
@@ -0,0 +1,8 @@
+package System::Introspector::Report::Publish::MediaWiki::Connection;
+use Moo;
+
+sub get { }
+
+sub put { }
+
+1;
diff --git a/lib/System/Introspector/Report/Publish/MediaWiki/Page.pm b/lib/System/Introspector/Report/Publish/MediaWiki/Page.pm
new file mode 100644 (file)
index 0000000..0244eef
--- /dev/null
@@ -0,0 +1,11 @@
+package System::Introspector::Report::Publish::MediaWiki::Page;
+use Moo;
+
+has timestamp => (is => 'ro', required => 1);
+has content   => (is => 'ro', required => 1);
+
+sub update {
+  my ($self, $update_stream) = @_;
+}
+
+1;
diff --git a/lib/System/Introspector/Report/Publish/MediaWiki/Parser.pm b/lib/System/Introspector/Report/Publish/MediaWiki/Parser.pm
new file mode 100644 (file)
index 0000000..4932353
--- /dev/null
@@ -0,0 +1,173 @@
+package System::Introspector::Report::Publish::MediaWiki::Parser;
+use Moo;
+use HTML::Zoom;
+
+my $_rx_front       = qr{\A<!--\s+SI:};
+my $_rx_end         = qr{\s+-->\Z};
+my $_rx_table_begin = qr{ $_rx_front TABLE \s+ begin \s+ }x;
+my $_rx_table_end   = qr{ $_rx_front TABLE \s+ end \s+ }x;
+my $_rx_title_begin = qr{ $_rx_front TITLE \s+ begin \s+ }x;
+my $_rx_title_end   = qr{ $_rx_front TITLE \s+ end \s+ }x;
+my $_rx_any_begin   = qr{ $_rx_front (TABLE|TITLE) \s+ begin \s }x;
+my $_rx_id          = qr{(\S+)};
+
+sub parse {
+  my ($self, $body) = @_;
+  my $lines = [split m{\n}, $body];
+  my $parts = [];
+  my $state = '_parse_text';
+  while (@$lines) {
+    $state = $self->$state($lines, $parts);
+  }
+  return $parts;
+}
+
+sub _parse_table {
+  my ($self, $lines, $done) = @_;
+  my ($id, $title) = $self->_read_title($lines);
+  my $description = [];
+  $self->_parse_text($lines, $description);
+  my $report = $self->_read_report($lines, $id);
+  $report->{title} = $title;
+  $report->{description} = $description;
+  push @$done, $report;
+  return '_parse_text';
+}
+
+sub _read_report {
+  my ($self, $lines, $id) = @_;
+  shift(@$lines) =~ m{ $_rx_table_begin \Q$id\E $_rx_end }x
+    or die "Expected table for '$id' after title\n";
+  my @report_lines;
+  while (@$lines) {
+    my $line = shift @$lines;
+    if ($line =~ m{ $_rx_table_end $_rx_id $_rx_end }x) {
+      return $self->_inflate_report(join('', @report_lines), $id);
+    }
+    push @report_lines, $line;
+  }
+  die "Missing end of '$id' report\n";
+}
+
+my $_is_type = sub {
+  my ($markup, $type, $tag) = @_;
+  my $next = $markup->peek;
+  return $markup->next
+    if not($next) or (
+      ($next->{name} || '') eq $tag
+      and ($next->{type} || '') eq uc($type)
+    );
+  return undef;
+};
+
+my $_collect_content = sub {
+  my ($stream, $type) = @_;
+  my @events;
+  until ($stream->$_is_type(close => $type)) {
+    push @events, $stream->next;
+  }
+  return HTML::Zoom->from_events(\@events)->to_html;
+};
+
+my $_get_class = sub {
+  my ($event, $rx) = @_;
+  my $class = $event->{attrs}{class} || '';
+  if ($class =~ $rx) {
+    return $1;
+  }
+  return undef;
+};
+
+sub _inflate_columns {
+  my ($self, $stream) = @_;
+  my $user_col_idx = 0;
+  my @columns;
+  while (my $next = $stream->next) {
+    if (my $ev = $stream->$_is_type(open => 'th')) {
+      my $key   = $ev->$_get_class(qr{si-colhead-(\S+)});
+      my $label = $stream->$_collect_content('th');
+      push @columns, {
+        key => defined($key) ? $key : '__usercol_' . $user_col_idx++,
+        label => $label,
+        defined($key) ? () : (user => 1),
+      };
+    }
+    elsif ($stream->$_is_type(close => 'tr')) {
+      last;
+    }
+  }
+  return @columns;
+}
+
+sub _inflate_rows {
+  my ($self, $stream, $cols) = @_;
+  my @rows;
+  my $user_col_orph_idx = 0;
+  while (my $next = $stream->next) {
+    if ($stream->$_is_type(open => 'tr')) {
+      push @rows, {};
+      next;
+    }
+    if (my $ev = $stream->$_is_type(open => 'td')) {
+      my $key;
+      if ($#$cols >= scalar keys %{$rows[-1]}) {
+          $key = $cols->[scalar keys %{$rows[-1]}]{key};
+      }
+      else {
+        my $newcol = {
+          key  => "__usercol_orph_" . $user_col_orph_idx++,
+          user => 1,
+        };
+        push @$cols, $newcol;
+        $key = $newcol->{key};
+      }
+      my $value = $stream->$_collect_content('td');
+      $rows[-1]{$key} = $value;
+    }
+  }
+  return \@rows;
+}
+
+sub _inflate_report {
+  my ($self, $table, $id) = @_;
+  my $markup  = HTML::Zoom->from_html($table);
+  my $stream  = $markup->to_stream;
+  my @columns = $self->_inflate_columns($stream);
+  my $rows    = $self->_inflate_rows($stream, \@columns);
+  return {
+    id      => [split m{:}, $id],
+    columns => [@columns],
+    rows    => $rows,
+  };
+}
+
+sub _read_title {
+  my ($self, $lines) = @_;
+  my $start_line = shift @$lines;
+  $start_line =~ m{ $_rx_title_begin ($_rx_id) $_rx_end }x
+    or die "Unable to parse table start: $start_line\n";
+  my $id = $1;
+  my $title_line = shift @$lines;
+  $title_line =~ m{ \A == \s+ (.+) \s+ == \Z }x
+    or die "Unable to parse title line: $title_line\n";
+  my $title = $1;
+  my $end_line = shift(@$lines);
+  $end_line =~ m{ $_rx_title_end \Q$id\E $_rx_end }x
+    or die "Missing title end marker, found: $end_line\n";
+  return $id, $title;
+}
+
+sub _parse_text {
+  my ($self, $lines, $done) = @_;
+  while (@$lines) {
+    if ($lines->[0] =~ m{ $_rx_any_begin $_rx_id $_rx_end }x) {
+      return '_parse_table';
+    }
+    my $line = shift @$lines;
+    chomp $line;
+    push @$done, $line;
+  }
+  return undef;
+}
+
+1;
diff --git a/lib/System/Introspector/Report/Publish/MediaWiki/Producer.pm b/lib/System/Introspector/Report/Publish/MediaWiki/Producer.pm
new file mode 100644 (file)
index 0000000..fa43a43
--- /dev/null
@@ -0,0 +1,119 @@
+package System::Introspector::Report::Publish::MediaWiki::Producer;
+use Moo;
+use HTML::Zoom;
+
+sub render {
+  my ($self, $stream) = @_;
+  return join "\n", map {
+    ref($_) ? $self->_render_table($_) : $_;
+  } @$stream;
+}
+
+sub _wrap {
+  my ($self, $type, $id, $body) = @_;
+  chomp $body;
+  return join "\n",
+    sprintf('<!-- SI:%s begin %s -->', uc($type), $id),
+    $body,
+    sprintf('<!-- SI:%s end %s -->', uc($type), $id),
+}
+
+sub _render_table {
+  my ($self, $report) = @_;
+  my $id     = $report->{id};
+  my $str_id = ref($id) ? join(':', @$id) : $id;
+  my $markup = $self->_load_markup;
+  $markup = $self->_apply_identity($markup, $report);
+  $markup = $self->_apply_table_head($markup, $report);
+  $markup = $self->_apply_table_body($markup, $report);
+  my $description = $report->{description} || [''];
+  return join "\n",
+    $self->_wrap('title', $str_id, $self->_render_title($report)),
+    @$description,
+    $self->_wrap('table', $str_id, $markup->to_html),
+    '';
+}
+
+sub _render_title {
+  my ($self, $report) = @_;
+  return sprintf '== %s ==', $report->{title};
+}
+
+sub _apply_identity {
+  my ($self, $markup, $report) = @_;
+  my $id = $report->{id};
+  return $markup
+    ->select('table')
+    ->set_attribute(id => 'si-id-' . join '__',
+      ref($id) ? @$id : $id,
+    );
+}
+
+sub _apply_table_body {
+  my ($self, $markup, $report) = @_;
+  my $index = 0;
+  my $rows  = $report->{rows};
+  my @cols  = map $_->{key}, @{$report->{columns}};
+  return $markup->repeat('.data-row', sub {
+    return HTML::Zoom::CodeStream->new({
+      code => sub {
+        return if $index > $#$rows;
+        my $row = $rows->[$index++];
+        return sub {
+          $_->repeat('td', [ map {
+            my $col_idx = $_;
+            my $col = $cols[$_];
+            my $value = $row->{$col};
+            sub {
+              $_->apply_if($col !~ m{^__}, sub {
+                  $_->add_to_attribute('td', class => "si-column-$col");
+                })
+                ->replace_content('td', defined($value) ? $value : '')
+                ->apply_if($col_idx != $#cols, sub {
+                  $_->add_after('td', "\n    ");
+                });
+            };
+          } 0 .. $#cols ]);
+        };
+      },
+    });
+  });
+}
+
+sub _apply_table_head {
+  my ($self, $markup, $report) = @_;
+  my @cols = @{$report->{columns}};
+  return $markup
+    ->repeat('th', [map {
+      my $col_idx = $_;
+      my $col = $cols[$_];
+      sub {
+        $_->apply_if($col->{key} !~ m{^__}, sub {
+            $_->add_to_attribute('th', class => 'si-colhead-' . $col->{key});
+          })
+          ->replace_content('th', $col->{label})
+          ->apply_if($col_idx != $#cols, sub {
+            $_->add_after('th', "\n    ");
+          });
+      };
+    } 0 .. $#cols ])
+}
+
+sub _load_markup {
+  return HTML::Zoom->from_html(do {
+    local $/;
+    scalar <DATA>;
+  });
+}
+
+1;
+
+__DATA__
+<table class="si-report">
+  <tr>
+    <th>Header</th>
+  </tr>
+  <tr class="data-row">
+    <td>Value</td>
+  </tr>
+</table>
diff --git a/lib/System/Introspector/Report/Publish/StdOut.pm b/lib/System/Introspector/Report/Publish/StdOut.pm
new file mode 100644 (file)
index 0000000..b9c09da
--- /dev/null
@@ -0,0 +1,28 @@
+package System::Introspector::Report::Publish::StdOut;
+use Moo;
+use JSON::PP;
+
+has report => (is => 'ro');
+
+my $_json = JSON::PP->new->utf8->pretty;
+
+sub publish {
+  my ($self, $reports) = @_;
+  my $match = $self->report;
+  return unless defined $match;
+  $match = $self->_prepare_matcher_from($match);
+  for my $idx (0 .. $#$reports) {
+    my $report = $reports->[$idx];
+    next unless $self->_match_id($report, $match);
+    print $_json->encode($report), "\n";
+    print "---\n"
+      if $idx < $#$reports;
+  }
+  return 1;
+}
+
+with $_ for qw(
+  System::Introspector::Report::Publish::API
+);
+
+1;
diff --git a/lib/System/Introspector/Report/Source.pm b/lib/System/Introspector/Report/Source.pm
new file mode 100644 (file)
index 0000000..e71d075
--- /dev/null
@@ -0,0 +1,73 @@
+package System::Introspector::Report::Source;
+use Moo;
+use JSON::PP;
+use IO::All;
+use Module::Runtime qw( use_module );
+
+my $_json = JSON::PP->new->utf8->relaxed->allow_nonref;
+
+has root => (is => 'ro', required => 1);
+
+sub new_from_root {
+  my ($class, $root, @args) = @_;
+  if ($root =~ m{^(.+):(.+)$}) {
+    my ($remote, $remote_root) = ($1, $2);
+    require Object::Remote;
+    return $class->new::on($remote, @args, root => $remote_root);
+  }
+  else {
+    return $class->new(@args, root => $root);
+  }
+}
+
+sub generate {
+  my ($self, @types) = @_;
+  my @generators = map { $self->_make_generator($_) } @types;
+  my $required = +{ map { ($_ => 1) } map $_->required_data, @generators };
+  my @dirs = $self->_find_source_dirs;
+  for my $dir_spec (@dirs) {
+    my ($id, $path) = @$dir_spec;
+    my $data = $self->_load_dataset($path, $required)
+      or next;
+    $_->collect_from($id, $data)
+      for @generators;
+  }
+  return map { ($_->render_reports) } @generators;
+}
+
+sub _load_dataset {
+  my ($self, $path, $required) = @_;
+  my $root = $self->root;
+  return +{
+    map {
+      ($_->[0], $_json->decode(scalar $_->[1]->slurp) || {});
+    } grep {
+      $required->{$_->[0]};
+    } map {
+      my $key = $_;
+      $key =~ s!^\Q$root\E/*(?:[^/]+/+){2}!!;
+      $key =~ s{\.json$}{};
+      [$key, $_];
+    } grep {
+      not(m{^\.}) and m{\.json$};
+    } $path->deep->all_files
+  };
+}
+
+sub _find_source_dirs {
+  my ($self) = @_;
+  my $root = $self->root;
+  return map {
+    (my $id = $_) =~ s{^\Q$root\E/*}{};
+    [$id, $_];
+  } io($root)->all_dirs;
+}
+
+sub _make_generator {
+  my ($self, $spec) = @_;
+  my ($type, $args) = @$spec;
+  return use_module("System::Introspector::Report::Builder::$type")
+    ->new($args || {});
+}
+
+1;
diff --git a/maint/Makefile.PL.include b/maint/Makefile.PL.include
new file mode 100644 (file)
index 0000000..05af156
--- /dev/null
@@ -0,0 +1,8 @@
+BEGIN {
+  -e 'Distar'
+    or system("git clone git://git.shadowcat.co.uk/p5sagit/Distar.git")
+}
+use lib 'Distar/lib';
+use Distar;
+
+author 'Robert Sedlacek <r.sedlacek@shadowcat.co.uk>';
diff --git a/t/publish_mediawiki_parser.t b/t/publish_mediawiki_parser.t
new file mode 100644 (file)
index 0000000..e02c6b2
--- /dev/null
@@ -0,0 +1,84 @@
+use strictures 1;
+use Test::More;
+
+use aliased 'System::Introspector::Report::Publish::MediaWiki::Parser';
+
+my $parser = Parser->new;
+my $stream = $parser->parse(<<'EOR');
+
+= Heading =
+
+<!-- SI:TITLE begin foo -->
+== Foo ==
+<!-- SI:TITLE end foo -->
+
+User description
+
+<!-- SI:TABLE begin foo -->
+<table class="si-report">
+  <tr>
+    <th class="si-colhead-bar">Bar</th>
+    <th class="si-colhead-baz">Baz</th>
+    <th>
+        Qux
+    </th>
+  </tr>
+  <tr>
+    <td class="si-column-bar">231</td>
+    <td class="si-column-baz">421</td>
+    <td>171</td>
+  </tr>
+  <tr>
+    <td class="si-column-bar">232</td>
+    <td class="si-column-baz">422</td>
+    <td>172</td>
+    <td>173</td>
+  </tr>
+</table>
+<!-- SI:TABLE end foo -->
+
+End Text
+
+EOR
+
+is shift(@$stream), '', 'empty line';
+is shift(@$stream), '= Heading =', 'heading';
+is shift(@$stream), '', 'empty line';
+
+do {
+  my $report = shift @$stream;
+  is ref($report), 'HASH', 'report structure';
+
+  is_deeply $report->{id}, ['foo'], 'report id';
+  is $report->{title}, 'Foo', 'report title';
+
+  is $report->{columns}[0]{key}, 'bar', 'first column key';
+  is $report->{columns}[0]{label}, 'Bar', 'first column label';
+  ok not($report->{columns}[0]{user}), 'first column not user supplied';
+
+  is $report->{columns}[1]{key}, 'baz', 'second column key';
+  is $report->{columns}[1]{label}, 'Baz', 'second column label';
+  ok not($report->{columns}[1]{user}), 'second column not user supplied';
+
+  is $report->{columns}[2]{key}, '__usercol_0', 'first user supplied column key';
+  like $report->{columns}[2]{label}, qr{^\s+Qux\s+$}, 'first user supplied column label';
+  ok $report->{columns}[2]{user}, 'third column is user supplied';
+
+  is $report->{columns}[3]{key}, '__usercol_orph_0', 'second user supplied column key';
+  is $report->{columns}[3]{label}, undef, 'second user supplied column label is empty';
+  ok $report->{columns}[3]{user}, 'fourth column is user supplied';
+
+  is_deeply $report->{rows}[0], {
+    bar => 231, baz => 421, __usercol_0 => 171,
+  }, 'first row';
+  is_deeply $report->{rows}[1], {
+    bar => 232, baz => 422, __usercol_0 => 172, __usercol_orph_0 => 173,
+  }, 'second row';
+};
+
+is shift(@$stream), '', 'empty line';
+is shift(@$stream), 'End Text', 'end text';
+is shift(@$stream), '', 'empty line'
+  while @$stream;
+
+done_testing;