http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/cf2f7a93/utils/common/src/main/java/org/apache/brooklyn/util/time/Duration.java
----------------------------------------------------------------------
diff --git 
a/utils/common/src/main/java/org/apache/brooklyn/util/time/Duration.java 
b/utils/common/src/main/java/org/apache/brooklyn/util/time/Duration.java
new file mode 100644
index 0000000..b11c0b0
--- /dev/null
+++ b/utils/common/src/main/java/org/apache/brooklyn/util/time/Duration.java
@@ -0,0 +1,317 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.brooklyn.util.time;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.io.Serializable;
+import java.lang.reflect.Method;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.Nullable;
+
+import com.google.common.base.Function;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Stopwatch;
+
+/** simple class determines a length of time */
+public class Duration implements Comparable<Duration>, Serializable {
+
+    private static final long serialVersionUID = -2303909964519279617L;
+    
+    public static final Duration ZERO = of(0, null);
+    public static final Duration ONE_MILLISECOND = of(1, 
TimeUnit.MILLISECONDS);
+    public static final Duration ONE_SECOND = of(1, TimeUnit.SECONDS);
+    public static final Duration FIVE_SECONDS = of(5, TimeUnit.SECONDS);
+    public static final Duration TEN_SECONDS = of(10, TimeUnit.SECONDS);
+    public static final Duration THIRTY_SECONDS = of(30, TimeUnit.SECONDS);
+    public static final Duration ONE_MINUTE = of(1, TimeUnit.MINUTES);
+    public static final Duration TWO_MINUTES = of(2, TimeUnit.MINUTES);
+    public static final Duration FIVE_MINUTES = of(5, TimeUnit.MINUTES);
+    public static final Duration ONE_HOUR = of(1, TimeUnit.HOURS);
+    public static final Duration ONE_DAY = of(1, TimeUnit.DAYS);
+    
+    /** longest supported duration, 2^{63}-1 nanoseconds, approx ten billion 
seconds, or 300 years */ 
+    public static final Duration PRACTICALLY_FOREVER = of(Long.MAX_VALUE, 
TimeUnit.NANOSECONDS);
+
+    private final long nanos;
+
+    public Duration(long value, TimeUnit unit) {
+        if (value != 0) {
+            Preconditions.checkNotNull(unit, "Cannot accept null timeunit 
(unless value is 0)");
+        } else {
+            unit = TimeUnit.MILLISECONDS;
+        }
+        nanos = TimeUnit.NANOSECONDS.convert(value, unit);
+    }
+
+    @Override
+    public int compareTo(Duration o) {
+        return ((Long)toNanoseconds()).compareTo(o.toNanoseconds());
+    }
+
+    @Override
+    public String toString() {
+        return Time.makeTimeStringExact(this);
+    }
+
+    public String toStringRounded() {
+        return Time.makeTimeStringRounded(this);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof Duration)) return false;
+        return toMilliseconds() == ((Duration)o).toMilliseconds();
+    }
+
+    @Override
+    public int hashCode() {
+        return Long.valueOf(toMilliseconds()).hashCode();
+    }
+
+    /** converts to the given {@link TimeUnit}, using {@link 
TimeUnit#convert(long, TimeUnit)} which rounds _down_
+     * (so 1 nanosecond converted to milliseconds gives 0 milliseconds, and -1 
ns gives -1 ms) */
+    public long toUnit(TimeUnit unit) {
+        return unit.convert(nanos, TimeUnit.NANOSECONDS);
+    }
+
+    /** as {@link #toUnit(TimeUnit)} but rounding as indicated
+     * (rather than always taking the floor which is TimeUnit's default 
behaviour) */
+    public long toUnit(TimeUnit unit, RoundingMode rounding) {
+        long result = unit.convert(nanos, TimeUnit.NANOSECONDS);
+        long check = TimeUnit.NANOSECONDS.convert(result, unit);
+        if (check==nanos || rounding==null || 
rounding==RoundingMode.UNNECESSARY) return result;
+        return new BigDecimal(nanos).divide(new BigDecimal(unit.toNanos(1)), 
rounding).longValue();
+    }
+
+    /** as {@link #toUnit(TimeUnit)} but rounding away from zero,
+     * so 1 ns converted to ms gives 1 ms, and -1 ns gives 1ms */
+    public long toUnitRoundingUp(TimeUnit unit) {
+        return toUnit(unit, RoundingMode.UP);
+    }
+
+    public long toMilliseconds() {
+        return toUnit(TimeUnit.MILLISECONDS);
+    }
+
+    /** as {@link #toMilliseconds()} but rounding away from zero (so 1 
nanosecond gets rounded to 1 millisecond);
+     * see {@link #toUnitRoundingUp(TimeUnit)}; provided as a convenience on 
top of {@link #toUnit(TimeUnit, RoundingMode)}
+     * as this is a common case (when you want to make sure you wait at least 
a certain amount of time) */
+    public long toMillisecondsRoundingUp() {
+        return toUnitRoundingUp(TimeUnit.MILLISECONDS);
+    }
+
+    public long toNanoseconds() {
+        return nanos;
+    }
+
+    public long toSeconds() {
+        return toUnit(TimeUnit.SECONDS);
+    }
+
+    /** number of nanoseconds of this duration */
+    public long nanos() {
+        return nanos;
+    }
+
+    /** 
+     * See {@link Time#parseElapsedTime(String)}; 
+     * also accepts "forever" (and for those who prefer things exceedingly 
accurate, "practically_forever"). 
+     * Also see {@link #of(Object)}. */
+    public static Duration parse(String textualDescription) {
+        if (textualDescription==null) return null;
+        if ("null".equalsIgnoreCase(textualDescription)) return null;
+        
+        if ("forever".equalsIgnoreCase(textualDescription)) return 
Duration.PRACTICALLY_FOREVER;
+        if ("practicallyforever".equalsIgnoreCase(textualDescription)) return 
Duration.PRACTICALLY_FOREVER;
+        if ("practically_forever".equalsIgnoreCase(textualDescription)) return 
Duration.PRACTICALLY_FOREVER;
+        
+        return new Duration((long) 
Time.parseElapsedTimeAsDouble(textualDescription), TimeUnit.MILLISECONDS);
+    }
+
+    /** creates new {@link Duration} instance of the given length of time */
+    public static Duration days(Number n) {
+        return new Duration((long) (n.doubleValue() * 
TimeUnit.DAYS.toNanos(1)), TimeUnit.NANOSECONDS);
+    }
+
+    /** creates new {@link Duration} instance of the given length of time */
+    public static Duration hours(Number n) {
+        return new Duration((long) (n.doubleValue() * 
TimeUnit.HOURS.toNanos(1)), TimeUnit.NANOSECONDS);
+    }
+
+    /** creates new {@link Duration} instance of the given length of time */
+    public static Duration minutes(Number n) {
+        return new Duration((long) (n.doubleValue() * 
TimeUnit.MINUTES.toNanos(1)), TimeUnit.NANOSECONDS);
+    }
+
+    /** creates new {@link Duration} instance of the given length of time */
+    public static Duration seconds(Number n) {
+        return new Duration((long) (n.doubleValue() * 
TimeUnit.SECONDS.toNanos(1)), TimeUnit.NANOSECONDS);
+    }
+
+    /** creates new {@link Duration} instance of the given length of time */
+    public static Duration millis(Number n) {
+        return new Duration((long) (n.doubleValue() * 
TimeUnit.MILLISECONDS.toNanos(1)), TimeUnit.NANOSECONDS);
+    }
+
+    /** creates new {@link Duration} instance of the given length of time */
+    public static Duration nanos(Number n) {
+        return new Duration(n.longValue(), TimeUnit.NANOSECONDS);
+    }
+
+    public static Function<Number, String> millisToStringRounded() { return 
millisToStringRounded; }
+    private static Function<Number, String> millisToStringRounded = new 
Function<Number, String>() {
+            @Override
+            @Nullable
+            public String apply(@Nullable Number input) {
+                if (input == null) return null;
+                return Duration.millis(input).toStringRounded();
+            }
+        };
+
+    public static Function<Number, String> secondsToStringRounded() { return 
secondsToStringRounded; }
+    private static Function<Number, String> secondsToStringRounded = new 
Function<Number, String>() {
+            @Override
+            @Nullable
+            public String apply(@Nullable Number input) {
+                if (input == null) return null;
+                return Duration.seconds(input).toStringRounded();
+            }
+        };
+
+    /** tries to convert given object to a Duration, parsing strings, treating 
numbers as millis, etc;
+     * throws IAE if not convertible */
+    public static Duration of(Object o) {
+        if (o == null) return null;
+        if (o instanceof Duration) return (Duration)o;
+        if (o instanceof String) return parse((String)o);
+        if (o instanceof Number) return millis((Number)o);
+        if (o instanceof Stopwatch) return 
millis(((Stopwatch)o).elapsed(TimeUnit.MILLISECONDS));
+
+        try {
+            // this allows it to work with groovy TimeDuration
+            Method millisMethod = o.getClass().getMethod("toMilliseconds");
+            return millis((Long)millisMethod.invoke(o));
+        } catch (Exception e) {
+            // probably no such method
+        }
+
+        throw new IllegalArgumentException("Cannot convert "+o+" (type 
"+o.getClass()+") to a duration");
+    }
+
+    public static Duration of(long value, TimeUnit unit) {
+        return new Duration(value, unit);
+    }
+
+    public static Duration max(Duration first, Duration second) {
+        return checkNotNull(first, "first").nanos >= checkNotNull(second, 
"second").nanos ? first : second;
+    }
+
+    public static Duration min(Duration first, Duration second) {
+        return checkNotNull(first, "first").nanos <= checkNotNull(second, 
"second").nanos ? first : second;
+    }
+
+    public static Duration untilUtc(long millisSinceEpoch) {
+        return millis(millisSinceEpoch - System.currentTimeMillis());
+    }
+
+    public static Duration sinceUtc(long millisSinceEpoch) {
+        return millis(System.currentTimeMillis() - millisSinceEpoch);
+    }
+
+    public Duration add(Duration other) {
+        return nanos(nanos() + other.nanos());
+    }
+
+    public Duration subtract(Duration other) {
+        return nanos(nanos() - other.nanos());
+    }
+
+    public Duration multiply(long x) {
+        return nanos(nanos() * x);
+    }
+    public Duration times(long x) {
+        return multiply(x);
+    }
+
+    /** as #multiply(long), but approximate due to the division (nano 
precision) */
+    public Duration multiply(double d) {
+        return nanos(nanos() * d);
+    }
+
+    public Duration half() {
+        return multiply(0.5);
+    }
+
+    /** see {@link Time#sleep(long)} */
+    public static void sleep(Duration duration) {
+        Time.sleep(duration);
+    }
+
+    /** returns a new started {@link CountdownTimer} with this duration */
+    public CountdownTimer countdownTimer() {
+        return CountdownTimer.newInstanceStarted(this);
+    }
+
+    public boolean isPositive() {
+        return nanos()>0;
+    }
+
+    public boolean isLongerThan(Duration x) {
+        return compareTo(x) > 0;
+    }
+
+    public boolean isLongerThan(Stopwatch stopwatch) {
+        return 
isLongerThan(Duration.millis(stopwatch.elapsed(TimeUnit.MILLISECONDS)));
+    }
+
+    public boolean isShorterThan(Duration x) {
+        return compareTo(x) < 0;
+    }
+
+    public boolean isShorterThan(Stopwatch stopwatch) {
+        return 
isShorterThan(Duration.millis(stopwatch.elapsed(TimeUnit.MILLISECONDS)));
+    }
+
+    /** returns the larger of this value or the argument */
+    public Duration lowerBound(Duration alternateMinimumValue) {
+        if (isShorterThan(alternateMinimumValue)) return alternateMinimumValue;
+        return this;
+    }
+
+    /** returns the smaller of this value or the argument */
+    public Duration upperBound(Duration alternateMaximumValue) {
+        if (isLongerThan(alternateMaximumValue)) return alternateMaximumValue;
+        return this;
+    }
+
+    /** @deprecated since 0.7.0 use {@link #lowerBound(Duration)} */ 
@Deprecated
+    public Duration minimum(Duration alternateMinimumValue) {
+        return lowerBound(alternateMinimumValue);
+    }
+
+    /** @deprecated since 0.7.0 use {@link #upperBound(Duration)} */ 
@Deprecated
+    /** returns the smaller of this value or the argument */
+    public Duration maximum(Duration alternateMaximumValue) {
+        return upperBound(alternateMaximumValue);
+    }
+}

