Skip to main content

max / goingson

7.5 KB · 212 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 an enum argument strictly, listing the accepted words on failure.
60 ///
61 /// Deliberately not [`ParseableEnum::from_str_or_default`]: that fallback exists
62 /// so database reads and UI input never fail, but here a typo'd word would
63 /// silently become the `Default` variant. A caller asking for `Archived` and
64 /// silently getting `Active` would be told the write succeeded.
65 pub fn parse_enum<T: std::str::FromStr>(
66 tool: &str,
67 field: &str,
68 raw: &str,
69 allowed: &[&str],
70 ) -> Result<T, Error> {
71 raw.trim().parse::<T>().map_err(|_| Error::InvalidArgs {
72 tool: tool.to_string(),
73 message: format!(
74 "`{raw}` is not a valid `{field}` (expected one of: {})",
75 allowed.join(", ")
76 ),
77 })
78 }
79
80 /// Parse a priority word (`High`/`Medium`/`Low`, case-insensitive), defaulting
81 /// to `Medium` when absent. Never errors, an unknown word falls back rather
82 /// than blocking a migration.
83 pub fn parse_priority(value: Option<&Value>) -> Priority {
84 match value.and_then(Value::as_str) {
85 Some(s) => Priority::from_str_or_default(s),
86 None => Priority::default(),
87 }
88 }
89
90 /// Parse a `limit`, defaulting to [`DEFAULT_LIMIT`] and clamping to
91 /// [`MAX_LIMIT`]. A non-integer or zero limit errors rather than silently
92 /// falling back, since a caller that asked for a page size is reasoning about
93 /// it. An over-large one clamps: the cap is the server's call, not a mistake.
94 pub fn parse_limit(tool: &str, value: Option<&Value>) -> Result<usize, Error> {
95 let Some(raw) = value else {
96 return Ok(DEFAULT_LIMIT);
97 };
98 if raw.is_null() {
99 return Ok(DEFAULT_LIMIT);
100 }
101 let n = raw
102 .as_u64()
103 .filter(|n| *n > 0)
104 .ok_or_else(|| Error::InvalidArgs {
105 tool: tool.to_string(),
106 message: format!("`limit` must be a positive integer (got `{raw}`)"),
107 })?;
108 Ok((n as usize).min(MAX_LIMIT))
109 }
110
111 /// Parse an `offset`, defaulting to 0.
112 pub fn parse_offset(tool: &str, value: Option<&Value>) -> Result<usize, Error> {
113 let Some(raw) = value else {
114 return Ok(0);
115 };
116 if raw.is_null() {
117 return Ok(0);
118 }
119 let n = raw.as_u64().ok_or_else(|| Error::InvalidArgs {
120 tool: tool.to_string(),
121 message: format!("`offset` must be a non-negative integer (got `{raw}`)"),
122 })?;
123 Ok(n as usize)
124 }
125
126 /// Read a `tags` argument shaped as a JSON array of strings.
127 pub fn parse_tags(value: Option<&Value>) -> Vec<String> {
128 value
129 .and_then(Value::as_array)
130 .map(|arr| {
131 arr.iter()
132 .filter_map(Value::as_str)
133 .map(str::to_string)
134 .collect()
135 })
136 .unwrap_or_default()
137 }
138
139 /// The tag carrying an item's provenance, used as the idempotency key so a
140 /// re-run of a `/dellm` wave does not double-insert. E.g. `source:todo.md:42`.
141 pub fn source_tag(source: &str) -> String {
142 format!("source:{source}")
143 }
144
145 /// Full-word priority, since [`Priority::as_str`] returns a one-letter badge
146 /// ("H"/"M"/"L") meant for the UI, not a wire value.
147 pub fn priority_word(p: &Priority) -> &'static str {
148 match p {
149 Priority::High => "High",
150 Priority::Medium => "Medium",
151 Priority::Low => "Low",
152 }
153 }
154
155 /// Longest description a `list_tasks` row carries before it is clipped.
156 /// Task descriptions here run to multi-paragraph specs; a full-project listing
157 /// of them overflows a tool-result budget long before the row count does.
158 pub const SUMMARY_DESCRIPTION_CHARS: usize = 240;
159
160 /// Default and maximum page sizes for `list_tasks`. The default is what an
161 /// unparameterized call gets, so the old unbounded behaviour cannot come back
162 /// by omission.
163 pub const DEFAULT_LIMIT: usize = 50;
164 pub const MAX_LIMIT: usize = 200;
165
166 /// Clip a description to [`SUMMARY_DESCRIPTION_CHARS`] on a char boundary.
167 /// Returns the text and whether it was clipped.
168 fn clip(s: &str) -> (String, bool) {
169 let mut chars = s.chars();
170 let head: String = chars.by_ref().take(SUMMARY_DESCRIPTION_CHARS).collect();
171 if chars.next().is_none() {
172 (head, false)
173 } else {
174 (head, true)
175 }
176 }
177
178 /// Row projection for `list_tasks`: [`task_row`] with the description clipped.
179 /// A clipped row carries `"truncated": true` so a caller knows the full text is
180 /// a `get_task` away rather than assuming it has read the whole spec.
181 pub fn task_summary_row(t: &Task) -> Value {
182 let mut row = task_row(t);
183 let (description, truncated) = clip(&t.description);
184 if truncated {
185 row["description"] = json!(description);
186 row["truncated"] = json!(true);
187 }
188 row
189 }
190
191 /// Compact JSON projection of a task for the read tools.
192 pub fn task_row(t: &Task) -> Value {
193 json!({
194 "id": t.id.to_string(),
195 "description": t.description,
196 "status": t.status.as_str(),
197 "priority": priority_word(&t.priority),
198 "due": t.due.map(|d| d.to_rfc3339()),
199 "tags": t.tags,
200 "project": t.project_name,
201 "project_id": t.project_id.map(|p: ProjectId| p.to_string()),
202 "status_tokens": t.status_tokens.iter()
203 .map(|k| json!({
204 "kind": k.kind,
205 "reference": k.reference,
206 "state": k.state.as_str(),
207 "primary": k.is_primary,
208 }))
209 .collect::<Vec<_>>(),
210 })
211 }
212