Skip to content
← Back to Examples

ISO 8601 Date/Time Parser

RustRubydata formatsintermediate

Parse ISO 8601 date, time, and datetime strings - the international standard for date and time representations used across systems and languages.

Key Concepts

  • calendar dates
  • week dates
  • ordinal dates
  • timezone handling
  • duration parsing

Why Parse This Format?

ISO 8601 is the international standard for date and time interchange. It's used in APIs, databases, logs, and any system that needs unambiguous date/time representation across cultures and timezones.

Use Cases

  • API date/time parsing and validation
  • Database timestamp handling
  • Log file timestamp parsing
  • Calendar application development
  • Cross-system data interchange

Input Format

ISO 8601 supports multiple formats: calendar dates (YYYY-MM-DD), week dates (YYYY-Www-D), ordinal dates (YYYY-DDD), times with optional fractions and timezones, and durations (PnYnMnDTnHnMnS).

YYYY-MM-DD | YYYY-Www-D | YYYY-DDD | HH:MM:SS | YYYY-MM-DDTHH:MM:SSZ | PnYnMnDTnHnMnS

Examples

2024-01-15VALID

Calendar date

20240115VALID

Compact calendar date (no separators)

2024-W02-1VALID

Week date (2nd week, Monday)

2024-015VALID

Ordinal date (15th day of year)

10:30:00VALID

Time

10:30:00.123VALID

Time with fractional seconds

2024-01-15T10:30:00ZVALID

UTC datetime

2024-01-15T10:30:00+09:00VALID

Datetime with timezone offset

P1Y2M3DT4H5M6SVALID

Duration (1 year, 2 months, 3 days, 4 hours, 5 minutes, 6 seconds)

PT30MVALID

Duration (30 minutes)

2024-13-45INVALID

Invalid: out of range values

Output Format

The parser returns structured types for dates (IsoDate), times (IsoTime), datetimes (IsoDateTime), and durations (IsoDuration), each with properly typed fields.

Structure

IsoDate

year, month, day, week, weekday, ordinal_day fields

IsoTime

hour, minute, second, fraction, timezone fields

IsoDateTime

date (IsoDate), time (Option<IsoTime>)

IsoDuration

years, months, days, hours, minutes, seconds fields

Visual Overview

ISO 8601 Date/Time Parser diagram

How the Code Works

This section explains how the parsing rules map to the format semantics.

Calendar Date Parsing

.rule(
    "calendar_date",
    seq(vec![
        dynamic(re("[0-9]{4}")),
        dynamic(str("-").optional()),
        dynamic(re("[0-9]{2}")),
        dynamic(str("-").optional()),
        dynamic(re("[0-9]{2}")),
    ]),
)

Calendar dates are `YYYY-MM-DD` or `YYYYMMDD`: The separator is optional for compact format.

Week Date Parsing

.rule(
    "week_date",
    seq(vec![
        dynamic(re("[0-9]{4}")),
        dynamic(str("-W")),
        dynamic(re("[0-9]{2}")), // week (01-53)
        dynamic(str("-")),
        dynamic(re("[1-7]")),    // weekday (1=Monday, 7=Sunday)
    ]),
)

Week dates use `YYYY-Www-D` format: Week 1 contains the year's first Thursday.

Ordinal Date Parsing

.rule(
    "ordinal_date",
    seq(vec![
        dynamic(re("[0-9]{4}")),
        dynamic(str("-")),
        dynamic(re("[0-9]{3}")), // day of year (001-366)
    ]),
)

Ordinal dates use day of year: Day 015 means January 15th.

Time Parsing

.rule(
    "time_basic",
    seq(vec![
        dynamic(re("[0-9]{2}")),
        dynamic(str(":").optional()),
        dynamic(re("[0-9]{2}")),
        dynamic(str(":").optional()),
        dynamic(re("[0-9]{2}")),
        dynamic(/* fraction */).optional(),
    ]),
)
.rule(
    "timezone",
    choice(vec![
        dynamic(str("Z")),
        dynamic(/* offset: +HH:MM or -HH:MM */),
    ]),
)

