0a4614a00a226bb52435ed89eda781d757d1cbeb
[irspy-moved-to-github.git] / lib / ZOOM / IRSpy.pm
1 # $Id: IRSpy.pm,v 1.90 2008-07-16 11:42:13 mike Exp $
2
3 package ZOOM::IRSpy;
4
5 use 5.008;
6 use strict;
7 use warnings;
8
9 use Data::Dumper;               # For debugging only
10 use File::Basename;
11 use XML::LibXSLT;
12 use XML::LibXML;
13 use XML::LibXML::XPathContext;
14 use ZOOM;
15 use Net::Z3950::ZOOM 1.13;      # For the ZOOM version-check only
16 use ZOOM::IRSpy::Node;
17 use ZOOM::IRSpy::Connection;
18 use ZOOM::IRSpy::Stats;
19 use ZOOM::IRSpy::Utils qw(cql_target render_record
20                           irspy_xpath_context irspy_make_identifier
21                           irspy_record2identifier);
22
23 our @ISA = qw();
24 our $VERSION = '1.01';
25 our $irspy_to_zeerex_xsl = dirname(__FILE__) . '/../../xsl/irspy2zeerex.xsl';
26 our $xslt_max_depth = 250;
27
28
29 # Enumeration for callback functions to return
30 package ZOOM::IRSpy::Status;
31 sub OK { 29 }                   # No problems, task is still progressing
32 sub TASK_DONE { 18 }            # Task is complete, next task should begin
33 sub TEST_GOOD { 8 }             # Whole test is complete, and succeeded
34 sub TEST_BAD { 31 }             # Whole test is complete, and failed
35 sub TEST_SKIPPED { 12 }         # Test couldn't be run
36 package ZOOM::IRSpy;
37
38
39 =head1 NAME
40
41 ZOOM::IRSpy - Perl extension for discovering and analysing IR services
42
43 =head1 SYNOPSIS
44
45  use ZOOM::IRSpy;
46  $spy = new ZOOM::IRSpy("target/string/for/irspy/database");
47  $spy->targets(@targets);
48  $spy->initialise("Main");
49  $res = $spy->check();
50
51 =head1 DESCRIPTION
52
53 This module exists to implement the IRspy program, which discovers,
54 analyses and monitors IR servers implementing the Z39.50 and SRU/W
55 protocols.  It is a successor to the ZSpy program.
56
57 =cut
58
59 BEGIN {
60     ZOOM::Log::mask_str("irspy");
61     ZOOM::Log::mask_str("irspy_debug");
62     ZOOM::Log::mask_str("irspy_event");
63     ZOOM::Log::mask_str("irspy_unhandled");
64     ZOOM::Log::mask_str("irspy_test");
65     ZOOM::Log::mask_str("irspy_task");
66 }
67
68 sub new {
69     my $class = shift();
70     my($dbname, $user, $password, $activeSetSize) = @_;
71
72     my @options;
73     push @options, (user => $user, password => $password)
74         if defined $user;
75
76     my $conn = new ZOOM::Connection($dbname, 0, @options)
77         or die "$0: can't connection to IRSpy database 'dbname'";
78
79     my $xslt = new XML::LibXSLT;
80
81     # raise the maximum number of nested template calls and variables/params (default 250)
82     $xslt->max_depth($xslt_max_depth);
83
84     $xslt->register_function($ZOOM::IRSpy::Utils::IRSPY_NS, 'strcmp',
85                              \&ZOOM::IRSpy::Utils::xslt_strcmp);
86
87     my $libxml = new XML::LibXML;
88     my $xsl_doc = $libxml->parse_file($irspy_to_zeerex_xsl);
89     my $irspy_to_zeerex_style = $xslt->parse_stylesheet($xsl_doc);
90
91     my $this = bless {
92         conn => $conn,
93         query => "cql.allRecords=1", # unless overridden
94         modn => undef,          # Filled in by restrict_modulo()
95         modi => undef,          # Filled in by restrict_modulo()
96         targets => undef,       # Filled in later if targets() is
97                                 # called; used only to keep state from
98                                 # targets() until initialise() is
99                                 # called.
100         connections => undef,   # Filled in by initialise()
101         queue => undef,         # Filled in by initialise()
102         libxml => $libxml,
103         irspy_to_zeerex_style => $irspy_to_zeerex_style,
104         test => undef,          # Filled in by initialise()
105         timeout => undef,       # Filled in by initialise()
106         tests => undef,         # Tree of tests to be executed
107         activeSetSize => defined $activeSetSize ? $activeSetSize : 10,
108     }, $class;
109     $this->log("irspy", "starting up with database '$dbname'");
110
111     return $this;
112 }
113
114
115 sub log {
116     my $this = shift();
117     ZOOM::Log::log(@_);
118 }
119
120
121 sub find_targets {
122     my $this = shift();
123     my($query) = @_;
124
125     $this->{query} = $query;
126 }
127
128
129 # Explicitly nominate a set of targets to check, overriding the
130 # default which is to re-check everything in the database.  Each
131 # target already in the database results in the existing record being
132 # updated; each new target causes a new record to be added.
133 #
134 sub targets {
135     my $this = shift();
136     my(@targets) = @_;
137
138     $this->log("irspy", "setting explicit list of targets ",
139                join(", ", map { "'$_'" } @targets));
140     my @qlist;
141     foreach my $target (@targets) {
142         my($protocol, $host, $port, $db, $newtarget) =
143             _parse_target_string($target);
144         if ($newtarget ne $target) {
145             $this->log("irspy_debug", "rewriting '$target' to '$newtarget'");
146             $target = $newtarget; # This is written through the ref
147         }
148         push @qlist, cql_target($protocol, $host, $port, $db);
149     }
150
151     $this->{targets} = \@targets;
152     $this->{query} = join(" or ", @qlist);
153 }
154
155
156 # Also used by ZOOM::IRSpy::Record
157 sub _parse_target_string {
158     my($target) = @_;
159
160     my($protocol, $host, $port, $db) = ($target =~ /(.*?):(.*?):(.*?)\/(.*)/);
161     if (!defined $host) {
162         $port = 210;
163         ($protocol, $host, $db) = ($target =~ /(.*?):(.*?)\/(.*)/);
164         $target = irspy_make_identifier($protocol, $host, $port, $db);
165     }
166     die "$0: invalid target string '$target'"
167         if !defined $host;
168
169     return ($protocol, $host, $port, $db, $target);
170 }
171
172
173 sub restrict_modulo {
174     my $this = shift();
175     my($n, $i) = @_;
176
177     $this->{modn} = $n;
178     $this->{modi} = $i;
179 }
180
181
182 # Records must be fetched for all records satisfying $this->{query} If
183 # $this->{targets} is already set (i.e. a specific list of targets to
184 # check was specified by a call to targets()), then new, empty records
185 # will be made for any targets that are not already in the database.
186 #
187 sub initialise {
188     my $this = shift();
189     my($tname) = @_;
190
191     $tname = "Main" if !defined $tname;
192     $this->{test} = $tname;
193     $this->{tree} = $this->_gather_tests($tname)
194         or die "No tests defined for '$tname'";
195     $this->{tree}->resolve();
196     #$this->{tree}->print(0);
197
198     $this->{timeout} = "ZOOM::IRSpy::Test::$tname"->timeout();
199
200     my @targets;
201     my $targets = $this->{targets};
202     if (defined $targets) {
203         @targets = @$targets;
204         delete $this->{targets};
205     } else {
206         my $rs = $this->{conn}->search(new ZOOM::Query::CQL($this->{query}));
207         $this->log("irspy", "'", $this->{query}, "' found ",
208                    $rs->size(), " target records");
209         delete $this->{query};
210
211         foreach my $i (1 .. $rs->size()) {
212             push @targets, render_record($rs, $i-1, "id");
213         }
214     }
215
216     my $n = $this->{activeSetSize};
217     $n = @targets if $n == 0 || $n > @targets;
218
219     $this->{queue} = \@targets;
220     $this->{connections} = [];
221     while (@{ $this->{connections} } < $n) {
222         my $conn = $this->_next_connection();
223         last if !defined $conn;
224         push @{ $this->{connections} }, $conn;
225     }
226 }
227
228
229 sub _next_connection {
230     my $this = shift();
231
232     my $target;
233     my $n = $this->{modn};
234     my $i = $this->{modi};
235     if (!defined $n) {
236         $target = shift @{ $this->{queue} };
237         return undef if !defined $target;
238     } else {
239         while (1) {
240             $target = shift @{ $this->{queue} };
241             return undef if !defined $target;
242             my $h = _hash($target);
243             my $hmodn = $h % $n;
244             last if $hmodn == $i;
245             #$this->log("irspy", "'$target' hash $h % $n = $hmodn != $i");
246         }
247     }
248
249     die "oops -- target is undefined" if !defined $target;
250     return create ZOOM::IRSpy::Connection($this, $target, async => 1,
251                                           timeout => $this->{timeout});
252 }
253
254
255 sub _hash {
256     my($target) = @_;
257
258     my $n = 0;
259     foreach my $s (split //, $target) {
260         $n += ord($s);
261     }
262
263     return $n;
264 }
265
266
267 sub _irspy_to_zeerex {
268     my $this = shift();
269     my($conn, $save_xml) = @_;
270     my $irspy_doc = $conn->record()->{zeerex}->ownerDocument;
271
272     if ($save_xml) {
273         unlink('/tmp/irspy_orig.xml');
274         open FH, '>/tmp/irspy_orig.xml'
275             or die "can't write irspy_orig.xml: $!";
276         print FH $irspy_doc->toString();
277         close FH;
278     }
279     my %params = ();
280     my $result = $this->{irspy_to_zeerex_style}->transform($irspy_doc, %params);
281     if ($save_xml) {
282         unlink('/tmp/irspy_transformed.xml');
283         open FH, '>/tmp/irspy_transformed.xml'
284             or die "can't write irspy_transformed.xml: $!";
285         print FH $result->toString();
286         close FH;
287     }
288
289     return $result->documentElement();
290 }
291
292
293 sub _rewrite_record {
294     my $this = shift();
295     my($conn) = @_;
296
297     $conn->log("irspy", "rewriting XML record");
298     my $rec = $this->_irspy_to_zeerex($conn, $ENV{IRSPY_SAVE_XML});
299
300     # Since IRSpy can run for a long time between writes back to the
301     # database, it's quite possible for the server to have closed the
302     # connection as idle.  So re-establish it if necessary.
303     $this->{conn}->connect($conn->option("host"));
304
305     _really_rewrite_record($this->{conn}, $rec);
306     $conn->log("irspy", "rewrote XML record");
307 }
308
309
310 sub _really_rewrite_record {
311     my($conn, $rec, $oldid) = @_;
312
313     my $p = $conn->package();
314     $p->option(action => "specialUpdate");
315     my $xml = $rec->toString();
316     $p->option(record => $xml);
317     $p->send("update");
318     $p->destroy();
319
320     # This is the expression in the ID-making stylesheet
321     # ../../zebra/zeerex2id.xsl
322     my $xc = irspy_xpath_context($rec);
323     my $id = irspy_record2identifier($xc);
324     if (defined $oldid && $id ne $oldid) {
325         warn "IDs differ (old='$oldid' new='$id')";
326         _delete_record($conn, $oldid);
327     }
328
329     $p = $conn->package();
330     $p->send("commit");
331     $p->destroy();
332     if (0) {
333         $xml =~ s/&/&amp/g;
334         $xml =~ s/</&lt;/g;
335         $xml =~ s/>/&gt;/g;
336         print "Updated $conn with xml=<br/>\n<pre>$xml</pre>\n";
337     }
338 }
339
340
341 sub _delete_record {
342     my($conn, $id) = @_;
343
344     # We can't delete records using recordIdOpaque, since character
345     # sets are handled differently here in extended services from how
346     # they are used in the Alvis filter's record-parsing, and so
347     # non-ASCII characters come out differently in the two contexts.
348     # Instead, we must send a record whose contents indicate the ID of
349     # that which we wish to delete.  There are two ways, both
350     # unsatisfactory: we could either fetch the actual record them
351     # resubmit it in the deletion request (which wastes a search and a
352     # fetch) or we could build a record by hand from the parsed-out
353     # components (which is error-prone and which I am not 100% certain
354     # will work since the other contents of the record will be
355     # different).  The former evil seems to be the lesser.
356
357     warn "$conn deleting record '$id'";
358
359     my $rs = $conn->search(new ZOOM::Query::CQL(cql_target($id)));
360     die "no such ID '$id'" if $rs->size() == 0;
361     my $rec = $rs->record(0);
362     my $xml = $rec->render();
363
364     my $p = $conn->package();
365     $p->option(action => "recordDelete");
366     $p->option(record => $xml);
367     $p->send("update");
368     $p->destroy();
369
370     $p = $conn->package();
371     $p->send("commit");
372     $p->destroy();
373 }
374
375
376 # The approach: gather declarative information about test hierarchy,
377 # then go into a loop.  In the loop, we ensure that each connection is
378 # running a test, and within that test a task, until its list of tests
379 # is exhausted.  No individual test ever calls wait(): tests just queue
380 # up tasks and return immediately.  When the tasks are run (one at a
381 # time on each connection) they generate events, and it is these that
382 # are harvested by ZOOM::event().  Since each connection knows what
383 # task it is running, it can invoke the appropriate callbacks.
384 # Callbacks return a ZOOM::IRSpy::Status value which tells the main
385 # loop how to continue.
386 #
387 # Invariants:
388 #       While a connection is running a task, its current_task()
389 #       points at the task structure.  When it finishes its task, 
390 #       next_task() is pointed at the next task to execute (if there
391 #       is one), and its current_task() is set to zero.  When the next
392 #       task is executed, the connection's next_task() is set to zero
393 #       and its current_task() pointed to the task structure.
394 #       current_task() and next_task() are both zero only when there
395 #       are no more queued tasks, which is when a new test is
396 #       started.
397 #
398 #       Each connection's current test is stored in its
399 #       "current_test_address" option.  The next test to execute is
400 #       calculated by walking the declarative tree of tests.  This
401 #       option begins empty; the "next test" after this is of course
402 #       the root test.
403 #
404 sub check {
405     my $this = shift();
406
407     my $topname = $this->{tree}->name();
408     my $timeout = $this->{timeout};
409     $this->log("irspy", "beginnning with test '$topname' (timeout $timeout)");
410
411     my $nskipped = 0;
412     my @conn = @{ $this->{connections} };
413
414     my $nruns = 0;
415   ROUND_AND_ROUND_WE_GO:
416     while (1) {
417         my @copy_conn = @conn;  # avoid alias problems after splice()
418         my $nconn = scalar(@copy_conn);
419         foreach my $i0 (0 .. $#copy_conn) {
420             my $conn = $copy_conn[$i0];
421             #print "connection $i0 of $nconn/", scalar(@conn), " is $conn\n";
422             next if !defined $conn;
423             if (!$conn->current_task()) {
424                 if (!$conn->next_task()) {
425                     # Out of tasks: we need a new test
426                   NEXT_TEST:
427                     my $address = $conn->option("current_test_address");
428                     my $nextaddr;
429                     if (!defined $address) {
430                         $nextaddr = "";
431                     } else {
432                         $conn->log("irspy_test",
433                                    "checking for next test after '$address'");
434                         $nextaddr = $this->_next_test($address);
435                     }
436                     if (!defined $nextaddr) {
437                         $conn->log("irspy", "has no more tests: removing");
438                         $this->_rewrite_record($conn);
439                         $conn->option(rewrote_record => 1);
440                         my $newconn = $this->_next_connection();
441                         if (!defined $newconn) {
442                             # Do not destroy: needed for later sanity checks
443                             splice @conn, $i0, 1;
444                         } else {
445                             $conn->destroy();
446                             $conn[$i0] = $newconn;
447                             $conn[$i0]->option(current_test_address => "");
448                             $conn[$i0]->log("irspy", "entering active pool - ",
449                                             scalar(@{ $this->{queue} }),
450                                             " targets remain in queue");
451                         }
452                         next;
453                     }
454
455                     my $node = $this->{tree}->select($nextaddr)
456                         or die "invalid nextaddr '$nextaddr'";
457                     $conn->option(current_test_address => $nextaddr);
458                     my $tname = $node->name();
459                     $conn->log("irspy_test",
460                                "starting test '$nextaddr' = $tname");
461                     my $tasks = $conn->tasks();
462                     my $oldcount = @$tasks;
463                     "ZOOM::IRSpy::Test::$tname"->start($conn);
464                     $tasks = $conn->tasks();
465                     if (@$tasks > $oldcount) {
466                         # Prepare to start the first of the newly added tasks
467                         $conn->next_task($tasks->[$oldcount]);
468                     } else {
469                         $conn->log("irspy_task",
470                                    "no tasks added by new test $tname");
471                         goto NEXT_TEST;
472                     }
473                 }
474
475                 my $task = $conn->next_task();
476                 die "no next task queued for $conn" if !defined $task;
477                 $conn->log("irspy_task", "preparing task $task");
478                 $conn->next_task(0);
479                 $conn->current_task($task);
480                 $task->run();
481             }
482         }
483
484       NEXT_EVENT:
485         my $i0 = ZOOM::event(\@conn);
486         $this->log("irspy_event",
487                    "ZOOM_event(", scalar(@conn), " connections) = $i0");
488         if ($i0 < 1) {
489             my %messages = (
490                             0 => "no events remain",
491                             -1 => "ZOOM::event() argument not a reference",
492                             -2 => "ZOOM::event() reference not an array",
493                             -3 => "no connections remain",
494                             -4 => "too many connections for ZOOM::event()",
495                             );
496             my $message = $messages{$i0} || "ZOOM::event() returned $i0";
497             $this->log("irspy", $message);
498             last;
499         }
500
501         my $conn = $conn[$i0-1];
502         my $ev = $conn->last_event();
503         my $evstr = ZOOM::event_str($ev);
504         $conn->log("irspy_event", "event $ev ($evstr)");
505         goto NEXT_EVENT if $ev != ZOOM::Event::ZEND;
506
507         my $task = $conn->current_task();
508         die "$conn has no current task for event $ev ($evstr)" if !$task;
509
510         my $res;
511         eval { $conn->check() };
512         if ($@ && ref $@ && $@->isa("ZOOM::Exception")) {
513             my $sub = $task->{cb}->{exception};
514             die $@ if !defined $sub;
515             $res = &$sub($conn, $task, $task->udata(), $@);
516         } elsif ($@) {
517             die "Unexpected non-ZOOM exception: " . ref($@) . " ($@)";
518         } else {
519             my $sub = $task->{cb}->{$ev};
520             if (!defined $sub) {
521                 $conn->log("irspy_unhandled", "event $ev ($evstr)");
522                 next;
523             }
524
525             $res = &$sub($conn, $task, $task->udata(), $ev);
526         }
527
528         if ($res == ZOOM::IRSpy::Status::OK) {
529             # Nothing to do -- life continues
530
531         } elsif ($res == ZOOM::IRSpy::Status::TASK_DONE) {
532             my $task = $conn->current_task();
533             die "no task for TASK_DONE on $conn" if !$task;
534             die "next task already defined for $conn" if $conn->next_task();
535             $conn->log("irspy_task", "completed task $task");
536             $conn->next_task($task->{next});
537             $conn->current_task(0);
538
539         } elsif ($res == ZOOM::IRSpy::Status::TEST_GOOD ||
540                  $res == ZOOM::IRSpy::Status::TEST_BAD) {
541             my $x = ($res == ZOOM::IRSpy::Status::TEST_GOOD) ? "good" : "bad";
542             $conn->log("irspy_task", "test ended during task $task ($x)");
543             $conn->log("irspy_test", "test completed ($x)");
544             $conn->current_task(0);
545             $conn->next_task(0);
546             if ($res == ZOOM::IRSpy::Status::TEST_BAD) {
547                 my $address = $conn->option('current_test_address');
548                 $conn->log("irspy", "top-level test failed!")
549                     if $address eq "";
550                 my $node = $this->{tree}->select($address);
551                 my $skipcount = 0;
552                 while (defined $node->next() &&
553                        length($node->next()->address()) >= length($address)) {
554                     $conn->log("irspy_debug", "skipping from '",
555                                $node->address(), "' to '",
556                                $node->next()->address(), "'");
557                     $node = $node->next();
558                     $skipcount++;
559                 }
560
561                 $conn->option(current_test_address => $node->address());
562                 $conn->log("irspy_test", "skipped $skipcount tests");
563                 $nskipped += $skipcount;
564             }
565
566         } elsif ($res == ZOOM::IRSpy::Status::TEST_SKIPPED) {
567             $conn->log("irspy_test", "test skipped during task $task");
568             $conn->current_task(0);
569             $conn->next_task(0);
570             $nskipped++;
571
572         } else {
573             die "unknown callback return-value '$res'";
574         }
575     }
576
577     $this->log("irspy", "exiting main loop");
578
579     # Sanity checks: none of the following should ever happen
580     my $finished = 1;
581     $this->log("irspy", "performing end-of-run sanity-checks");
582     foreach my $conn (@conn) {
583         my $test = $conn->option("current_test_address");
584         my $next = $this->_next_test($test);
585         if (defined $next) {
586             $this->log("irspy",
587                        "$conn (in test '$test') has queued test '$next'");
588             $finished = 0;
589         }
590         if (my $task = $conn->current_task()) {
591             $this->log("irspy", "$conn still has an active task $task");
592             $finished = 0;
593         }
594         if (my $task = $conn->next_task()) {
595             $this->log("irspy", "$conn still has a queued task $task");
596             $finished = 0;
597         }
598         if (!$conn->is_idle()) {
599             $this->log("irspy",
600                        "$conn still has ZOOM-C level tasks queued: see below");
601             $finished = 0;
602         }
603         my $ev = $conn->peek_event();
604         if ($ev != 0 && $ev != ZOOM::Event::ZEND) {
605             my $evstr = ZOOM::event_str($ev);
606             $this->log("irspy", "$conn has event $ev ($evstr) waiting");
607             $finished = 0;
608         }
609         if (!$conn->option("rewrote_record")) {
610             $this->log("irspy", "$conn did not rewrite its ZeeRex record");
611             $finished = 0;
612         }
613     }
614
615     # This really shouldn't be necessary, and in practice it rarely
616     # helps, but it's belt and braces.  (For now, we don't do this
617     # hence the zero in the $nruns check).
618     if (!$finished) {
619         if (++$nruns < 0) {
620             $this->log("irspy", "back into main loop, ${nruns}th time");
621             goto ROUND_AND_ROUND_WE_GO;
622         } else {
623             $this->log("irspy", "bailing after $nruns main-loop runs");
624         }
625     }
626
627     # This shouldn't happen emit anything either:
628     while ((my $i1 = ZOOM::event(\@conn)) > 0) {
629         my $conn = $conn[$i1-1];
630         my $ev = $conn->last_event();
631         my $evstr = ZOOM::event_str($ev);
632         $this->log("irspy",
633                    "$conn still has ZOOM-C level task queued: $ev ($evstr)")
634             if $ev != ZOOM::Event::ZEND;
635     }
636
637     return $nskipped;
638 }
639
640
641 # Exactly equivalent to ZOOM::event() except that it is tolerant to
642 # undefined values in the array being passed in.
643 #
644 sub __UNUSED_tolerant_ZOOM_event {
645     my($connref) = @_;
646
647     my(@conn, @map);
648     foreach my $i (0 .. @$connref-1) {
649         my $conn = $connref->[$i];
650         if (defined $conn) {
651             push @conn, $conn;
652             push @map, $i;
653         }
654     }
655
656     my $res = ZOOM::event(\@conn);
657     return $res if $res <= 0;
658     my $res2 = $map[$res-1] + 1;
659     print STDERR "*** tolerant_ZOOM_event() returns $res->$res2\n";
660     return $res2;
661 }
662
663
664 sub _gather_tests {
665     my $this = shift();
666     my($tname, @ancestors) = @_;
667
668     die("$0: test-hierarchy loop detected: " .
669         join(" -> ", @ancestors, $tname))
670         if grep { $_ eq $tname } @ancestors;
671
672     my $slashSeperatedTname = $tname;
673     $slashSeperatedTname =~ s/::/\//g;
674     my $fullName = "ZOOM/IRSpy/Test/$slashSeperatedTname.pm";
675
676     eval {
677         require $fullName;
678     }; if ($@) {
679         $this->log("irspy", "couldn't require '$fullName': $@");
680         $this->log("warn", "can't load test '$tname': skipping",
681                    $@ =~ /^Can.t locate/ ? () : " ($@)");
682         return undef;
683     }
684
685     $this->log("irspy", "adding test '$tname'");
686     my @subnodes;
687     foreach my $subtname ("ZOOM::IRSpy::Test::$tname"->subtests($this)) {
688         my $subtest = $this->_gather_tests($subtname, @ancestors, $tname);
689         push @subnodes, $subtest if defined $subtest;
690     }
691
692     return new ZOOM::IRSpy::Node($tname, @subnodes);
693 }
694
695
696 # These next three should arguably be Node methods
697 sub _next_test {
698     my $this = shift();
699     my($address, $omit_child) = @_;
700
701     # Try first child
702     if (!$omit_child) {
703         my $maybe = $address eq "" ? "0" : "$address:0";
704         return $maybe if $this->{tree}->select($maybe);
705     }
706
707     # The top-level node has no successor or parent
708     return undef if $address eq "";
709
710     # Try next sibling child
711     my @components = split /:/, $address;
712     my $last = pop @components;
713     my $maybe = join(":", @components, $last+1);
714     return $maybe if $this->{tree}->select($maybe);
715
716     # This node is exhausted: try the parent's successor
717     return $this->_next_test(join(":", @components), 1)
718 }
719
720
721 sub _last_sibling_test {
722     my $this = shift();
723     my($address) = @_;
724
725     return undef
726         if !defined $this->_next_sibling_test($address);
727
728     my $nskipped = 0;
729     while (1) {
730         my $maybe = $this->_next_sibling_test($address);
731         last if !defined $maybe;
732         $nskipped++;
733         $address = $maybe;
734         $this->log("irspy", "skipping $nskipped tests to '$address'");
735     }
736
737     return ($address, $nskipped);
738 }
739
740
741 sub _next_sibling_test {
742     my $this = shift();
743     my($address) = @_;
744
745     my @components = split /:/, $address;
746     my $last = pop @components;
747     my $maybe = join(":", @components, $last+1);
748     return $maybe if $this->{tree}->select($maybe);
749     return undef;
750 }
751
752
753 =head1 SEE ALSO
754
755 ZOOM::IRSpy::Record,
756 ZOOM::IRSpy::Web,
757 ZOOM::IRSpy::Test,
758 ZOOM::IRSpy::Maintenance.
759
760 The ZOOM-Perl module,
761 http://search.cpan.org/~mirk/Net-Z3950-ZOOM/
762
763 The Zebra Database,
764 http://indexdata.com/zebra/
765
766 =head1 AUTHOR
767
768 Mike Taylor, E<lt>mike@indexdata.comE<gt>
769
770 =head1 COPYRIGHT AND LICENSE
771
772 Copyright (C) 2006 by Index Data ApS.
773
774 This library is free software; you can redistribute it and/or modify
775 it under the same terms as Perl itself, either Perl version 5.8.7 or,
776 at your option, any later version of Perl 5 you may have available.
777
778 =cut
779
780
781 1;