Skip to main content

max / goingson

4.2 KB · 119 lines History Blame Raw
1 //! Parsing and projection helpers shared across the tools.
2 //!
3 //! These translate between the wire shapes an LLM sends (string ids, ISO
4 //! dates, priority words) and the `goingson-core` domain types, and project a
5 //! [`Task`] back down to the compact JSON rows the read tools return.
6
7 use chrono::{DateTime, NaiveDate, TimeZone, Utc};
8 use goingson_core::{Priority, ProjectId, Task, TaskId};
9 use kberg::Error;
10 use serde_json::{Value, json};
11 use uuid::Uuid;
12
13 /// Pull a required string argument, or an `InvalidArgs` naming the field.
14 pub fn req_str<'a>(tool: &str, args: &'a Value, field: &str) -> Result<&'a str, Error> {
15 args.get(field)
16 .and_then(Value::as_str)
17 .filter(|s| !s.trim().is_empty())
18 .ok_or_else(|| Error::InvalidArgs {
19 tool: tool.to_string(),
20 message: format!("missing or empty string field `{field}`"),
21 })
22 }
23
24 /// Parse a task id string into a [`TaskId`], or an `InvalidArgs`.
25 pub fn parse_task_id(tool: &str, s: &str) -> Result<TaskId, Error> {
26 Uuid::parse_str(s.trim())
27 .map(TaskId::from_uuid)
28 .map_err(|_| Error::InvalidArgs {
29 tool: tool.to_string(),
30 message: format!("`{s}` is not a valid task id (expected a UUID)"),
31 })
32 }
33
34 /// Best-effort due-date parse. Accepts an RFC 3339 timestamp or a bare
35 /// `YYYY-MM-DD` (interpreted as midnight UTC), matching the CSV import
36 /// interchange semantics. Returns `None` for empty/absent, `Err` for garbage
37 /// so a typo surfaces instead of silently dropping the date.
38 pub fn parse_due(tool: &str, value: Option<&Value>) -> Result<Option<DateTime<Utc>>, Error> {
39 let Some(raw) = value.and_then(Value::as_str) else {
40 return Ok(None);
41 };
42 let raw = raw.trim();
43 if raw.is_empty() {
44 return Ok(None);
45 }
46 if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
47 return Ok(Some(dt.with_timezone(&Utc)));
48 }
49 if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
50 let naive = date.and_hms_opt(0, 0, 0).expect("midnight is always valid");
51 return Ok(Some(Utc.from_utc_datetime(&naive)));
52 }
53 Err(Error::InvalidArgs {
54 tool: tool.to_string(),
55 message: format!("`{raw}` is not an RFC 3339 timestamp or YYYY-MM-DD date"),
56 })
57 }
58
59 /// Parse a priority word (`High`/`Medium`/`Low`, case-insensitive), defaulting
60 /// to `Medium` when absent. Never errors — an unknown word falls back rather
61 /// than blocking a migration.
62 pub fn parse_priority(value: Option<&Value>) -> Priority {
63 match value.and_then(Value::as_str) {
64 Some(s) => Priority::from_str_or_default(s),
65 None => Priority::default(),
66 }
67 }
68
69 /// Read a `tags` argument shaped as a JSON array of strings.
70 pub fn parse_tags(value: Option<&Value>) -> Vec<String> {
71 value
72 .and_then(Value::as_array)
73 .map(|arr| {
74 arr.iter()
75 .filter_map(Value::as_str)
76 .map(str::to_string)
77 .collect()
78 })
79 .unwrap_or_default()
80 }
81
82 /// The tag carrying an item's provenance, used as the idempotency key so a
83 /// re-run of a `/dellm` wave does not double-insert. E.g. `source:todo.md:42`.
84 pub fn source_tag(source: &str) -> String {
85 format!("source:{source}")
86 }
87
88 /// Full-word priority, since [`Priority::as_str`] returns a one-letter badge
89 /// ("H"/"M"/"L") meant for the UI, not a wire value.
90 pub fn priority_word(p: &Priority) -> &'static str {
91 match p {
92 Priority::High => "High",
93 Priority::Medium => "Medium",
94 Priority::Low => "Low",
95 }
96 }
97
98 /// Compact JSON projection of a task for the read tools.
99 pub fn task_row(t: &Task) -> Value {
100 json!({
101 "id": t.id.to_string(),
102 "description": t.description,
103 "status": t.status.as_str(),
104 "priority": priority_word(&t.priority),
105 "due": t.due.map(|d| d.to_rfc3339()),
106 "tags": t.tags,
107 "project": t.project_name,
108 "project_id": t.project_id.map(|p: ProjectId| p.to_string()),
109 "status_tokens": t.status_tokens.iter()
110 .map(|k| json!({
111 "kind": k.kind,
112 "reference": k.reference,
113 "state": k.state.as_str(),
114 "primary": k.is_primary,
115 }))
116 .collect::<Vec<_>>(),
117 })
118 }
119