Times include optional fractions and timezone: `Z` indicates UTC; offsets are `+HH:MM` or `-HH:MM`.

Duration Parsing

.rule(
    "duration",
    seq(vec![
        dynamic(str("P")),
        dynamic(seq(vec![
            dynamic(re("[0-9]+Y").optional()),
            dynamic(re("[0-9]+M").optional()),
            dynamic(re("[0-9]+D").optional()),
            dynamic(seq(vec![
                dynamic(str("T")),
                dynamic(re("[0-9]+H").optional()),
                dynamic(re("[0-9]+M").optional()),
                dynamic(re("[0-9]+S").optional()),
            ]).optional()),
        ]).optional()),
    ]),
)

Durations use `PnYnMnDTnHnMnS` format: `P` starts date portion, `T` starts time portion.

Date Parsing Implementation

fn parse_date(input: &str) -> Result<IsoDate, String> {
    // Week date: YYYY-Www-D
    if input.contains("-W") {
        let parts: Vec<&str> = input.split("-W").collect();
        let year: i32 = parts[0].parse()?;
        let week_weekday: Vec<&str> = parts[1].split('-').collect();
        let week: u32 = week_weekday[0].parse()?;
        let weekday: u32 = week_weekday.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
        return Ok(IsoDate { year, week: Some(week), weekday: Some(weekday), ... });
    }

    // Ordinal date: YYYY-DDD
    if parts.len() == 2 && parts[1].len() == 3 {
        return Ok(IsoDate { year, ordinal_day: Some(day_of_year), ... });
    }

    // Calendar date: YYYY-MM-DD
    Ok(IsoDate { year, month: Some(month), day: Some(day), ... })
}

Dates are parsed by detecting format:

Date Parsing Implementation

fn parse_date(input: &str) -> Result<IsoDate, String> {
    // Week date: YYYY-Www-D
    if input.contains("-W") {
        let parts: Vec<&str> = input.split("-W").collect();
        let year: i32 = parts[0].parse()?;
        let week_weekday: Vec<&str> = parts[1].split('-').collect();
        let week: u32 = week_weekday[0].parse()?;
        let weekday: u32 = week_weekday.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
        return Ok(IsoDate { year, week: Some(week), weekday: Some(weekday), ... });
    }

    // Ordinal date: YYYY-DDD
    if parts.len() == 2 && parts[1].len() == 3 {
        return Ok(IsoDate { year, ordinal_day: Some(day_of_year), ... });
    }

    // Calendar date: YYYY-MM-DD
    Ok(IsoDate { year, month: Some(month), day: Some(day), ... })
}

Dates are parsed by detecting format:

Source Code

Rust
//! ISO 8601 Date/Time Parser Example
//!
//! This example demonstrates parsing ISO 8601 date, time, and datetime strings.
//! ISO 8601 is the international standard for date and time representations.
//!
//! Supported formats:
//! - Calendar dates: 2024-01-15, 20240115
//! - Week dates: 2024-W02-1 (year-week-weekday)
//! - Ordinal dates: 2024-015 (year-day of year)
//! - Times: 10:30:00, 10:30:00.123
//! - Timezones: Z, +02:00, -05:00
//! - Date-times: 2024-01-15T10:30:00Z
//! - Durations: P1Y2M3DT4H5M6S
//!
//! Run with: cargo run --example iso-8601/basic --no-default-features

use parsanol::portable::{
    parser_dsl::{choice, dynamic, re, seq, str, GrammarBuilder, ParsletExt},
    AstArena, Grammar, PortableParser,
};
use serde::{Deserialize, Serialize};