http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/cf2f7a93/utils/common/src/main/java/org/apache/brooklyn/util/time/Durations.java
----------------------------------------------------------------------
diff --git 
a/utils/common/src/main/java/org/apache/brooklyn/util/time/Durations.java 
b/utils/common/src/main/java/org/apache/brooklyn/util/time/Durations.java
new file mode 100644
index 0000000..7680184
--- /dev/null
+++ b/utils/common/src/main/java/org/apache/brooklyn/util/time/Durations.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.brooklyn.util.time;
+
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.brooklyn.util.exceptions.Exceptions;
+import org.apache.brooklyn.util.guava.Maybe;
+
+public class Durations {
+
+    public static boolean await(CountDownLatch latch, Duration time) throws 
InterruptedException {
+        return latch.await(time.toNanoseconds(), TimeUnit.NANOSECONDS);
+    }
+    
+    public static void join(Thread thread, Duration time) throws 
InterruptedException {
+        thread.join(time.toMillisecondsRoundingUp());
+    }
+
+    public static <T> Maybe<T> get(Future<T> t, Duration timeout) {
+        try {
+            if (timeout==null || timeout.toMilliseconds()<0 || 
Duration.PRACTICALLY_FOREVER.equals(timeout))
+                return Maybe.of(t.get());
+            if (timeout.toMilliseconds()==0 && !t.isDone()) {
+                for (int i=0; i<10; i++) {
+                    // give it 10 nanoseconds to complete - heuristically this 
is often enough
+                    // (Thread.yield should do it, but often seems to have no 
effect, e.g. on Mac)
+                    Thread.yield();
+                    Thread.sleep(0, 1);
+                }
+                return Maybe.absent("Task "+t+" not completed when immediate 
completion requested");
+            }
+            return Maybe.of(t.get(timeout.toMilliseconds(), 
TimeUnit.MILLISECONDS));
+        } catch (TimeoutException e) {
+            return Maybe.absent("Task "+t+" did not complete within "+timeout);
+        } catch (CancellationException e) {
+            return Maybe.absent("Task "+t+" was cancelled");
+        } catch (Exception e) {
+            throw Exceptions.propagate(e);
+        }
+    }
+
+    public static <T> Maybe<T> get(Future<T> t, CountdownTimer timer) {
+        if (timer==null) return get(t, (Duration)null);
+        Duration remaining = timer.getDurationRemaining();
+        if (remaining.isPositive()) return get(t, remaining);
+        return get(t, Duration.ZERO);
+    }
+    
+}

