//! 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 an enum argument strictly, listing the accepted words on failure. /// /// Deliberately not [`ParseableEnum::from_str_or_default`]: that fallback exists /// so database reads and UI input never fail, but here a typo'd word would /// silently become the `Default` variant. A caller asking for `Archived` and /// silently getting `Active` would be told the write succeeded. pub fn parse_enum( tool: &str, field: &str, raw: &str, allowed: &[&str], ) -> Result { raw.trim().parse::().map_err(|_| Error::InvalidArgs { tool: tool.to_string(), message: format!( "`{raw}` is not a valid `{field}` (expected one of: {})", allowed.join(", ") ), }) } /// 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(), } } /// Parse a `limit`, defaulting to [`DEFAULT_LIMIT`] and clamping to /// [`MAX_LIMIT`]. A non-integer or zero limit errors rather than silently /// falling back, since a caller that asked for a page size is reasoning about /// it. An over-large one clamps: the cap is the server's call, not a mistake. pub fn parse_limit(tool: &str, value: Option<&Value>) -> Result { let Some(raw) = value else { return Ok(DEFAULT_LIMIT); }; if raw.is_null() { return Ok(DEFAULT_LIMIT); } let n = raw .as_u64() .filter(|n| *n > 0) .ok_or_else(|| Error::InvalidArgs { tool: tool.to_string(), message: format!("`limit` must be a positive integer (got `{raw}`)"), })?; Ok((n as usize).min(MAX_LIMIT)) } /// Parse an `offset`, defaulting to 0. pub fn parse_offset(tool: &str, value: Option<&Value>) -> Result { let Some(raw) = value else { return Ok(0); }; if raw.is_null() { return Ok(0); } let n = raw.as_u64().ok_or_else(|| Error::InvalidArgs { tool: tool.to_string(), message: format!("`offset` must be a non-negative integer (got `{raw}`)"), })?; Ok(n as usize) } /// 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", } } /// Longest description a `list_tasks` row carries before it is clipped. /// Task descriptions here run to multi-paragraph specs; a full-project listing /// of them overflows a tool-result budget long before the row count does. pub const SUMMARY_DESCRIPTION_CHARS: usize = 240; /// Default and maximum page sizes for `list_tasks`. The default is what an /// unparameterized call gets, so the old unbounded behaviour cannot come back /// by omission. pub const DEFAULT_LIMIT: usize = 50; pub const MAX_LIMIT: usize = 200; /// Clip a description to [`SUMMARY_DESCRIPTION_CHARS`] on a char boundary. /// Returns the text and whether it was clipped. fn clip(s: &str) -> (String, bool) { let mut chars = s.chars(); let head: String = chars.by_ref().take(SUMMARY_DESCRIPTION_CHARS).collect(); if chars.next().is_none() { (head, false) } else { (head, true) } } /// Row projection for `list_tasks`: [`task_row`] with the description clipped. /// A clipped row carries `"truncated": true` so a caller knows the full text is /// a `get_task` away rather than assuming it has read the whole spec. pub fn task_summary_row(t: &Task) -> Value { let mut row = task_row(t); let (description, truncated) = clip(&t.description); if truncated { row["description"] = json!(description); row["truncated"] = json!(true); } row } /// 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::>(), }) }