/// Build ISO 8601 grammar
fn build_iso8601_grammar() -> Grammar {
    GrammarBuilder::new()
        // Date components
        .rule("year", re("[0-9]{4}"))
        .rule("month", re("[0-9]{2}"))
        .rule("day", re("[0-9]{2}"))
        .rule("date_separator", str("-").optional())
        // Calendar date: YYYY-MM-DD or YYYYMMDD
        .rule(
            "calendar_date",
            seq(vec![
                dynamic(re("[0-9]{4}")),
                dynamic(str("-").optional()),
                dynamic(re("[0-9]{2}")),
                dynamic(str("-").optional()),
                dynamic(re("[0-9]{2}")),
            ]),
        )
        // Week date: YYYY-Www-D
        .rule(
            "week_date",
            seq(vec![
                dynamic(re("[0-9]{4}")),
                dynamic(str("-W")),
                dynamic(re("[0-9]{2}")), // week
                dynamic(str("-")),
                dynamic(re("[1-7]")), // weekday (1=Monday, 7=Sunday)
            ]),
        )
        // Ordinal date: YYYY-DDD
        .rule(
            "ordinal_date",
            seq(vec![
                dynamic(re("[0-9]{4}")),
                dynamic(str("-")),
                dynamic(re("[0-9]{3}")), // day of year
            ]),
        )
        // Time components
        .rule("hour", re("[0-9]{2}"))
        .rule("minute", re("[0-9]{2}"))
        .rule("second", re("[0-9]{2}"))
        .rule(
            "fraction",
            seq(vec![dynamic(str(".")), dynamic(re("[0-9]+"))]),
        )
        // Time: HH:MM:SS or HHMMSS
        .rule(
            "time_basic",
            seq(vec![
                dynamic(re("[0-9]{2}")),
                dynamic(str(":").optional()),
                dynamic(re("[0-9]{2}")),
                dynamic(str(":").optional()),
                dynamic(re("[0-9]{2}")),
                dynamic(seq(vec![dynamic(str(".")), dynamic(re("[0-9]+"))]).optional()),
            ]),
        )
        // Timezone
        .rule("utc_designator", str("Z"))
        .rule(
            "tz_offset",
            seq(vec![
                dynamic(str("+").or(str("-"))),
                dynamic(re("[0-9]{2}")),
                dynamic(str(":").optional()),
                dynamic(re("[0-9]{2}").optional()),
            ]),
        )
        .rule(
            "timezone",
            choice(vec![
                dynamic(str("Z")),
                dynamic(seq(vec![
                    dynamic(str("+").or(str("-"))),
                    dynamic(re("[0-9]{2}")),
                    dynamic(str(":").optional()),
                    dynamic(re("[0-9]{2}").optional()),
                ])),
            ]),
        )
        // Combined date-time
        .rule(
            "datetime",
            seq(vec![
                dynamic(choice(vec![
                    dynamic(re("[0-9]{4}-[0-9]{2}-[0-9]{2}")), // calendar
                    dynamic(re("[0-9]{4}-W[0-9]{2}-[1-7]")),   // week
                    dynamic(re("[0-9]{4}-[0-9]{3}")),          // ordinal
                ])),
                dynamic(str("T").or(str(" "))),
                dynamic(re("[0-9]{2}(:[0-9]{2}){1,2}")), // time
                dynamic(re("[.,][0-9]+").optional()),    // fraction
                dynamic(
                    choice(vec![
                        dynamic(str("Z")),
                        dynamic(re("[+-][0-9]{2}(:?[0-9]{2})?")),
                    ])
                    .optional(),
                ),
            ]),
        )
        // Duration: PnYnMnDTnHnMnS
        .rule(
            "duration",
            seq(vec![
                dynamic(str("P")),
                dynamic(
                    seq(vec![
                        dynamic(re("[0-9]+Y").optional()),
                        dynamic(re("[0-9]+M").optional()),
                        dynamic(re("[0-9]+D").optional()),
                        dynamic(
                            seq(vec![
                                dynamic(str("T")),
                                dynamic(re("[0-9]+H").optional()),
                                dynamic(re("[0-9]+M").optional()),
                                dynamic(re("[0-9]+S").optional()),
                            ])
                            .optional(),
                        ),
                    ])
                    .optional(),
                ),
            ]),
        )
        // Root: try datetime, then date, then time, then duration
        .rule(
            "iso_value",
            choice(vec![
                dynamic(re("[0-9]{4}(-[0-9]{2}){2}[T ][0-9]{2}:[0-9]{2}")), // datetime
                dynamic(re("[0-9]{4}-W[0-9]{2}-[1-7]")),                    // week date
                dynamic(re("[0-9]{4}-[0-9]{3}")),                           // ordinal date
                dynamic(re("[0-9]{4}(-[0-9]{2}){0,2}")),                    // calendar date
                dynamic(re("[0-9]{2}:[0-9]{2}(:[0-9]{2})?")),               // time
                dynamic(re("P[0-9YMDTHMS]+")),                              // duration
            ]),
        )
        .build()
}