http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/cf2f7a93/utils/common/src/main/java/org/apache/brooklyn/util/time/Time.java
----------------------------------------------------------------------
diff --git a/utils/common/src/main/java/org/apache/brooklyn/util/time/Time.java 
b/utils/common/src/main/java/org/apache/brooklyn/util/time/Time.java
new file mode 100644
index 0000000..b1ab8f3
--- /dev/null
+++ b/utils/common/src/main/java/org/apache/brooklyn/util/time/Time.java
@@ -0,0 +1,961 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.brooklyn.util.time;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.ParsePosition;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.List;
+import java.util.Locale;
+import java.util.SimpleTimeZone;
+import java.util.TimeZone;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import javax.annotation.Nullable;
+
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.exceptions.Exceptions;
+import org.apache.brooklyn.util.guava.Maybe;
+import org.apache.brooklyn.util.text.Strings;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.common.base.Function;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Stopwatch;
+
+public class Time {
+
+    private static final Logger log = LoggerFactory.getLogger(Time.class);
+    
+    public static final String DATE_FORMAT_PREFERRED_W_TZ = "yyyy-MM-dd 
HH:mm:ss.SSS Z";
+    public static final String DATE_FORMAT_PREFERRED = "yyyy-MM-dd 
HH:mm:ss.SSS";
+    public static final String DATE_FORMAT_STAMP = "yyyyMMdd-HHmmssSSS";
+    public static final String DATE_FORMAT_SIMPLE_STAMP = "yyyy-MM-dd-HHmm";
+    public static final String DATE_FORMAT_OF_DATE_TOSTRING = "EEE MMM dd 
HH:mm:ss zzz yyyy";
+    public static final String DATE_FORMAT_ISO8601 = 
"yyyy-MM-dd'T'HH:mm:ss.SSSZ";
+    public static final String DATE_FORMAT_ISO8601_NO_MILLIS = 
"yyyy-MM-dd'T'HH:mm:ssZ";
+
+    public static final long MILLIS_IN_SECOND = 1000;
+    public static final long MILLIS_IN_MINUTE = 60*MILLIS_IN_SECOND;
+    public static final long MILLIS_IN_HOUR = 60*MILLIS_IN_MINUTE;
+    public static final long MILLIS_IN_DAY = 24*MILLIS_IN_HOUR;
+    public static final long MILLIS_IN_YEAR = 365*MILLIS_IN_DAY;
+    
+    /** GMT/UTC/Z time zone constant */
+    public static final TimeZone TIME_ZONE_UTC = TimeZone.getTimeZone("");
+    
+    /** as {@link #makeDateString(Date)} for current date/time */
+    public static String makeDateString() {
+        return makeDateString(System.currentTimeMillis());
+    }
+
+    /** as {@link #makeDateString(Date)} for long millis since UTC epock */
+    public static String makeDateString(long date) {
+        return makeDateString(new Date(date), DATE_FORMAT_PREFERRED);
+    }
+    /** returns the time in {@value #DATE_FORMAT_PREFERRED} format for the 
given date;
+     * this format is numeric big-endian but otherwise optimized for people to 
read, with spaces and colons and dots;
+     * time is local to the server and time zone is <i>not</i> included */
+    public static String makeDateString(Date date) {
+        return makeDateString(date, DATE_FORMAT_PREFERRED);
+    }
+    /** as {@link #makeDateString(Date, String, TimeZone)} for the local time 
zone */
+    public static String makeDateString(Date date, String format) {
+        return makeDateString(date, format, null);
+    }
+    /** as {@link #makeDateString(Date, String, TimeZone)} for the given time 
zone; consider {@link TimeZone#GMT} */
+    public static String makeDateString(Date date, String format, TimeZone tz) 
{
+        SimpleDateFormat fmt = new SimpleDateFormat(format);
+        if (tz!=null) fmt.setTimeZone(tz);
+        return fmt.format(date);
+    }
+    /** as {@link #makeDateString(Date, String)} using {@link 
#DATE_FORMAT_PREFERRED_W_TZ} */
+    public static String makeDateString(Calendar date) {
+        return makeDateString(date.getTime(), DATE_FORMAT_PREFERRED_W_TZ);
+    }
+    /** as {@link #makeDateString(Date, String, TimeZone)} for the time zone 
of the given calendar object */
+    public static String makeDateString(Calendar date, String format) {
+        return makeDateString(date.getTime(), format, date.getTimeZone());
+    }
+
+    public static Function<Long, String> toDateString() { return dateString; }
+    private static Function<Long, String> dateString = new Function<Long, 
String>() {
+            @Override
+            @Nullable
+            public String apply(@Nullable Long input) {
+                if (input == null) return null;
+                return Time.makeDateString(input);
+            }
+        };
+
+    /** returns the current time in {@value #DATE_FORMAT_STAMP} format,
+     * suitable for machines to read with only numbers and dashes and quite 
precise (ms) */
+    public static String makeDateStampString() {
+        return makeDateStampString(System.currentTimeMillis());
+    }
+
+    /** returns the time in {@value #DATE_FORMAT_STAMP} format, given a long 
(e.g. returned by System.currentTimeMillis);
+     * cf {@link #makeDateStampString()} */
+    public static String makeDateStampString(long date) {
+        return new SimpleDateFormat(DATE_FORMAT_STAMP).format(new Date(date));
+    }
+
+    /** returns the current time in {@value #DATE_FORMAT_SIMPLE_STAMP} format, 
+     * suitable for machines to read but easier for humans too, 
+     * like {@link #makeDateStampString()} but not as precise */
+    public static String makeDateSimpleStampString() {
+        return makeDateSimpleStampString(System.currentTimeMillis());
+    }
+
+    /** returns the time in {@value #DATE_FORMAT_SIMPLE_STAMP} format, given a 
long (e.g. returned by System.currentTimeMillis);
+     * cf {@link #makeDateSimpleStampString()} */
+    public static String makeDateSimpleStampString(long date) {
+        return new SimpleDateFormat(DATE_FORMAT_SIMPLE_STAMP).format(new 
Date(date));
+    }
+
+    public static Function<Long, String> toDateStampString() { return 
dateStampString; }
+    private static Function<Long, String> dateStampString = new Function<Long, 
String>() {
+            @Override
+            @Nullable
+            public String apply(@Nullable Long input) {
+                if (input == null) return null;
+                return Time.makeDateStampString(input);
+            }
+        };
+
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringExact(long t, TimeUnit unit) {
+        long nanos = unit.toNanos(t);
+        return makeTimeStringNanoExact(nanos);
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringRounded(long t, TimeUnit unit) {
+        long nanos = unit.toNanos(t);
+        return makeTimeStringNanoRounded(nanos);
+    }
+    public static String makeTimeStringRounded(Stopwatch timer) {
+        return makeTimeStringRounded(timer.elapsed(TimeUnit.MILLISECONDS), 
TimeUnit.MILLISECONDS);
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringExact(long t) {
+        return makeTimeString(t, false);
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringRounded(long t) {
+        return makeTimeString(t, true);
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringRoundedSince(long utc) {
+        return makeTimeString(System.currentTimeMillis() - utc, true);
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringExact(Duration d) {
+        return makeTimeStringNanoExact(d.toNanoseconds());
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringRounded(Duration d) {
+        return makeTimeStringNanoRounded(d.toNanoseconds());
+    }
+    /** given an elapsed time, makes it readable, eg 44d 6h, or 8s 923ms, 
optionally rounding */
+    public static String makeTimeString(long t, boolean round) {
+        return makeTimeStringNano(t*1000000L, round);
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringNanoExact(long tn) {
+        return makeTimeStringNano(tn, false);
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringNanoRounded(long tn) {
+        return makeTimeStringNano(tn, true);
+    }
+    /** @see #makeTimeString(long, boolean) */
+    public static String makeTimeStringNano(long tn, boolean round) {
+        if (tn<0) return "-"+makeTimeStringNano(-tn, round);
+        // units don't matter, but since ms is the usual finest granularity 
let's use it
+        // (previously was just "0" but that was too ambiguous in contexts 
like "took 0")
+        if (tn==0) return "0ms";
+        
+        long tnm = tn % 1000000;
+        long t = tn/1000000;
+        String result = "";
+        
+        long d = t/MILLIS_IN_DAY;  t %= MILLIS_IN_DAY;
+        long h = t/MILLIS_IN_HOUR;  t %= MILLIS_IN_HOUR;
+        long m = t/MILLIS_IN_MINUTE;  t %= MILLIS_IN_MINUTE;
+        long s = t/MILLIS_IN_SECOND;  t %= MILLIS_IN_SECOND;
+        long ms = t;
+        
+        int segments = 0;
+        if (d>0) { result += d+"d "; segments++; }
+        if (h>0) { result += h+"h "; segments++; }
+        if (round && segments>=2) return Strings.removeAllFromEnd(result, " ");
+        if (m>0) { result += m+"m "; segments++; }
+        if (round && (segments>=2 || d>0)) return 
Strings.removeAllFromEnd(result, " ");
+        if (s>0) {
+            if (ms==0 && tnm==0) {
+                result += s+"s"; segments++;
+                return result;
+            }
+            if (round && segments>0) {
+                result += s+"s"; segments++;
+                return result;
+            }
+            if (round && s>10) {
+                result += toDecimal(s, ms/1000.0, 1)+"s"; segments++;
+                return result;
+            }
+            if (round) {
+                result += toDecimal(s, ms/1000.0, 2)+"s"; segments++;
+                return result;
+            }
+            result += s+"s ";
+        }
+        if (round && segments>0)
+            return Strings.removeAllFromEnd(result, " ");
+        if (ms>0) {
+            if (tnm==0) {
+                result += ms+"ms"; segments++;
+                return result;
+            }
+            if (round && ms>=100) {
+                result += toDecimal(ms, tnm/1000000.0, 1)+"ms"; segments++;
+                return result;
+            }
+            if (round && ms>=10) {
+                result += toDecimal(ms, tnm/1000000.0, 2)+"ms"; segments++;
+                return result;
+            }
+            if (round) {
+                result += toDecimal(ms, tnm/1000000.0, 3)+"ms"; segments++;
+                return result;
+            }
+            result += ms+"ms ";
+        }
+        
+        long us = tnm/1000;
+        long ns = tnm % 1000;
+
+        if (us>0) {
+            if (ns==0) {
+                result += us+"us"; segments++;
+                return result;
+            }
+            if (round && us>=100) {
+                result += toDecimal(us, ns/1000.0, 1)+"us"; segments++;
+                return result;
+            }
+            if (round && us>=10) {
+                result += toDecimal(us, ns/1000.0, 2)+"us"; segments++;
+                return result;
+            }
+            if (round) {
+                result += toDecimal(us, ns/1000.0, 3)+"us"; segments++;
+                return result;
+            }
+            result += us+"us ";
+        }
+
+        if (ns>0) result += ns+"ns";
+        return Strings.removeAllFromEnd(result, " ");
+    }
+
+    public static Function<Long, String> fromLongToTimeStringExact() { return 
LONG_TO_TIME_STRING_EXACT; }
+    private static final Function<Long, String> LONG_TO_TIME_STRING_EXACT = 
new FunctionLongToTimeStringExact();
+    private static final class FunctionLongToTimeStringExact implements 
Function<Long, String> {
+        @Override @Nullable
+        public String apply(@Nullable Long input) {
+            if (input == null) return null;
+            return Time.makeTimeStringExact(input);
+        }
+    }
+
+    /** @deprecated since 0.7.0 use {@link #fromLongToTimeStringExact()} */ 
@Deprecated
+    public static Function<Long, String> toTimeString() { return timeString; }
+    @Deprecated
+    private static Function<Long, String> timeString = new Function<Long, 
String>() {
+            @Override
+            @Nullable
+            public String apply(@Nullable Long input) {
+                if (input == null) return null;
+                return Time.makeTimeStringExact(input);
+            }
+        };
+        
+    public static Function<Long, String> fromLongToTimeStringRounded() { 
return LONG_TO_TIME_STRING_ROUNDED; }
+    private static final Function<Long, String> LONG_TO_TIME_STRING_ROUNDED = 
new FunctionLongToTimeStringRounded();
+    private static final class FunctionLongToTimeStringRounded implements 
Function<Long, String> {
+        @Override @Nullable
+        public String apply(@Nullable Long input) {
+            if (input == null) return null;
+            return Time.makeTimeStringRounded(input);
+        }
+    }
+
+    /** @deprecated since 0.7.0 use {@link #fromLongToTimeStringRounded()} */ 
@Deprecated
+    public static Function<Long, String> toTimeStringRounded() { return 
timeStringRounded; }
+    @Deprecated
+    private static Function<Long, String> timeStringRounded = new 
Function<Long, String>() {
+        @Override
+        @Nullable
+        public String apply(@Nullable Long input) {
+            if (input == null) return null;
+            return Time.makeTimeStringRounded(input);
+        }
+    };
+
+    public static Function<Duration, String> fromDurationToTimeStringRounded() 
{ return DURATION_TO_TIME_STRING_ROUNDED; }
+    private static final Function<Duration, String> 
DURATION_TO_TIME_STRING_ROUNDED = new FunctionDurationToTimeStringRounded();
+    private static final class FunctionDurationToTimeStringRounded implements 
Function<Duration, String> {
+        @Override @Nullable
+        public String apply(@Nullable Duration input) {
+            if (input == null) return null;
+            return Time.makeTimeStringRounded(input);
+        }
+    }
+
+    private static String toDecimal(long intPart, double fracPart, int 
decimalPrecision) {
+        long powTen = 1;
+        for (int i=0; i<decimalPrecision; i++) powTen *= 10;
+        long fpr = Math.round(fracPart * powTen);
+        if (fpr==powTen) {
+            intPart++;
+            fpr = 0;
+        }
+        return intPart + "." + Strings.makePaddedString(""+fpr, 
decimalPrecision, "0", "");
+    }
+
+    /** sleep which propagates Interrupted as unchecked */
+    public static void sleep(long millis) {
+        try {
+            if (millis > 0) Thread.sleep(millis);
+        } catch (InterruptedException e) {
+            throw Exceptions.propagate(e);
+        }
+    }
+    
+    /** as {@link #sleep(long)} */
+    public static void sleep(Duration duration) {
+        Time.sleep(duration.toMillisecondsRoundingUp());
+    }    
+
+    /**
+     * Calculates the number of milliseconds past midnight for a given UTC 
time.
+     */
+    public static long getTimeOfDayFromUtc(long timeUtc) {
+        GregorianCalendar gregorianCalendar = new 
GregorianCalendar(TimeZone.getTimeZone("UTC"));
+        gregorianCalendar.setTimeInMillis(timeUtc);
+        int hour = gregorianCalendar.get(Calendar.HOUR_OF_DAY);
+        int min = gregorianCalendar.get(Calendar.MINUTE);
+        int sec = gregorianCalendar.get(Calendar.SECOND);
+        int millis = gregorianCalendar.get(Calendar.MILLISECOND);
+        return (((((hour * 60) + min) * 60) + sec) * 1000) + millis;
+    }
+    
+    /**
+     * Calculates the number of milliseconds past epoch for a given UTC time.
+     */
+    public static long getTimeUtc(TimeZone zone, int year, int month, int 
date, int hourOfDay, int minute, int second, int millis) {
+        GregorianCalendar time = new GregorianCalendar(zone);
+        time.set(year, month, date, hourOfDay, minute, second);
+        time.set(Calendar.MILLISECOND, millis);
+        return time.getTimeInMillis();
+    }
+    
+    public static long roundFromMillis(long millis, TimeUnit units) {
+        if (units.compareTo(TimeUnit.MILLISECONDS) > 0) {
+            double result = ((double)millis) / units.toMillis(1);
+            return Math.round(result);
+        } else {
+            return units.convert(millis, TimeUnit.MILLISECONDS);
+        }
+    }
+    
+    public static long roundFromMillis(long millis, long millisPerUnit) {
+        double result = ((double)millis) / millisPerUnit;
+        return Math.round(result);
+    }
+    
+    /**
+     * Calculates how long until maxTime has passed since the given startTime. 
+     * However, maxTime==0 is a special case (e.g. could mean wait forever), 
so the result is guaranteed
+     * to be only 0 if maxTime was 0; otherwise -1 will be returned.
+     */
+    public static long timeRemaining(long startTime, long maxTime) {
+        if (maxTime == 0) {
+            return 0;
+        }
+        long result = (startTime+maxTime) - System.currentTimeMillis();
+        return (result == 0) ? -1 : result;
+    }
+    
+    /** Convenience for {@link Duration#parse(String)}. */
+    public static Duration parseDuration(String timeString) {
+        return Duration.parse(timeString);
+    }
+    
+    /** 
+     * As {@link #parseElapsedTimeAsDouble(String)}. Consider using {@link 
#parseDuration(String)} for a more usable return type.
+     * 
+     * @throws NumberFormatException if cannot be parsed (or if null)
+     */
+    public static long parseElapsedTime(String timeString) {
+        return (long) parseElapsedTimeAsDouble(timeString);
+    }
+    /** @deprecated since 0.7.0 see {@link #parseElapsedTime(String)} */ 
@Deprecated
+    public static long parseTimeString(String timeString) {
+        return (long) parseElapsedTime(timeString);
+    }
+    /** @deprecated since 0.7.0 see {@link #parseElapsedTimeAsDouble(String)} 
*/ @Deprecated
+    public static double parseTimeStringAsDouble(String timeString) {
+        return parseElapsedTimeAsDouble(timeString);
+    }
+    
+    /** 
+     * Parses a string eg '5s' or '20m 22.123ms', returning the number of 
milliseconds it represents; 
+     * -1 on blank or never or off or false.
+     * Assumes unit is millisections if no unit is specified.
+     * 
+     * @throws NumberFormatException if cannot be parsed (or if null)
+     */
+    public static double parseElapsedTimeAsDouble(String timeString) {
+        if (timeString==null)
+            throw new NumberFormatException("GeneralHelper.parseTimeString 
cannot parse a null string");
+        try {
+            double d = Double.parseDouble(timeString);
+            return d;
+        } catch (NumberFormatException e) {
+            //look for a type marker
+            timeString = timeString.trim();
+            String s = Strings.getLastWord(timeString).toLowerCase();
+            timeString = timeString.substring(0, 
timeString.length()-s.length()).trim();
+            int i=0;
+            while (s.length()>i) {
+                char c = s.charAt(i);
+                if (c=='.' || Character.isDigit(c)) i++;
+                else break;
+            }
+            String num = s.substring(0, i);
+            if (i==0) {
+                num = Strings.getLastWord(timeString).toLowerCase();
+                timeString = timeString.substring(0, 
timeString.length()-num.length()).trim();
+            } else {
+                s = s.substring(i);
+            }
+            long multiplier = 0;
+            if (num.length()==0) {
+                //must be never or something
+                if (s.equalsIgnoreCase("never") || s.equalsIgnoreCase("off") 
|| s.equalsIgnoreCase("false"))
+                    return -1;
+                throw new NumberFormatException("unrecognised word  '"+s+"' in 
time string");
+            }
+            if (s.equalsIgnoreCase("ms") || s.equalsIgnoreCase("milli") || 
s.equalsIgnoreCase("millis")
+                    || s.equalsIgnoreCase("millisec") || 
s.equalsIgnoreCase("millisecs")
+                    || s.equalsIgnoreCase("millisecond") || 
s.equalsIgnoreCase("milliseconds"))
+                multiplier = 1;
+            else if (s.equalsIgnoreCase("s") || s.equalsIgnoreCase("sec") || 
s.equalsIgnoreCase("secs")
+                    || s.equalsIgnoreCase("second") || 
s.equalsIgnoreCase("seconds"))
+                multiplier = 1000;
+            else if (s.equalsIgnoreCase("m") || s.equalsIgnoreCase("min") || 
s.equalsIgnoreCase("mins")
+                    || s.equalsIgnoreCase("minute") || 
s.equalsIgnoreCase("minutes"))
+                multiplier = 60*1000;
+            else if (s.equalsIgnoreCase("h") || s.equalsIgnoreCase("hr") || 
s.equalsIgnoreCase("hrs")
+                    || s.equalsIgnoreCase("hour") || 
s.equalsIgnoreCase("hours"))
+                multiplier = 60*60*1000;
+            else if (s.equalsIgnoreCase("d") || s.equalsIgnoreCase("day") || 
s.equalsIgnoreCase("days"))
+                multiplier = 24*60*60*1000;
+            else
+                throw new NumberFormatException("unknown unit '"+s+"' in time 
string");
+            double d = Double.parseDouble(num);
+            double dd = 0;
+            if (timeString.length()>0) {
+                dd = parseElapsedTimeAsDouble(timeString);
+                if (dd==-1) {
+                    throw new NumberFormatException("cannot combine 
'"+timeString+"' with '"+num+" "+s+"'");
+                }
+            }
+            return d*multiplier + dd;
+        }
+    }
+
+    public static Calendar newCalendarFromMillisSinceEpochUtc(long timestamp) {
+        GregorianCalendar cal = new GregorianCalendar();
+        cal.setTimeInMillis(timestamp);
+        return cal;
+    }
+
+    public static Calendar newCalendarFromDate(Date date) {
+        return newCalendarFromMillisSinceEpochUtc(date.getTime());
+    }
+    
+    /** As {@link #parseCalendar(String)} but returning a {@link Date},
+     * (i.e. a record where the time zone has been applied and forgotten). */
+    public static Date parseDate(String input) {
+        if (input==null) return null;
+        return parseCalendarMaybe(input).get().getTime();
+    }
+
+    /** Parses dates from string, accepting many formats including ISO-8601 
and http://yaml.org/type/timestamp.html, 
+     * e.g. 2015-06-15 16:00:00 +0000.
+     * <p>
+     * Millis since epoch (1970) is also supported to represent the epoch (0) 
or dates in this millenium,
+     * but to prevent ambiguity of e.g. "20150615", any other dates prior to 
the year 2001 are not accepted.
+     * (However if a type Long is supplied, e.g. from a YAML parse, it will 
always be treated as millis since epoch.) 
+     * <p>
+     * Other formats including locale-specific variants, e.g. recognising 
month names,
+     * are supported but this may vary from platform to platform and may 
change between versions. */
+    public static Calendar parseCalendar(String input) {
+        if (input==null) return null;
+        return parseCalendarMaybe(input).get();
+    }
+    
+    /** as {@link #parseCalendar(String)} but returning a {@link Maybe} rather 
than throwing or returning null */
+    public static Maybe<Calendar> parseCalendarMaybe(String input) {
+        if (input==null) return Maybe.absent("value is null");
+        input = input.trim();
+        Maybe<Calendar> result;
+
+        result = parseCalendarUtc(input);
+        if (result.isPresent()) return result;
+
+        result = parseCalendarSimpleFlexibleFormatParser(input);
+        if (result.isPresent()) return result;
+        // return the error from this method
+        Maybe<Calendar> returnResult = result;
+
+        result = parseCalendarFormat(input, new 
SimpleDateFormat(DATE_FORMAT_OF_DATE_TOSTRING, Locale.ROOT));
+        if (result.isPresent()) return result;
+        result = parseCalendarDefaultParse(input);
+        if (result.isPresent()) return result;
+
+        return returnResult;
+    }
+
+    @SuppressWarnings("deprecation")
+    private static Maybe<Calendar> parseCalendarDefaultParse(String input) {
+        try {
+            long ms = Date.parse(input);
+            if (ms>=new Date(1999, 12, 25).getTime() && ms <= new Date(2200, 
1, 2).getTime()) {
+                // accept default date parse for this century and next
+                GregorianCalendar c = new GregorianCalendar();
+                c.setTimeInMillis(ms);
+                return Maybe.of((Calendar)c);
+            }
+        } catch (Exception e) {
+            Exceptions.propagateIfFatal(e);
+        }
+        return Maybe.absent();
+    }
+
+    private static Maybe<Calendar> parseCalendarUtc(String input) {
+        input = input.trim();
+        if (input.matches("\\d+")) {
+            if ("0".equals(input)) {
+                // accept 0 as epoch UTC
+                return Maybe.of(newCalendarFromMillisSinceEpochUtc(0));
+            }
+            Maybe<Calendar> result = 
Maybe.of(newCalendarFromMillisSinceEpochUtc(Long.parseLong(input)));
+            if (result.isPresent()) {
+                int year = result.get().get(Calendar.YEAR);
+                if (year >= 2000 && year < 2200) {
+                    // only applicable for dates in this century
+                    return result;
+                } else {
+                    return Maybe.absent("long is probably not millis since 
epoch UTC; millis as string is not in acceptable range");
+                }
+            }
+        }
+        return Maybe.absent("not long millis since epoch UTC");
+    }
+
+    private final static String DIGIT = "\\d";
+    private final static String LETTER = "\\p{L}";
+    private final static String COMMON_SEPARATORS = "-\\.";
+    private final static String TIME_SEPARATOR = COMMON_SEPARATORS+":";
+    private final static String DATE_SEPARATOR = COMMON_SEPARATORS+"/ ";
+    private final static String DATE_TIME_ANY_ORDER_GROUP_SEPARATOR = 
COMMON_SEPARATORS+":/ ";
+
+    private final static String DATE_ONLY_WITH_INNER_SEPARATORS = 
+            namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT) +
+            anyChar(DATE_SEPARATOR) +
+            namedGroup("month", options(optionally(DIGIT)+DIGIT, 
anyChar(LETTER)+"+")) +
+            anyChar(DATE_SEPARATOR) +
+            namedGroup("day", optionally(DIGIT)+DIGIT);
+    private final static String DATE_WORDS_2 = 
+            namedGroup("month", anyChar(LETTER)+"+") +
+            anyChar(DATE_SEPARATOR) +
+            namedGroup("day", optionally(DIGIT)+DIGIT) +
+            ",?"+anyChar(DATE_SEPARATOR)+"+" +
+            namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT);
+    // we could parse NN-NN-NNNN as DD-MM-YYYY always, but could be confusing 
for MM-DD-YYYY oriented people, so require month named
+    private final static String DATE_WORDS_3 = 
+            namedGroup("day", optionally(DIGIT)+DIGIT) +
+            anyChar(DATE_SEPARATOR) +
+            namedGroup("month", anyChar(LETTER)+"+") +
+            ",?"+anyChar(DATE_SEPARATOR)+"+" +
+            namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT);
+
+    private final static String DATE_ONLY_NO_SEPARATORS = 
+            namedGroup("year", DIGIT+DIGIT+DIGIT+DIGIT) +
+            namedGroup("month", DIGIT+DIGIT) +
+            namedGroup("day", DIGIT+DIGIT);
+
+    private final static String MERIDIAN = 
anyChar("aApP")+optionally(anyChar("mM"));
+    private final static String TIME_ONLY_WITH_INNER_SEPARATORS = 
+            namedGroup("hours", optionally(DIGIT)+DIGIT)+
+            optionally(
+                anyChar(TIME_SEPARATOR)+
+                namedGroup("mins", DIGIT+DIGIT)+
+                optionally(
+                    anyChar(TIME_SEPARATOR)+
+                    namedGroup("secs", DIGIT+DIGIT+optionally( 
optionally("\\.")+DIGIT+"+"))))+
+            optionally(" *" + namedGroup("meridian", 
notMatching(LETTER+LETTER+LETTER)+MERIDIAN));
+    private final static String TIME_ONLY_NO_SEPARATORS = 
+            namedGroup("hours", DIGIT+DIGIT)+
+            namedGroup("mins", DIGIT+DIGIT)+
+            optionally(
+                namedGroup("secs", DIGIT+DIGIT+optionally( 
optionally("\\.")+DIGIT+"+")))+
+                namedGroup("meridian", "");
+
+    private final static String TZ_CODE = 
+            namedGroup("tzCode",
+                notMatching(MERIDIAN+options("$", anyChar("^"+LETTER))) + // 
not AM or PM
+                anyChar(LETTER)+"+"+anyChar(LETTER+DIGIT+"\\/\\-\\' _")+"*");
+    private final static String TIME_ZONE_SIGNED_OFFSET = 
+            namedGroup("tz", 
+                options(
+                    namedGroup("tzOffset", options("\\+", "-")+
+                        
DIGIT+optionally(DIGIT)+optionally(optionally(":")+DIGIT+DIGIT)), 
+                    optionally("\\+")+TZ_CODE));
+    private final static String TIME_ZONE_OPTIONALLY_SIGNED_OFFSET = 
+            namedGroup("tz", 
+                options(
+                    namedGroup("tzOffset", options("\\+", "-", " ")+
+                        options("0"+DIGIT, "10", "11", 
"12")+optionally(optionally(":")+DIGIT+DIGIT)), 
+                    TZ_CODE));
+
+    private static String getDateTimeSeparatorPattern(String extraChars) {
+        return 
+            options(
+                " 
+"+optionally(anyChar(DATE_TIME_ANY_ORDER_GROUP_SEPARATOR+extraChars+",")),
+                anyChar(DATE_TIME_ANY_ORDER_GROUP_SEPARATOR+extraChars+",")) +
+            anyChar(DATE_TIME_ANY_ORDER_GROUP_SEPARATOR+extraChars)+"*";
+    }
+    
+    @SuppressWarnings("deprecation")
+    // we have written our own parsing because the alternatives were either 
too specific or too general
+    // java and apache and even joda-time are too specific, and would require 
explosion of patterns to be flexible;
+    // Natty - https://github.com/joestelmach/natty - is very cool, but it 
drags in ANTLR,
+    // it doesn't support dashes between date and time, and 
+    // it encourages relative time which would be awesome but only if we 
resolved it on read
+    // (however there is natty code to parseDateNatty in the git history if we 
did want to use it)
+    private static Maybe<Calendar> 
parseCalendarSimpleFlexibleFormatParser(String input) {
+        input = input.trim();
+
+        String[] DATE_PATTERNS = new String[] {
+            DATE_ONLY_WITH_INNER_SEPARATORS,
+            DATE_ONLY_NO_SEPARATORS,
+            DATE_WORDS_2,
+            DATE_WORDS_3,            
+        };
+        String[] TIME_PATTERNS = new String[] {
+            TIME_ONLY_WITH_INNER_SEPARATORS,
+            TIME_ONLY_NO_SEPARATORS            
+        };
+        String[] TZ_PATTERNS = new String[] {
+            // space then time zone with sign (+-) or code is preferred
+            optionally(getDateTimeSeparatorPattern("")) + " " + 
TIME_ZONE_SIGNED_OFFSET,
+            // then no TZ - but declare the named groups
+            namedGroup("tz", namedGroup("tzOffset", "")+namedGroup("tzCode", 
"")),
+            // then any separator then offset with sign
+            getDateTimeSeparatorPattern("") + TIME_ZONE_SIGNED_OFFSET,
+            
+            // try parsing with enforced separators before TZ first 
+            // (so e.g. in the case of DATE-0100, the -0100 is the time, not 
the timezone)
+            // then relax below (e.g. in the case of DATE-TIME+0100)
+            
+            // finally match DATE-TIME-1000 as time zone -1000
+            // or DATE-TIME 1000 as TZ +1000 in case a + was supplied but 
converted to ' ' by web
+            // (but be stricter about the format, two or four digits required, 
and hours <= 12 so as not to confuse with a year)
+            optionally(getDateTimeSeparatorPattern("")) + 
TIME_ZONE_OPTIONALLY_SIGNED_OFFSET
+        };
+        
+        List<String> basePatterns = MutableList.of();
+        
+        // patterns with date first
+        String[] DATE_PATTERNS_UNCLOSED = new String[] {
+            // separator before time *required* if date had separators
+            DATE_ONLY_WITH_INNER_SEPARATORS + 
"("+getDateTimeSeparatorPattern("Tt"),
+            // separator before time optional if date did not have separators
+            DATE_ONLY_NO_SEPARATORS + 
"("+optionally(getDateTimeSeparatorPattern("Tt")),
+            // separator before time required if date has words
+            DATE_WORDS_2 + "("+getDateTimeSeparatorPattern("Tt"),
+            DATE_WORDS_3 + "("+getDateTimeSeparatorPattern("Tt"),
+        };
+        for (String tzP: TZ_PATTERNS)
+            for (String dateP: DATE_PATTERNS_UNCLOSED)
+                for (String timeP: TIME_PATTERNS)
+                    basePatterns.add(dateP + timeP+")?" + tzP);
+        
+        // also allow time first, with TZ after, then before
+        for (String tzP: TZ_PATTERNS)
+            for (String dateP: DATE_PATTERNS)
+                for (String timeP: TIME_PATTERNS)
+                    basePatterns.add(timeP + getDateTimeSeparatorPattern("") + 
dateP + tzP);
+        // also allow time first, with TZ after, then before
+        for (String tzP: TZ_PATTERNS)
+            for (String dateP: DATE_PATTERNS)
+                for (String timeP: TIME_PATTERNS)
+                    basePatterns.add(timeP + tzP + 
getDateTimeSeparatorPattern("") + dateP);
+
+        Maybe<Matcher> mm = Maybe.absent();
+        for (String p: basePatterns) {
+            mm = match(p, input);
+            if (mm.isPresent()) break;
+        }
+        if (mm.isPresent()) {
+            Matcher m = mm.get();
+            Calendar result;
+
+            String tz = m.group("tz");
+            
+            int year = Integer.parseInt(m.group("year"));
+            int day = Integer.parseInt(m.group("day"));
+            
+            String monthS = m.group("month");
+            int month;
+            if (monthS.matches(DIGIT+"+")) {
+                month = Integer.parseInt(monthS)-1;
+            } else {
+                try {
+                    month = new SimpleDateFormat("yyyy-MMM-dd", 
Locale.ROOT).parse("2015-"+monthS+"-15").getMonth();
+                } catch (ParseException e) {
+                    return Maybe.absent("Unknown date format '"+input+"': 
invalid month '"+monthS+"'; try http://yaml.org/type/timestamp.html format e.g. 
2015-06-15 16:00:00 +0000");
+                }
+            }
+            
+            if (Strings.isNonBlank(tz)) {
+                TimeZone tzz = null;
+                String tzCode = m.group("tzCode");
+                if (Strings.isNonBlank(tzCode)) {
+                    tz = tzCode;
+                }
+                if (tz.matches(DIGIT+"+")) {
+                    // stick a plus in front in case it was submitted by a web 
form and turned into a space
+                    tz = "+"+tz;
+                } else {
+                    tzz = getTimeZone(tz);
+                }
+                if (tzz==null) {
+                    Maybe<Matcher> tmm = match(" 
?(?<tzH>(\\+|\\-||)"+DIGIT+optionally(DIGIT)+")"+optionally(optionally(":")+namedGroup("tzM",
 DIGIT+DIGIT)), tz);
+                    if (tmm.isAbsent()) {
+                        return Maybe.absent("Unknown date format '"+input+"': 
invalid timezone '"+tz+"'; try http://yaml.org/type/timestamp.html format e.g. 
2015-06-15 16:00:00 +0000");
+                    }
+                    Matcher tm = tmm.get();
+                    String tzM = tm.group("tzM");
+                    int offset = (60*Integer.parseInt(tm.group("tzH")) + 
Integer.parseInt("0"+(tzM!=null ? tzM : "")))*60;
+                    tzz = new SimpleTimeZone(offset*1000, tz);
+                }
+                tz = getTimeZoneOffsetString(tzz, year, month, day);
+                result = new GregorianCalendar(tzz);
+            } else {
+                result = new GregorianCalendar();
+            }
+            result.clear();
+            
+            result.set(Calendar.YEAR, year);
+            result.set(Calendar.MONTH, month);
+            result.set(Calendar.DAY_OF_MONTH, day);
+            if (m.group("hours")!=null) {
+                int hours = Integer.parseInt(m.group("hours"));
+                String meridian = m.group("meridian");
+                if (Strings.isNonBlank(meridian) && 
meridian.toLowerCase().startsWith("p")) {
+                    if (hours>12) {
+                        return Maybe.absent("Unknown date format '"+input+"': 
can't be "+hours+" PM; try http://yaml.org/type/timestamp.html format e.g. 
2015-06-15 16:00:00 +0000");
+                    }
+                    hours += 12;
+                }
+                result.set(Calendar.HOUR_OF_DAY, hours);
+                String minsS = m.group("mins");
+                if (Strings.isNonBlank(minsS)) {
+                    result.set(Calendar.MINUTE, Integer.parseInt(minsS));
+                }
+                String secsS = m.group("secs");
+                if (Strings.isBlank(secsS)) {
+                    // leave at zero
+                } else if (secsS.matches(DIGIT+DIGIT+"?")) {
+                    result.set(Calendar.SECOND, Integer.parseInt(secsS));
+                } else {
+                    double s = Double.parseDouble(secsS);
+                    if (secsS.indexOf('.')>=0) {
+                        // accept
+                    } else if (secsS.length()==5) {
+                        // allow ssSSS with no punctuation
+                        s = s/=1000;
+                    } else {
+                        return Maybe.absent("Unknown date format '"+input+"': 
invalid seconds '"+secsS+"'; try http://yaml.org/type/timestamp.html format 
e.g. 2015-06-15 16:00:00 +0000");
+                    }
+                    result.set(Calendar.SECOND, (int)s);
+                    result.set(Calendar.MILLISECOND, (int)((s*1000) % 1000));
+                }
+            }
+            
+            return Maybe.of(result);
+        }
+        return Maybe.absent("Unknown date format '"+input+"'; try 
http://yaml.org/type/timestamp.html format e.g. 2015-06-15 16:00:00 +0000");
+    }
+    
+    public static TimeZone getTimeZone(String code) {
+        if (code.indexOf('/')==-1) {
+            if ("Z".equals(code)) return TIME_ZONE_UTC;
+            if ("UTC".equals(code)) return TIME_ZONE_UTC;
+            if ("GMT".equals(code)) return TIME_ZONE_UTC;
+            
+            // get the time zone -- most short codes aren't accepted, so 
accept (and prefer) certain common codes
+            if ("EST".equals(code)) return getTimeZone("America/New_York");
+            if ("EDT".equals(code)) return getTimeZone("America/New_York");
+            if ("PST".equals(code)) return getTimeZone("America/Los_Angeles");
+            if ("PDT".equals(code)) return getTimeZone("America/Los_Angeles");
+            if ("CST".equals(code)) return getTimeZone("America/Chicago");
+            if ("CDT".equals(code)) return getTimeZone("America/Chicago");
+            if ("MST".equals(code)) return getTimeZone("America/Denver");
+            if ("MDT".equals(code)) return getTimeZone("America/Denver");
+
+            if ("BST".equals(code)) return getTimeZone("Europe/London");  // 
otherwise BST is Bangladesh!
+            if ("CEST".equals(code)) return getTimeZone("Europe/Paris");
+            // IST falls through to below, where it is treated as India (not 
Irish); IDT not recognised
+        }
+        
+        TimeZone tz = TimeZone.getTimeZone(code);
+        if (tz!=null && !tz.equals(TimeZone.getTimeZone("GMT"))) {
+            // recognized
+            return tz;
+        }
+        // possibly unrecognized -- GMT returned if not known, bad TimeZone 
API!
+        String timeZones[] = TimeZone.getAvailableIDs();
+        for (String tzs: timeZones) {
+            if (tzs.equals(code)) return tz;
+        }
+        // definitely unrecognized
+        return null;
+    }
+    
+    /** convert a TimeZone e.g. Europe/London to an offset string as at the 
given day, e.g. +0100 or +0000 depending daylight savings,
+     * absent with nice error if zone unknown */
+    public static Maybe<String> getTimeZoneOffsetString(String tz, int year, 
int month, int day) {
+        TimeZone tzz = getTimeZone(tz);
+        if (tzz==null) return Maybe.absent("Unknown time zone code: "+tz);
+        return Maybe.of(getTimeZoneOffsetString(tzz, year, month, day));
+    }
+    
+    /** as {@link #getTimeZoneOffsetString(String, int, int, int)} where the 
{@link TimeZone} is already instantiated */
+    @SuppressWarnings("deprecation")
+    public static String getTimeZoneOffsetString(TimeZone tz, int year, int 
month, int day) {
+        int tzMins = tz.getOffset(new Date(year, month, 
day).getTime())/60/1000;
+        String tzStr = (tzMins<0 ? "-" : "+") + 
Strings.makePaddedString(""+(Math.abs(tzMins)/60), 2, "0", 
"")+Strings.makePaddedString(""+(Math.abs(tzMins)%60), 2, "0", "");
+        return tzStr;
+    }
+
+    private static String namedGroup(String name, String pattern) {
+        return "(?<"+name+">"+pattern+")";
+    }
+    private static String anyChar(String charSet) {
+        return "["+charSet+"]";
+    }
+    private static String optionally(String pattern) {
+        return "("+pattern+")?";
+    }
+    private static String options(String ...patterns) {
+        return "("+Strings.join(patterns,"|")+")";
+    }
+    private static String notMatching(String pattern) {
+        return "(?!"+pattern+")";
+    }
+    
+    private static Maybe<Matcher> match(String pattern, String input) {
+        Matcher m = Pattern.compile("^"+pattern+"$").matcher(input);
+        if (m.find()) return Maybe.of(m);
+        return Maybe.absent();
+    }
+
+    /**
+     * Parses the given date, accepting either a UTC timestamp (i.e. a long), 
or a formatted date.
+     * <p>
+     * If no time zone supplied, this defaults to the TZ configured at the 
brooklyn server.
+     * 
+     * @deprecated since 0.7.0 use {@link #parseCalendar(String)} for general 
or {@link #parseCalendarFormat(String, DateFormat)} for a format,
+     * plus {@link #parseCalendarUtc(String)} if you want to accept UTC
+     */
+    public static Date parseDateString(String dateString, DateFormat format) {
+        Maybe<Calendar> r = parseCalendarFormat(dateString, format);
+        if (r.isPresent()) return r.get().getTime();
+        
+        r = parseCalendarUtc(dateString);
+        if (r.isPresent()) return r.get().getTime();
+
+        throw new IllegalArgumentException("Date " + dateString + " cannot be 
parsed as UTC millis or using format " + format);
+    }
+    public static Maybe<Calendar> parseCalendarFormat(String dateString, 
String format) {
+        return parseCalendarFormat(dateString, new SimpleDateFormat(format, 
Locale.ROOT));
+    }
+    public static Maybe<Calendar> parseCalendarFormat(String dateString, 
DateFormat format) {
+        if (dateString == null) { 
+            throw new NumberFormatException("GeneralHelper.parseDateString 
cannot parse a null string");
+        }
+        Preconditions.checkNotNull(format, "date format");
+        dateString = dateString.trim();
+        
+        ParsePosition p = new ParsePosition(0);
+        Date result = format.parse(dateString, p);
+        if (result!=null) {
+            // accept results even if the entire thing wasn't parsed, as 
enough was to match the requested format
+            return Maybe.of(newCalendarFromDate(result));
+        }
+        if (log.isTraceEnabled()) log.trace("Could not parse date 
"+dateString+" using format "+format+": "+p);
+        return Maybe.absent();
+    }
+
+    /** removes milliseconds from the date object; needed if serializing to 
ISO-8601 format 
+     * and want to serialize back and get the same data */
+    public static Date dropMilliseconds(Date date) {
+        return date==null ? null : date.getTime()%1000!=0 ? new 
Date(date.getTime() - (date.getTime()%1000)) : date;
+    }
+
+    /** returns the duration elapsed since the given timestamp (UTC) */
+    public static Duration elapsedSince(long timestamp) {
+        return Duration.millis(System.currentTimeMillis() - timestamp);
+    }
+    
+    /** true iff it has been longer than the given duration since the given 
timestamp */
+    public static boolean hasElapsedSince(long timestamp, Duration duration) {
+        return elapsedSince(timestamp).compareTo(duration) > 0;
+    }
+
+    /** more readable and shorter convenience for System.currentTimeMillis() */
+    public static long now() {
+        return System.currentTimeMillis();
+    }
+    
+}

http://git-wip-us.apache.org/repos/asf/incubator-brooklyn/blob/cf2f7a93/utils/common/src/main/java/org/apache/brooklyn/util/yaml/Yamls.java
----------------------------------------------------------------------
diff --git 
a/utils/common/src/main/java/org/apache/brooklyn/util/yaml/Yamls.java 
b/utils/common/src/main/java/org/apache/brooklyn/util/yaml/Yamls.java
new file mode 100644
index 0000000..1697097
--- /dev/null
+++ b/utils/common/src/main/java/org/apache/brooklyn/util/yaml/Yamls.java
@@ -0,0 +1,553 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.brooklyn.util.yaml;
+
+import java.io.Reader;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.annotation.Nullable;
+
+import org.apache.brooklyn.util.collections.Jsonya;
+import org.apache.brooklyn.util.collections.MutableList;
+import org.apache.brooklyn.util.exceptions.Exceptions;
+import org.apache.brooklyn.util.exceptions.UserFacingException;
+import org.apache.brooklyn.util.text.Strings;
+import org.apache.brooklyn.util.yaml.Yamls;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.error.Mark;
+import org.yaml.snakeyaml.nodes.MappingNode;
+import org.yaml.snakeyaml.nodes.Node;
+import org.yaml.snakeyaml.nodes.NodeId;
+import org.yaml.snakeyaml.nodes.NodeTuple;
+import org.yaml.snakeyaml.nodes.ScalarNode;
+import org.yaml.snakeyaml.nodes.SequenceNode;
+
+import com.google.common.annotations.Beta;
+import com.google.common.collect.Iterables;
+
+public class Yamls {
+
+    private static final Logger log = LoggerFactory.getLogger(Yamls.class);
+
+    /** returns the given (yaml-parsed) object as the given yaml type.
+     * <p>
+     * if the object is an iterable or iterator this method will fully expand 
it as a list. 
+     * if the requested type is not an iterable or iterator, and the list 
contains a single item, this will take that single item.
+     * <p>
+     * in other cases this method simply does a type-check and cast (no other 
type coercion).
+     * <p>
+     * @throws IllegalArgumentException if the input is an iterable not 
containing a single element,
+     *   and the cast is requested to a non-iterable type 
+     * @throws ClassCastException if cannot be casted */
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    public static <T> T getAs(Object x, Class<T> type) {
+        if (x==null) return null;
+        if (x instanceof Iterable || x instanceof Iterator) {
+            List result = new ArrayList();
+            Iterator xi;
+            if (Iterator.class.isAssignableFrom(x.getClass())) {
+                xi = (Iterator)x;
+            } else {
+                xi = ((Iterable)x).iterator();
+            }
+            while (xi.hasNext()) {
+                result.add( xi.next() );
+            }
+            if (type.isAssignableFrom(List.class)) return (T)result;
+            if (type.isAssignableFrom(Iterator.class)) return 
(T)result.iterator();
+            x = Iterables.getOnlyElement(result);
+        }
+        if (type.isInstance(x)) return (T)x;
+        throw new ClassCastException("Cannot convert "+x+" ("+x.getClass()+") 
to "+type);
+    }
+
+    /**
+     * Parses the given yaml, and walks the given path to return the 
referenced object.
+     * 
+     * @see #getAt(Object, List)
+     */
+    @Beta
+    public static Object getAt(String yaml, List<String> path) {
+        Iterable<Object> result = new org.yaml.snakeyaml.Yaml().loadAll(yaml);
+        Object current = result.iterator().next();
+        return getAtPreParsed(current, path);
+    }
+    
+    /** 
+     * For pre-parsed yaml, walks the maps/lists to return the given sub-item.
+     * In the given path:
+     * <ul>
+     *   <li>A vanilla string is assumed to be a key into a map.
+     *   <li>A string in the form like "[0]" is assumed to be an index into a 
list
+     * </ul>
+     * 
+     * Also see {@link Jsonya}, such as {@code 
Jsonya.of(current).at(path).get()}.
+     * 
+     * @return The object at the given path, or {@code null} if that path does 
not exist.
+     */
+    @Beta
+    @SuppressWarnings("unchecked")
+    public static Object getAtPreParsed(Object current, List<String> path) {
+        for (String pathPart : path) {
+            if (pathPart.startsWith("[") && pathPart.endsWith("]")) {
+                String index = pathPart.substring(1, pathPart.length()-1);
+                try {
+                    current = Iterables.get((Iterable<?>)current, 
Integer.parseInt(index));
+                } catch (NumberFormatException e) {
+                    throw new IllegalArgumentException("Invalid index 
'"+index+"', in path "+path);
+                } catch (IndexOutOfBoundsException e) {
+                    throw new IllegalArgumentException("Invalid index 
'"+index+"', in path "+path);
+                }
+            } else {
+                current = ((Map<String, ?>)current).get(pathPart);
+            }
+            if (current == null) return null;
+        }
+        return current;
+    }
+
+    @SuppressWarnings("rawtypes")
+    public static void dump(int depth, Object r) {
+        if (r instanceof Iterable) {
+            for (Object ri : ((Iterable)r))
+                dump(depth+1, ri);
+        } else if (r instanceof Map) {
+            for (Object re: ((Map)r).entrySet()) {
+                for (int i=0; i<depth; i++) System.out.print(" ");
+                System.out.println(((Entry)re).getKey()+":");
+                dump(depth+1, ((Entry)re).getValue());
+            }
+        } else {
+            for (int i=0; i<depth; i++) System.out.print(" ");
+            if (r==null) System.out.println("<null>");
+            else System.out.println("<"+r.getClass().getSimpleName()+">"+" 
"+r);
+        }
+    }
+
+    /** simplifies new Yaml().loadAll, and converts to list to prevent 
single-use iterable bug in yaml */
+    @SuppressWarnings("unchecked")
+    public static Iterable<Object> parseAll(String yaml) {
+        Iterable<Object> result = new org.yaml.snakeyaml.Yaml().loadAll(yaml);
+        return (List<Object>) getAs(result, List.class);
+    }
+
+    /** as {@link #parseAll(String)} */
+    @SuppressWarnings("unchecked")
+    public static Iterable<Object> parseAll(Reader yaml) {
+        Iterable<Object> result = new org.yaml.snakeyaml.Yaml().loadAll(yaml);
+        return (List<Object>) getAs(result, List.class);
+    }
+
+    public static Object removeMultinameAttribute(Map<String,Object> obj, 
String ...equivalentNames) {
+        Object result = null;
+        for (String name: equivalentNames) {
+            Object candidate = obj.remove(name);
+            if (candidate!=null) {
+                if (result==null) result = candidate;
+                else if (!result.equals(candidate)) {
+                    log.warn("Different values for attributes 
"+Arrays.toString(equivalentNames)+"; " +
+                            "preferring '"+result+"' to '"+candidate+"'");
+                }
+            }
+        }
+        return result;
+    }
+
+    public static Object getMultinameAttribute(Map<String,Object> obj, String 
...equivalentNames) {
+        Object result = null;
+        for (String name: equivalentNames) {
+            Object candidate = obj.get(name);
+            if (candidate!=null) {
+                if (result==null) result = candidate;
+                else if (!result.equals(candidate)) {
+                    log.warn("Different values for attributes 
"+Arrays.toString(equivalentNames)+"; " +
+                            "preferring '"+result+"' to '"+candidate+"'");
+                }
+            }
+        }
+        return result;
+    }
+    
+    @Beta
+    public static class YamlExtract {
+        String yaml;
+        NodeTuple focusTuple;
+        Node prev, key, focus, next;
+        Exception error;
+        boolean includeKey = false, includePrecedingComments = true, 
includeOriginalIndentation = false;
+        
+        private int indexStart(Node node, boolean defaultIsYamlEnd) {
+            if (node==null) return defaultIsYamlEnd ? yaml.length() : 0;
+            return index(node.getStartMark());
+        }
+        private int indexEnd(Node node, boolean defaultIsYamlEnd) {
+            if (!found() || node==null) return defaultIsYamlEnd ? 
yaml.length() : 0;
+            return index(node.getEndMark());
+        }
+        private int index(Mark mark) {
+            try {
+                return mark.getIndex();
+            } catch (NoSuchMethodError e) {
+                try {
+                    getClass().getClassLoader().loadClass("org.testng.TestNG");
+                } catch (ClassNotFoundException e1) {
+                    // not using TestNG
+                    Exceptions.propagateIfFatal(e1);
+                    throw e;
+                }
+                if (!LOGGED_TESTNG_WARNING.getAndSet(true)) {
+                    log.warn("Detected TestNG/SnakeYAML version 
incompatibilities: "
+                        + "some YAML source reconstruction will be 
unavailable. "
+                        + "This can happen with TestNG plugins which force an 
older version of SnakeYAML "
+                        + "which does not support Mark.getIndex. "
+                        + "It should not occur from maven CLI runs. "
+                        + "(Subsequent occurrences will be silently dropped, 
and source code reconstructed from YAML.)");
+                }
+                // using TestNG
+                throw new KnownClassVersionException(e);
+            }
+        }
+        
+        static AtomicBoolean LOGGED_TESTNG_WARNING = new AtomicBoolean();
+        static class KnownClassVersionException extends IllegalStateException {
+            private static final long serialVersionUID = -1620847775786753301L;
+            public KnownClassVersionException(Throwable e) {
+                super("Class version error. This can happen if using a TestNG 
plugin in your IDE "
+                    + "which is an older version, dragging in an older version 
of SnakeYAML which does not support Mark.getIndex.", e);
+            }
+        }
+
+        public int getEndOfPrevious() {
+            return indexEnd(prev, false);
+        }
+        @Nullable public Node getKey() {
+            return key;
+        }
+        public Node getResult() {
+            return focus;
+        }
+        public int getStartOfThis() {
+            if (includeKey && focusTuple!=null) return 
indexStart(focusTuple.getKeyNode(), false);
+            return indexStart(focus, false);
+        }
+        private int getStartColumnOfThis() {
+            if (includeKey && focusTuple!=null) return 
focusTuple.getKeyNode().getStartMark().getColumn();
+            return focus.getStartMark().getColumn();
+        }
+        public int getEndOfThis() {
+            return getEndOfThis(false);
+        }
+        private int getEndOfThis(boolean goToEndIfNoNext) {
+            if (next==null && goToEndIfNoNext) return yaml.length();
+            return indexEnd(focus, false);
+        }
+        public int getStartOfNext() {
+            return indexStart(next, true);
+        }
+
+        private static int initialWhitespaceLength(String x) {
+            int i=0;
+            while (i < x.length() && x.charAt(i)==' ') i++;
+            return i;
+        }
+        
+        public String getFullYamlTextOriginal() {
+            return yaml;
+        }
+
+        /** Returns the original YAML with the found item replaced by the 
given replacement YAML.
+         * @param replacement YAML to put in for the found item;
+         * this YAML typically should not have any special indentation -- if 
required when replacing it will be inserted.
+         * <p>
+         * if replacing an inline map entry, the supplied entry must follow 
the structure being replaced;
+         * for example, if replacing the value in <code>key: value</code> with 
a map,
+         * supplying a replacement <code>subkey: value</code> would result in 
invalid yaml;
+         * the replacement must be supplied with a newline, either before the 
subkey or after.
+         * (if unsure we believe it is always valid to include an initial 
newline or comment with newline.)
+         */
+        public String getFullYamlTextWithExtractReplaced(String replacement) {
+            if (!found()) throw new IllegalStateException("Cannot perform 
replacement when item was not matched.");
+            String result = yaml.substring(0, getStartOfThis());
+            
+            String[] newLines = replacement.split("\n");
+            for (int i=1; i<newLines.length; i++)
+                newLines[i] = Strings.makePaddedString("", 
getStartColumnOfThis(), "", " ") + newLines[i];
+            result += Strings.lines(newLines);
+            if (replacement.endsWith("\n")) result += "\n";
+            
+            int end = getEndOfThis();
+            result += yaml.substring(end);
+            
+            return result;
+        }
+
+        /** Specifies whether the key should be included in the found text, 
+         * when calling {@link #getMatchedYamlText()} or {@link 
#getFullYamlTextWithExtractReplaced(String)},
+         * if the found item is a map entry.
+         * Defaults to false.
+         * @return this object, for use in a fluent constructions
+         */
+        public YamlExtract withKeyIncluded(boolean includeKey) {
+            this.includeKey = includeKey;
+            return this;
+        }
+
+        /** Specifies whether comments preceding the found item should be 
included, 
+         * when calling {@link #getMatchedYamlText()} or {@link 
#getFullYamlTextWithExtractReplaced(String)}.
+         * This will not include comments which are indented further than the 
item,
+         * as those will typically be aligned with the previous item
+         * (whereas comments whose indentation is the same or less than the 
found item
+         * will typically be aligned with this item).
+         * Defaults to true.
+         * @return this object, for use in a fluent constructions
+         */
+        public YamlExtract withPrecedingCommentsIncluded(boolean 
includePrecedingComments) {
+            this.includePrecedingComments = includePrecedingComments;
+            return this;
+        }
+
+        /** Specifies whether the original indentation should be preserved
+         * (and in the case of the first line, whether whitespace should be 
inserted so its start column is preserved), 
+         * when calling {@link #getMatchedYamlText()}.
+         * Defaults to false, the returned text will be outdented as far as 
possible.
+         * @return this object, for use in a fluent constructions
+         */
+        public YamlExtract withOriginalIndentation(boolean 
includeOriginalIndentation) {
+            this.includeOriginalIndentation = includeOriginalIndentation;
+            return this;
+        }
+
+        @Beta
+        public String getMatchedYamlTextOrWarn() {
+            try {
+                return getMatchedYamlText();
+            } catch (Exception e) {
+                Exceptions.propagateIfFatal(e);
+                if (e instanceof KnownClassVersionException) {
+                    log.debug("Known class version exception; no yaml text 
being matched for "+this+": "+e);
+                } else {
+                    if (e instanceof UserFacingException) {
+                        log.warn("Unable to match yaml text in "+this+": 
"+e.getMessage());
+                    } else {
+                        log.warn("Unable to match yaml text in "+this+": "+e, 
e);
+                    }
+                }
+                return null;
+            }
+        }
+        
+        @Beta
+        public String getMatchedYamlText() {
+            if (!found()) return null;
+            
+            String[] body = yaml.substring(getStartOfThis(), 
getEndOfThis(true)).split("\n", -1);
+            
+            int firstLineIndentationOfFirstThing;
+            if (focusTuple!=null) {
+                firstLineIndentationOfFirstThing = 
focusTuple.getKeyNode().getStartMark().getColumn();
+            } else {
+                firstLineIndentationOfFirstThing = 
focus.getStartMark().getColumn();
+            }
+            int firstLineIndentationToAdd;
+            if (focusTuple!=null && (includeKey || body.length==1)) {
+                firstLineIndentationToAdd = 
focusTuple.getKeyNode().getStartMark().getColumn();
+            } else {
+                firstLineIndentationToAdd = focus.getStartMark().getColumn();
+            }
+            
+            
+            String firstLineIndentationToAddS = Strings.makePaddedString("", 
firstLineIndentationToAdd, "", " ");
+            String subsequentLineIndentationToRemoveS = 
firstLineIndentationToAddS;
+
+/* complexities of indentation:
+
+x: a
+ bc
+ 
+should become
+
+a
+ bc
+
+whereas
+
+- a: 0
+  b: 1
+  
+selecting 0 should give
+
+a: 0
+b: 1
+
+ */
+            List<String> result = MutableList.of();
+            if (includePrecedingComments) {
+                if (getEndOfPrevious() > getStartOfThis()) {
+                    throw new UserFacingException("YAML not in expected 
format; when scanning, previous end "+getEndOfPrevious()+" exceeds this start 
"+getStartOfThis());
+                }
+                String[] preceding = yaml.substring(getEndOfPrevious(), 
getStartOfThis()).split("\n");
+                // suppress comments which are on the same line as the 
previous item or indented more than firstLineIndentation,
+                // ensuring appropriate whitespace is added to preceding[0] if 
it starts mid-line
+                if (preceding.length>0 && prev!=null) {
+                    preceding[0] = Strings.makePaddedString("", 
prev.getEndMark().getColumn(), "", " ") + preceding[0];
+                }
+                for (String p: preceding) {
+                    int w = initialWhitespaceLength(p);
+                    p = p.substring(w);
+                    if (p.startsWith("#")) {
+                        // only add if the hash is not indented further than 
the first line
+                        if (w <= firstLineIndentationOfFirstThing) {
+                            if (includeOriginalIndentation) p = 
firstLineIndentationToAddS + p;
+                            result.add(p);
+                        }
+                    }
+                }
+            }
+            
+            boolean doneFirst = false;
+            for (String p: body) {
+                if (!doneFirst) {
+                    if (includeOriginalIndentation) {
+                        // have to insert the right amount of spacing
+                        p = firstLineIndentationToAddS + p;
+                    }
+                    result.add(p);
+                    doneFirst = true;
+                } else {
+                    if (includeOriginalIndentation) {
+                        result.add(p);
+                    } else {
+                        result.add(Strings.removeFromStart(p, 
subsequentLineIndentationToRemoveS));
+                    }
+                }
+            }
+            return Strings.join(result, "\n");
+        }
+        
+        boolean found() {
+            return focus != null;
+        }
+        
+        public Exception getError() {
+            return error;
+        }
+        
+        @Override
+        public String toString() {
+            return 
"Extract["+focus+";prev="+prev+";key="+key+";next="+next+"]";
+        }
+    }
+    
+    private static void findTextOfYamlAtPath(YamlExtract result, int 
pathIndex, Object ...path) {
+        if (pathIndex>=path.length) {
+            // we're done
+            return;
+        }
+        
+        Object pathItem = path[pathIndex];
+        Node node = result.focus;
+        
+        if (node.getNodeId()==NodeId.mapping && pathItem instanceof String) {
+            // find key
+            Iterator<NodeTuple> ti = ((MappingNode)node).getValue().iterator();
+            while (ti.hasNext()) {
+                NodeTuple t = ti.next();
+                Node key = t.getKeyNode();
+                if (key.getNodeId()==NodeId.scalar) {
+                    if (pathItem.equals( ((ScalarNode)key).getValue() )) {
+                        result.key = key;
+                        result.focus = t.getValueNode();
+                        if (pathIndex+1<path.length) {
+                            // there are more path items, so the key here is a 
previous node
+                            result.prev = key;
+                        } else {
+                            result.focusTuple = t;
+                        }
+                        findTextOfYamlAtPath(result, pathIndex+1, path);
+                        if (result.next==null) {
+                            if (ti.hasNext()) result.next = 
ti.next().getKeyNode();
+                        }
+                        return;
+                    } else {
+                        result.prev = t.getValueNode();
+                    }
+                } else {
+                    throw new IllegalStateException("Key "+key+" is not a 
scalar, searching for "+pathItem+" at depth "+pathIndex+" of 
"+Arrays.asList(path));
+                }
+            }
+            throw new IllegalStateException("Did not find "+pathItem+" in 
"+node+" at depth "+pathIndex+" of "+Arrays.asList(path));
+            
+        } else if (node.getNodeId()==NodeId.sequence && pathItem instanceof 
Number) {
+            // find list item
+            List<Node> nl = ((SequenceNode)node).getValue();
+            int i = ((Number)pathItem).intValue();
+            if (i>=nl.size()) 
+                throw new IllegalStateException("Index "+i+" is out of bounds 
in "+node+", searching for "+pathItem+" at depth "+pathIndex+" of 
"+Arrays.asList(path));
+            if (i>0) result.prev = nl.get(i-1);
+            result.key = null;
+            result.focus = nl.get(i);
+            findTextOfYamlAtPath(result, pathIndex+1, path);
+            if (result.next==null) {
+                if (nl.size()>i+1) result.next = nl.get(i+1);
+            }
+            return;
+            
+        } else {
+            throw new IllegalStateException("Node "+node+" does not match 
selector "+pathItem+" at depth "+pathIndex+" of "+Arrays.asList(path));
+        }
+        
+        // unreachable
+    }
+    
+    
+    /** Given a path, where each segment consists of a string (key) or number 
(element in list),
+     * this will find the YAML text for that element
+     * <p>
+     * If not found this will return a {@link YamlExtract} 
+     * where {@link YamlExtract#isMatch()} is false and {@link 
YamlExtract#getError()} is set. */
+    public static YamlExtract getTextOfYamlAtPath(String yaml, Object ...path) 
{
+        YamlExtract result = new YamlExtract();
+        if (yaml==null) return result;
+        try {
+            int pathIndex = 0;
+            result.yaml = yaml;
+            result.focus = new Yaml().compose(new StringReader(yaml));
+    
+            findTextOfYamlAtPath(result, pathIndex, path);
+            return result;
+        } catch (NoSuchMethodError e) {
+            throw new IllegalStateException("Class version error. This can 
happen if using a TestNG plugin in your IDE "
+                + "which is an older version, dragging in an older version of 
SnakeYAML which does not support Mark.getIndex.", e);
+        } catch (Exception e) {
+            Exceptions.propagateIfFatal(e);
+            log.debug("Unable to find element in yaml (setting in result): 
"+e);
+            result.error = e;
+            return result;
+        }
+    }
+}

Reply via email to