//! Parsing and projection helpers shared across the tools. //! //! These translate between the wire shapes an LLM sends (string ids, ISO //! dates, priority words) and the `goingson-core` domain types, and project a //! [`Task`] back down to the compact JSON rows the read tools return. use chrono::{DateTime, NaiveDate, TimeZone, Utc}; use goingson_core::{Priority, ProjectId, Task, TaskId}; use kberg::Error; use serde_json::{Value, json}; use uuid::Uuid; /// Pull a required string argument, or an `InvalidArgs` naming the field. pub fn req_str<'a>(tool: &str, args: &'a Value, field: &str) -> Result<&'a str, Error> { args.get(field) .and_then(Value::as_str) .filter(|s| !s.trim().is_empty()) .ok_or_else(|| Error::InvalidArgs { tool: tool.to_string(), message: format!("missing or empty string field `{field}`"), }) } /// Parse a task id string into a [`TaskId`], or an `InvalidArgs`. pub fn parse_task_id(tool: &str, s: &str) -> Result { Uuid::parse_str(s.trim()) .map(TaskId::from_uuid) .map_err(|_| Error::InvalidArgs { tool: tool.to_string(), message: format!("`{s}` is not a valid task id (expected a UUID)"), }) } /// Best-effort due-date parse. Accepts an RFC 3339 timestamp or a bare /// `YYYY-MM-DD` (interpreted as midnight UTC), matching the CSV import /// interchange semantics. Returns `None` for empty/absent, `Err` for garbage /// so a typo surfaces instead of silently dropping the date. pub fn parse_due(tool: &str, value: Option<&Value>) -> Result>, Error> { let Some(raw) = value.and_then(Value::as_str) else { return Ok(None); }; let raw = raw.trim(); if raw.is_empty() { return Ok(None); } if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { return Ok(Some(dt.with_timezone(&Utc))); } if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") { let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid"); return Ok(Some(Utc.from_utc_datetime(&naive))); } Err(Error::InvalidArgs { tool: tool.to_string(), message: format!("`{raw}` is not an RFC 3339 timestamp or YYYY-MM-DD date"), }) } /// Parse a priority word (`High`/`Medium`/`Low`, case-insensitive), defaulting /// to `Medium` when absent. Never errors — an unknown word falls back rather /// than blocking a migration. pub fn parse_priority(value: Option<&Value>) -> Priority { match value.and_then(Value::as_str) { Some(s) => Priority::from_str_or_default(s), None => Priority::default(), } } /// Read a `tags` argument shaped as a JSON array of strings. pub fn parse_tags(value: Option<&Value>) -> Vec { value .and_then(Value::as_array) .map(|arr| { arr.iter() .filter_map(Value::as_str) .map(str::to_string) .collect() }) .unwrap_or_default() } /// The tag carrying an item's provenance, used as the idempotency key so a /// re-run of a `/dellm` wave does not double-insert. E.g. `source:todo.md:42`. pub fn source_tag(source: &str) -> String { format!("source:{source}") } /// Full-word priority, since [`Priority::as_str`] returns a one-letter badge /// ("H"/"M"/"L") meant for the UI, not a wire value. pub fn priority_word(p: &Priority) -> &'static str { match p { Priority::High => "High", Priority::Medium => "Medium", Priority::Low => "Low", } } /// Compact JSON projection of a task for the read tools. pub fn task_row(t: &Task) -> Value { json!({ "id": t.id.to_string(), "description": t.description, "status": t.status.as_str(), "priority": priority_word(&t.priority), "due": t.due.map(|d| d.to_rfc3339()), "tags": t.tags, "project": t.project_name, "project_id": t.project_id.map(|p: ProjectId| p.to_string()), "status_tokens": t.status_tokens.iter() .map(|k| json!({ "kind": k.kind, "reference": k.reference, "state": k.state.as_str(), "primary": k.is_primary, })) .collect::>(), }) }