/// Parsed ISO 8601 date components
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoDate {
    pub year: i32,
    pub month: Option<u32>,
    pub day: Option<u32>,
    pub week: Option<u32>,
    pub weekday: Option<u32>,
    pub ordinal_day: Option<u32>,
}

/// Parsed ISO 8601 time components
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoTime {
    pub hour: u32,
    pub minute: u32,
    pub second: u32,
    pub fraction: Option<f64>,
    pub timezone: Option<String>,
}

/// Parsed ISO 8601 datetime
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoDateTime {
    pub date: IsoDate,
    pub time: Option<IsoTime>,
}

/// Parsed ISO 8601 duration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoDuration {
    pub years: Option<u32>,
    pub months: Option<u32>,
    pub days: Option<u32>,
    pub hours: Option<u32>,
    pub minutes: Option<u32>,
    pub seconds: Option<u32>,
}

/// Parse ISO 8601 datetime string
pub fn parse_iso8601(input: &str) -> Result<String, String> {
    let grammar = build_iso8601_grammar();
    let mut arena = AstArena::for_input(input.len());
    let mut parser = PortableParser::new(&grammar, input, &mut arena);
    let _ast = parser
        .parse()
        .map_err(|e| format!("Parse error: {:?}", e))?;

    // Parse manually for structured output
    let input = input.trim();

    // Try duration first
    if input.starts_with('P') {
        let duration = parse_duration(input)?;
        return serde_json::to_string_pretty(&duration).map_err(|e| e.to_string());
    }

    // Try datetime
    if let Some(sep_pos) = input.find(['T', ' ']) {
        let date_str = &input[..sep_pos];
        let time_str = &input[sep_pos + 1..];

        let date = parse_date(date_str)?;
        let time = parse_time(time_str)?;

        let datetime = IsoDateTime {
            date,
            time: Some(time),
        };
        return serde_json::to_string_pretty(&datetime).map_err(|e| e.to_string());
    }

    // Try date only
    if input.contains('-') || input.len() == 8 {
        let date = parse_date(input)?;
        return serde_json::to_string_pretty(&date).map_err(|e| e.to_string());
    }

    // Try time only
    if input.contains(':') {
        let time = parse_time(input)?;
        return serde_json::to_string_pretty(&time).map_err(|e| e.to_string());
    }

    Err(format!("Unrecognized ISO 8601 format: {}", input))
}

