| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 35 |
|
| 36 |
|
| 37 |
|
| 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 |
|
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
|
| 64 |
|
| 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 |
|
| 81 |
|
| 82 |
|
| 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 |
|
| 91 |
|
| 92 |
|
| 93 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 140 |
|
| 141 |
pub fn source_tag(source: &str) -> String { |
| 142 |
format!("source:{source}") |
| 143 |
} |
| 144 |
|
| 145 |
|
| 146 |
|
| 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 |
|
| 156 |
|
| 157 |
|
| 158 |
pub const SUMMARY_DESCRIPTION_CHARS: usize = 240; |
| 159 |
|
| 160 |
|
| 161 |
|
| 162 |
|
| 163 |
pub const DEFAULT_LIMIT: usize = 50; |
| 164 |
pub const MAX_LIMIT: usize = 200; |
| 165 |
|
| 166 |
|
| 167 |
|
| 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 |
|
| 179 |
|
| 180 |
|
| 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 |
|
| 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 |
|