use elicitation::contracts::{And, Established};
use elicitation::{ElicitPlugin, Prop, VerifiedWorkflow, elicit_tool};
use jiff::Timestamp;
use rmcp::{
ErrorData,
model::{CallToolResult, Content},
};
use schemars::JsonSchema;
use serde::Deserialize;
use tracing::instrument;
#[derive(Prop)]
pub struct TimestampParsed;
impl VerifiedWorkflow for TimestampParsed {}
#[derive(Prop)]
pub struct TimestampFuture;
impl VerifiedWorkflow for TimestampFuture {}
#[derive(Prop)]
pub struct ZonedParsed;
impl VerifiedWorkflow for ZonedParsed {}
#[derive(Prop)]
pub struct TimezoneConverted;
impl VerifiedWorkflow for TimezoneConverted {}
pub type FutureTimestampProof = And<TimestampParsed, TimestampFuture>;
pub type ConvertedZonedProof = And<ZonedParsed, TimezoneConverted>;
pub struct UnvalidatedTimestampStr {
src: String,
}
pub struct ParsedTimestamp {
pub inner: Timestamp,
}
pub struct FutureTimestampState {
pub inner: Timestamp,
}
impl FutureTimestampState {
pub fn into_inner(self) -> Timestamp {
self.inner
}
}
pub struct UnvalidatedZonedStr {
src: String,
}
pub struct ParsedZoned {
pub inner: jiff::Zoned,
}
pub struct ConvertedZonedState {
pub inner: jiff::Zoned,
}
impl ConvertedZonedState {
pub fn into_inner(self) -> jiff::Zoned {
self.inner
}
}
impl UnvalidatedTimestampStr {
pub fn new(src: impl Into<String>) -> Self {
Self { src: src.into() }
}
pub fn parse(self) -> Result<(ParsedTimestamp, Established<TimestampParsed>), String> {
self.src
.parse::<Timestamp>()
.map(|inner| (ParsedTimestamp { inner }, Established::assert()))
.map_err(|e| format!("TimestampParsed not established: {e}"))
}
}
impl ParsedTimestamp {
pub fn into_inner(self) -> Timestamp {
self.inner
}
pub fn assert_future(
self,
parsed: Established<TimestampParsed>,
) -> Result<(FutureTimestampState, Established<FutureTimestampProof>), String> {
let now = Timestamp::now();
if self.inner > now {
let proof =
elicitation::contracts::both(parsed, Established::<TimestampFuture>::assert());
Ok((FutureTimestampState { inner: self.inner }, proof))
} else {
Err(format!(
"TimestampFuture not established: {} is not after now ({})",
self.inner, now
))
}
}
}
impl UnvalidatedZonedStr {
pub fn new(src: impl Into<String>) -> Self {
Self { src: src.into() }
}
pub fn parse(self) -> Result<(ParsedZoned, Established<ZonedParsed>), String> {
self.src
.parse::<jiff::Zoned>()
.map(|inner| (ParsedZoned { inner }, Established::assert()))
.map_err(|e| format!("ZonedParsed not established: {e}"))
}
}
impl ParsedZoned {
pub fn into_inner(self) -> jiff::Zoned {
self.inner
}
pub fn convert_tz(
self,
tz_name: &str,
parsed: Established<ZonedParsed>,
) -> Result<(ConvertedZonedState, Established<ConvertedZonedProof>), String> {
match self.inner.in_tz(tz_name) {
Ok(converted) => {
let proof = elicitation::contracts::both(
parsed,
Established::<TimezoneConverted>::assert(),
);
Ok((ConvertedZonedState { inner: converted }, proof))
}
Err(e) => Err(format!("TimezoneConverted not established: {e}")),
}
}
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ParseTimestampParams {
pub timestamp: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ParseZonedParams {
pub zoned: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct AssertFutureParams {
pub timestamp: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ConvertTzParams {
pub zoned: String,
pub timezone: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ComputeSpanParams {
pub from: String,
pub to: String,
}
pub fn parse_ts(s: &str) -> Result<Timestamp, String> {
s.parse::<Timestamp>()
.map_err(|e| format!("TimestampParsed not established: {e}"))
}
#[derive(Debug, ElicitPlugin)]
#[plugin(name = "jiff_workflow")]
pub struct JiffWorkflowPlugin;
#[elicit_tool(
plugin = "jiff_workflow",
name = "parse_timestamp",
description = "Parse an ISO 8601 timestamp string using jiff. \
Establishes: TimestampParsed. \
Returns seconds, milliseconds, nanoseconds, and human-readable form."
)]
#[instrument(skip_all)]
async fn parse_timestamp(p: ParseTimestampParams) -> Result<CallToolResult, ErrorData> {
let (parsed, _proof) = match UnvalidatedTimestampStr::new(p.timestamp).parse() {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let ts = parsed.inner;
let summary = format!(
"TimestampParsed established.\n\
timestamp: {}\n\
as_second: {}\n\
as_millis: {}\n\
subsec_nanos: {}",
ts,
ts.as_second(),
ts.as_millisecond(),
ts.subsec_nanosecond(),
);
Ok(CallToolResult::success(vec![Content::text(summary)]))
}
#[elicit_tool(
plugin = "jiff_workflow",
name = "parse_zoned",
description = "Parse a jiff zoned datetime string (e.g. '2025-03-05T12:00:00[America/New_York]'). \
Establishes: ZonedParsed. \
Returns year/month/day/hour/minute/second and timezone name."
)]
#[instrument(skip_all)]
async fn parse_zoned(p: ParseZonedParams) -> Result<CallToolResult, ErrorData> {
let (parsed, _proof) = match UnvalidatedZonedStr::new(p.zoned).parse() {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let z = parsed.inner;
let summary = format!(
"ZonedParsed established.\n\
zoned: {}\n\
year: {}\n\
month: {}\n\
day: {}\n\
hour: {}\n\
minute: {}\n\
second: {}\n\
timezone: {}",
z,
z.year(),
z.month(),
z.day(),
z.hour(),
z.minute(),
z.second(),
z.time_zone().iana_name().unwrap_or("(fixed offset)"),
);
Ok(CallToolResult::success(vec![Content::text(summary)]))
}
#[elicit_tool(
plugin = "jiff_workflow",
name = "assert_future",
description = "Parse an ISO 8601 timestamp and assert it is strictly after now. \
Establishes: TimestampParsed ∧ TimestampFuture. \
Returns the timestamp and seconds-from-now."
)]
#[instrument(skip_all)]
async fn assert_future(p: AssertFutureParams) -> Result<CallToolResult, ErrorData> {
let (parsed, parsed_proof) = match UnvalidatedTimestampStr::new(p.timestamp).parse() {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let (future, _proof) = match parsed.assert_future(parsed_proof) {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let now = Timestamp::now();
let diff_secs = (future.inner.as_second()) - (now.as_second());
Ok(CallToolResult::success(vec![Content::text(format!(
"TimestampParsed ∧ TimestampFuture established.\n\
timestamp: {}\n\
seconds_from_now: {}",
future.inner, diff_secs,
))]))
}
#[elicit_tool(
plugin = "jiff_workflow",
name = "convert_tz",
description = "Parse a jiff zoned datetime and convert it to the named IANA timezone. \
Establishes: ZonedParsed ∧ TimezoneConverted. \
The resulting datetime preserves the same instant in time."
)]
#[instrument(skip_all)]
async fn convert_tz(p: ConvertTzParams) -> Result<CallToolResult, ErrorData> {
let (parsed, parsed_proof) = match UnvalidatedZonedStr::new(p.zoned).parse() {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let (converted, _proof) = match parsed.convert_tz(&p.timezone, parsed_proof) {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
Ok(CallToolResult::success(vec![Content::text(format!(
"ZonedParsed ∧ TimezoneConverted established.\n\
result: {}\n\
timezone: {}",
converted.inner,
converted
.inner
.time_zone()
.iana_name()
.unwrap_or("(fixed offset)"),
))]))
}
#[elicit_tool(
plugin = "jiff_workflow",
name = "compute_span",
description = "Compute the signed duration between two ISO 8601 timestamps. \
Establishes: TimestampParsed(from) ∧ TimestampParsed(to). \
Returns span in seconds, minutes, hours, and days."
)]
#[instrument(skip_all)]
async fn compute_span(p: ComputeSpanParams) -> Result<CallToolResult, ErrorData> {
let from = match parse_ts(&p.from) {
Ok(t) => t,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let to = match parse_ts(&p.to) {
Ok(t) => t,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let secs = to.as_second() - from.as_second();
let summary = format!(
"TimestampParsed(from) ∧ TimestampParsed(to) established.\n\
from: {}\n\
to: {}\n\
seconds: {}\n\
minutes: {}\n\
hours: {}\n\
days: {}",
from,
to,
secs,
secs / 60,
secs / 3600,
secs / 86400,
);
Ok(CallToolResult::success(vec![Content::text(summary)]))
}