/// Parse date portion
fn parse_date(input: &str) -> Result<IsoDate, String> {
    // Week date: YYYY-Www-D
    if input.contains("-W") {
        let parts: Vec<&str> = input.split("-W").collect();
        if parts.len() != 2 {
            return Err("Invalid week date format".into());
        }
        let year: i32 = parts[0].parse().map_err(|_| "Invalid year")?;
        let week_weekday: Vec<&str> = parts[1].split('-').collect();
        let week: u32 = week_weekday[0].parse().map_err(|_| "Invalid week")?;
        let weekday: u32 = week_weekday
            .get(1)
            .and_then(|s| s.parse().ok())
            .unwrap_or(1);

        return Ok(IsoDate {
            year,
            month: None,
            day: None,
            week: Some(week),
            weekday: Some(weekday),
            ordinal_day: None,
        });
    }

    // Ordinal date: YYYY-DDD
    let parts: Vec<&str> = input.split('-').collect();
    if parts.len() == 2 && parts[1].len() == 3 {
        let year: i32 = parts[0].parse().map_err(|_| "Invalid year")?;
        let ordinal_day: u32 = parts[1].parse().map_err(|_| "Invalid ordinal day")?;

        return Ok(IsoDate {
            year,
            month: None,
            day: None,
            week: None,
            weekday: None,
            ordinal_day: Some(ordinal_day),
        });
    }

    // Calendar date: YYYY-MM-DD or YYYYMMDD
    let (year, month, day) = if input.contains('-') {
        let parts: Vec<&str> = input.split('-').collect();
        let year: i32 = parts[0].parse().map_err(|_| "Invalid year")?;
        let month: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
        let day: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(1);
        (year, month, day)
    } else if input.len() == 8 {
        let year: i32 = input[0..4].parse().map_err(|_| "Invalid year")?;
        let month: u32 = input[4..6].parse().map_err(|_| "Invalid month")?;
        let day: u32 = input[6..8].parse().map_err(|_| "Invalid day")?;
        (year, month, day)
    } else {
        return Err(format!("Invalid date format: {}", input));
    };

    Ok(IsoDate {
        year,
        month: Some(month),
        day: Some(day),
        week: None,
        weekday: None,
        ordinal_day: None,
    })
}

/// Parse time portion
fn parse_time(input: &str) -> Result<IsoTime, String> {
    // Extract timezone
    let (time_part, timezone) = if let Some(pos) = input.rfind(['Z', '+', '-']) {
        if input.chars().nth(pos) == Some('Z') {
            (&input[..pos], Some("Z".to_string()))
        } else if pos > 8 {
            // Ensure it's not the date separator
            let tz = &input[pos..];
            // Validate it looks like a timezone
            if tz.starts_with(['+', '-']) {
                (&input[..pos], Some(tz.to_string()))
            } else {
                (input, None)
            }
        } else {
            (input, None)
        }
    } else {
        (input, None)
    };

    // Parse time components
    let parts: Vec<&str> = time_part.split(':').collect();
    let hour: u32 = parts[0].parse().map_err(|_| "Invalid hour")?;
    let minute: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);

    // Handle seconds with optional fraction
    let (second, fraction) = if let Some(sec_str) = parts.get(2) {
        if sec_str.contains('.') {
            let sec_parts: Vec<&str> = sec_str.split('.').collect();
            let sec: u32 = sec_parts[0].parse().map_err(|_| "Invalid second")?;
            let frac: f64 = format!("0.{}", sec_parts[1]).parse().unwrap_or(0.0);
            (sec, Some(frac))
        } else {
            (sec_str.parse().map_err(|_| "Invalid second")?, None)
        }
    } else {
        (0, None)
    };

    Ok(IsoTime {
        hour,
        minute,
        second,
        fraction,
        timezone,
    })
}

/// Parse duration
fn parse_duration(input: &str) -> Result<IsoDuration, String> {
    if !input.starts_with('P') {
        return Err("Duration must start with P".into());
    }

    let rest = &input[1..];
    let (date_part, time_part) = if let Some(t_pos) = rest.find('T') {
        (&rest[..t_pos], Some(&rest[t_pos + 1..]))
    } else {
        (rest, None)
    };

    let mut duration = IsoDuration {
        years: None,
        months: None,
        days: None,
        hours: None,
        minutes: None,
        seconds: None,
    };

    // Parse date components
    let mut num_buf = String::new();
    for c in date_part.chars() {
        if c.is_ascii_digit() {
            num_buf.push(c);
        } else {
            let value: u32 = num_buf.parse().unwrap_or(0);
            num_buf.clear();
            match c {
                'Y' => duration.years = Some(value),
                'M' => duration.months = Some(value),
                'D' => duration.days = Some(value),
                _ => {}
            }
        }
    }

    // Parse time components
    if let Some(time) = time_part {
        for c in time.chars() {
            if c.is_ascii_digit() {
                num_buf.push(c);
            } else {
                let value: u32 = num_buf.parse().unwrap_or(0);
                num_buf.clear();
                match c {
                    'H' => duration.hours = Some(value),
                    'M' => duration.minutes = Some(value),
                    'S' => duration.seconds = Some(value),
                    _ => {}
                }
            }
        }
    }

    Ok(duration)
}

fn main() {
    println!("ISO 8601 Date/Time Parser");
    println!("=========================\n");

    let examples = [
        // Calendar dates
        ("2024-01-15", "Calendar date"),
        ("20240115", "Compact date"),
        // Week dates
        ("2024-W02-1", "Week date (2nd week, Monday)"),
        // Ordinal dates
        ("2024-015", "Ordinal date (15th day of year)"),
        // Times
        ("10:30:00", "Time"),
        ("10:30:00.123", "Time with fraction"),
        // Date-times
        ("2024-01-15T10:30:00Z", "UTC datetime"),
        ("2024-01-15T10:30:00+09:00", "Datetime with timezone"),
        // Durations
        ("P1Y2M3D", "Duration (1 year, 2 months, 3 days)"),
        ("PT30M", "Duration (30 minutes)"),
        ("P1Y2M3DT4H5M6S", "Full duration"),
    ];

    for (input, description) in examples {
        println!("Input: {} ({})", input, description);
        match parse_iso8601(input) {
            Ok(json) => println!("Output:\n{}\n", json),
            Err(e) => println!("Error: {}\n", e),
        }
    }
}
Ruby
# frozen_string_literal: true

# ISO 8601 Date/Time Parser - Ruby Implementation
#
# Parse ISO 8601 dates, times, datetimes, and durations.
#
# Run with: ruby example/iso-8601/basic.rb

$LOAD_PATH.unshift "#{File.dirname(__FILE__)}/../lib"

require "parsanol/parslet"

# ISO 8601 parser
class Iso8601Parser < Parsanol::Parser
  root :iso_value

  # Date components
  rule(:year) { match("[0-9]").repeat(4, 4).as(:year) }
  rule(:month) { match("[0-9]").repeat(2, 2).as(:month) }
  rule(:day) { match("[0-9]").repeat(2, 2).as(:day) }
  rule(:date_separator) { str("-").maybe }

  # Calendar date: YYYY-MM-DD or YYYYMMDD
  rule(:calendar_date) do
    year >> date_separator >> month >> date_separator >> day
  end

  # Week date: YYYY-Www-D
  rule(:week_date) do
    year >> str("-W") >>
      match("[0-9]").repeat(2, 2).as(:week) >>
      str("-") >>
      match("[1-7]").as(:weekday)
  end

  # Ordinal date: YYYY-DDD
  rule(:ordinal_date) do
    year >> str("-") >>
      match("[0-9]").repeat(3, 3).as(:ordinal_day)
  end

  # Time components
  rule(:hour) { match("[0-9]").repeat(2, 2).as(:hour) }
  rule(:minute) { match("[0-9]").repeat(2, 2).as(:minute) }
  rule(:second) { match("[0-9]").repeat(2, 2).as(:second) }
  rule(:fraction) { str(".") >> match("[0-9]").repeat(1).as(:fraction) }
  rule(:time_separator) { str(":").maybe }

  # Time: HH:MM:SS[.frac]
  rule(:time_basic) do
    hour >> time_separator >> minute >> time_separator >> second >> fraction.maybe
  end

  # Timezone
  rule(:utc_designator) { str("Z").as(:utc) }
  rule(:tz_sign) { (str("+") | str("-")).as(:tz_sign) }
  rule(:tz_hour) { match("[0-9]").repeat(2, 2).as(:tz_hour) }
  rule(:tz_minute) do
    (str(":") >> match("[0-9]").repeat(2, 2)).maybe.as(:tz_minute)
  end

  rule(:tz_offset) do
    tz_sign >> tz_hour >> tz_minute
  end

  rule(:timezone) { utc_designator | tz_offset | str("") }

  rule(:time) { time_basic >> timezone }

  # Combined date-time
  rule(:datetime) do
    (calendar_date | week_date | ordinal_date) >>
      (str("T") | str(" ")) >>
      time
  end

  # Duration: P[nY][nM][nD][T[nH][nM][nS]]
  rule(:duration) do
    str("P") >>
      (
        (match("[0-9]").repeat(1).as(:years) >> str("Y")).maybe >>
        (match("[0-9]").repeat(1).as(:months) >> str("M")).maybe >>
        (match("[0-9]").repeat(1).as(:days) >> str("D")).maybe >>
        (
          str("T") >>
          (
            (match("[0-9]").repeat(1).as(:hours) >> str("H")).maybe >>
            (match("[0-9]").repeat(1).as(:minutes) >> str("M")).maybe >>
            (match("[0-9]").repeat(1).as(:seconds) >> str("S")).maybe
          )
        ).maybe
      )
  end

  # Top-level alternatives
  rule(:iso_value) { datetime | calendar_date | time | duration }
end

# Date result class
IsoDate = Struct.new(:year, :month, :day, :week, :weekday, :ordinal_day) do
  def to_s
    if week
      "#{year}-W#{week}-#{weekday}"
    elsif ordinal_day
      "#{year}-#{ordinal_day}"
    else
      "#{year}-#{month}-#{day}"
    end
  end

  def to_date
    require "date"
    if week
      Date.commercial(year.to_i, week.to_i, weekday.to_i)
    elsif ordinal_day
      Date.ordinal(year.to_i, ordinal_day.to_i)
    else
      Date.new(year.to_i, month.to_i, day.to_i)
    end
  end
end

# Time result class
IsoTime = Struct.new(:hour, :minute, :second, :fraction, :utc, :tz_sign,
                     :tz_hour, :tz_minute) do
  def to_s
    h = "#{hour}:#{minute}:#{second}"
    h += ".#{fraction}" if fraction
    h += "Z" if utc
    h += "#{tz_sign}#{tz_hour}#{tz_minute}" if tz_hour
    h
  end
end

# DateTime result class
IsoDateTime = Struct.new(:date, :time) do
  def to_s
    "#{date}T#{time}"
  end
end

# Duration result class
IsoDuration = Struct.new(:years, :months, :days, :hours, :minutes, :seconds) do
  def to_s
    parts = ["P"]
    parts << "#{years}Y" if years
    parts << "#{months}M" if months
    parts << "#{days}D" if days

    time_parts = []
    time_parts << "#{hours}H" if hours
    time_parts << "#{minutes}M" if minutes
    time_parts << "#{seconds}S" if seconds

    if time_parts.any?
      parts << "T"
      parts.concat(time_parts)
    end

    parts.join
  end

  def to_seconds
    total = 0
    total += years.to_i * 365.25 * 24 * 3600 if years
    total += months.to_i * 30.44 * 24 * 3600 if months
    total += days.to_i * 24 * 3600 if days
    total += hours.to_i * 3600 if hours
    total += minutes.to_i * 60 if minutes
    total += seconds.to_i if seconds
    total.to_i
  end
end

# Transform parse tree to result objects
class Iso8601Transform < Parsanol::Transform
  # Calendar date
  rule(year: simple(:y), month: simple(:m), day: simple(:d)) do
    IsoDate.new(y.to_s, m.to_s, d.to_s, nil, nil, nil)
  end

  # Week date
  rule(year: simple(:y), week: simple(:w), weekday: simple(:wd)) do
    IsoDate.new(y.to_s, nil, nil, w.to_s, wd.to_s, nil)
  end

  # Ordinal date
  rule(year: simple(:y), ordinal_day: simple(:od)) do
    IsoDate.new(y.to_s, nil, nil, nil, nil, od.to_s)
  end

  # Time
  rule(hour: simple(:h), minute: simple(:m), second: simple(:s)) do
    IsoTime.new(h.to_s, m.to_s, s.to_s, nil, nil, nil, nil)
  end

  rule(hour: simple(:h), minute: simple(:m), second: simple(:s),
       fraction: simple(:f)) do
    IsoTime.new(h.to_s, m.to_s, s.to_s, f.to_s, nil, nil, nil)
  end

  rule(hour: simple(:h), minute: simple(:m), second: simple(:s),
       utc: simple(:u)) do
    IsoTime.new(h.to_s, m.to_s, s.to_s, nil, u.to_s, nil, nil)
  end

  rule(hour: simple(:h), minute: simple(:m), second: simple(:s),
       tz_sign: simple(:ts), tz_hour: simple(:th)) do
    IsoTime.new(h.to_s, m.to_s, s.to_s, nil, nil, ts.to_s, th.to_s, nil)
  end

  # Duration
  rule(
    years: simple(:y), months: simple(:mo), days: simple(:d),
    hours: simple(:h), minutes: simple(:mi), seconds: simple(:s)
  ) do
    IsoDuration.new(y.to_s, mo.to_s, d.to_s, h.to_s, mi.to_s, s.to_s)
  end
end

# Parse ISO 8601 string
def parse_iso8601(str)
  parser = Iso8601Parser.new
  transform = Iso8601Transform.new

  tree = parser.parse(str)
  transform.apply(tree)
rescue Parsanol::ParseError => e
  puts "Parse error: #{e.message}"
  nil
end

# Main demo
if __FILE__ == $PROGRAM_NAME
  puts "ISO 8601 Date/Time Parser"
  puts "=" * 50
  puts

  examples = [
    # Calendar dates
    ["2024-01-15", "Calendar date"],
    ["20240115", "Compact date"],
    ["2024-12-25", "Christmas"],

    # Week dates
    ["2024-W02-1", "Week date (2nd week, Monday)"],

    # Ordinal dates
    ["2024-015", "Ordinal date (15th day)"],

    # Times
    ["10:30:00", "Time"],
    ["10:30:00.123", "Time with fraction"],
    ["10:30:00Z", "UTC time"],
    ["10:30:00+09:00", "Time with timezone"],

    # Date-times
    ["2024-01-15T10:30:00Z", "DateTime UTC"],
    ["2024-01-15T10:30:00+09:00", "DateTime with timezone"],

    # Durations
    ["P1Y2M3DT4H5M6S", "Full duration"],
    ["PT30M", "30 minutes duration"],
    ["P1D", "1 day duration"],
  ]

  examples.each do |input, description|
    puts "#{description}:"
    puts "  Input:  #{input}"
    result = parse_iso8601(input)
    if result
      puts "  Result: #{result.inspect}"
      puts "  String: #{result}"
      if result.respond_to?(:to_date)
        puts "  Date:   #{begin
          result.to_date
        rescue StandardError
          'N/A'
        end}"
      end
      puts "  Seconds: #{result.to_seconds}" if result.respond_to?(:to_seconds)
    end
    puts
  end
end