kasl/libs/task.rs
1//! The task model, its query filters, and name normalization.
2//!
3//! ```rust
4//! use kasl::libs::task::Task;
5//!
6//! let task = Task::new(
7//! "Implement user authentication",
8//! "Add OAuth2 integration with Google and GitHub",
9//! Some(25)
10//! );
11//! ```
12
13use crate::db::tags::Tag;
14use chrono::NaiveDate;
15
16/// A single work item.
17///
18/// `task_id` links to an external system (a Jira issue id, a GitLab MR);
19/// `timestamp` is managed by the database layer.
20///
21/// ```rust
22/// use kasl::libs::task::Task;
23///
24/// let task = Task::new(
25/// "Code review for PR #123",
26/// "Review authentication changes and security implications",
27/// Some(0) // Just started
28/// );
29/// ```
30///
31/// ```rust
32/// use kasl::libs::task::Task;
33///
34/// let existing_task = Task::new("Existing task", "Details", Some(50));
35/// let mut task = existing_task;
36/// task.completeness = Some(75);
37/// task.comment = "Almost finished, testing remaining".to_string();
38/// ```
39///
40/// Populating a task from an external issue:
41/// ```rust
42/// use kasl::libs::task::Task;
43///
44/// // Simulated Jira issue data used to populate a task.
45/// struct JiraIssue {
46/// key: String,
47/// summary: String,
48/// description: Option<String>,
49/// }
50/// let jira_issue = JiraIssue {
51/// key: "PROJ-412".to_string(),
52/// summary: "Fix login bug".to_string(),
53/// description: Some("Users cannot log in with SSO".to_string()),
54/// };
55///
56/// let jira_task = Task {
57/// id: None, // Will be assigned by database
58/// task_id: None, // Set to the task's own id once saved
59/// timestamp: None,
60/// name: jira_issue.summary,
61/// comment: jira_issue.description.unwrap_or_default(),
62/// completeness: Some(100), // Imported completed issues
63/// excluded_from_search: None,
64/// tags: vec![],
65/// jira_key: Some(jira_issue.key),
66/// };
67/// # let _ = jira_task;
68/// ```
69#[derive(Debug, Clone)]
70pub struct Task {
71 /// Database primary key; `None` until the task is saved.
72 pub id: Option<i32>,
73
74 /// Groups a task with its own history across days.
75 ///
76 /// Points at the id of the first task in the chain, so `task find` can
77 /// offer yesterday's unfinished work and see today's progress as the same
78 /// item. Not an external reference - the Jira issue a task came from is
79 /// [`Task::jira_key`].
80 pub task_id: Option<i32>,
81
82 /// `"YYYY-MM-DD HH:MM:SS"` in local time, set by the database layer.
83 pub timestamp: Option<String>,
84
85 /// Task title.
86 pub name: String,
87
88 /// Free-form notes.
89 pub comment: String,
90
91 /// Progress 0-100; imported completed issues default to 100.
92 pub completeness: Option<i32>,
93
94 /// Hidden from task discovery when true.
95 pub excluded_from_search: Option<bool>,
96
97 /// Tags, maintained through the `task_tags` relationship table.
98 pub tags: Vec<Tag>,
99
100 /// Jira issue this task was taken from, e.g. `PROJ-412`.
101 ///
102 /// Set by `kasl inbox take`; `None` for tasks that did not come from the
103 /// inbox. The key also opens the task's name, but only this field survives
104 /// a rename.
105 pub jira_key: Option<String>,
106}
107
108impl Task {
109 /// Creates an unsaved task; whitespace in `name` and `comment` is collapsed.
110 ///
111 /// ```rust
112 /// use kasl::libs::task::Task;
113 ///
114 /// let new_task = Task::new(
115 /// "Implement user registration",
116 /// "Add email verification and password validation",
117 /// Some(0)
118 /// );
119 ///
120 /// let completed_task = Task::new(
121 /// "Fix login redirect bug",
122 /// "Resolved issue with OAuth callback URL handling",
123 /// Some(100)
124 /// );
125 ///
126 /// let planning_task = Task::new(
127 /// "Research authentication libraries",
128 /// "Evaluate OAuth2 libraries for Node.js backend",
129 /// None
130 /// );
131 /// ```
132 pub fn new(name: &str, comment: &str, completeness: Option<i32>) -> Self {
133 Task {
134 id: None,
135 task_id: None,
136 timestamp: None,
137 name: collapse_whitespace(name),
138 comment: collapse_whitespace(comment),
139 completeness,
140 excluded_from_search: None,
141 tags: Vec::new(),
142 jira_key: None,
143 }
144 }
145
146 /// Records the Jira issue this task was taken from.
147 pub fn from_jira(mut self, key: &str) -> Self {
148 self.jira_key = Some(key.to_string());
149 self
150 }
151
152 /// Copies `name`, `comment` and `completeness` from `other`, keeping
153 /// identity fields (`id`, `task_id`, `timestamp`, search flag, tags,
154 /// `jira_key`).
155 ///
156 /// ```rust,no_run
157 /// # fn f() -> anyhow::Result<()> {
158 /// use kasl::libs::task::Task;
159 /// use kasl::db::tasks::Tasks;
160 ///
161 /// let mut tasks_db = Tasks::new()?;
162 /// let mut existing_task = tasks_db.get_by_id(42)?.expect("task exists");
163 ///
164 /// let updated_task = Task::new(
165 /// "Updated task name",
166 /// "Updated description with new requirements",
167 /// Some(75)
168 /// );
169 ///
170 /// existing_task.update_from(&updated_task);
171 /// tasks_db.update(&existing_task)?;
172 /// # Ok(())
173 /// # }
174 /// ```
175 ///
176 /// ```rust,no_run
177 /// # fn f() -> anyhow::Result<()> {
178 /// use kasl::libs::task::Task;
179 /// use kasl::db::tasks::Tasks;
180 ///
181 /// let mut tasks_db = Tasks::new()?;
182 /// let tasks_to_update: Vec<Task> = vec![];
183 /// let get_update_template = |_task: &Task| -> Option<Task> { None };
184 ///
185 /// for mut task in tasks_to_update {
186 /// if let Some(template) = get_update_template(&task) {
187 /// task.update_from(&template);
188 /// tasks_db.update(&task)?;
189 /// }
190 /// }
191 /// # Ok(())
192 /// # }
193 /// ```
194 ///
195 /// ```rust
196 /// use kasl::libs::task::Task;
197 ///
198 /// let mut original_task = Task::new(
199 /// "Original task",
200 /// "Original description",
201 /// Some(25)
202 /// );
203 /// original_task.id = Some(42);
204 ///
205 /// let updated_task = Task::new(
206 /// "Updated task name",
207 /// "Updated description with more details",
208 /// Some(75)
209 /// );
210 ///
211 /// original_task.update_from(&updated_task);
212 ///
213 /// assert_eq!(original_task.id, Some(42)); // ID preserved
214 /// assert_eq!(original_task.name, "Updated task name"); // Content updated
215 /// assert_eq!(original_task.completeness, Some(75)); // Progress updated
216 /// ```
217 pub fn update_from(&mut self, other: &Task) {
218 self.name = other.name.clone();
219 self.comment = other.comment.clone();
220 self.completeness = other.completeness;
221 }
222}
223
224/// Filtering criteria for task queries.
225///
226/// ```rust
227/// use kasl::libs::task::TaskFilter;
228/// use chrono::Local;
229///
230/// let all_tasks_filter = TaskFilter::All;
231///
232/// let today = Local::now().date_naive();
233/// let today_filter = TaskFilter::Date(today);
234///
235/// let incomplete_filter = TaskFilter::Incomplete;
236/// let specific_filter = TaskFilter::ByIds(vec![1, 2, 3]);
237///
238/// let tagged_filter = TaskFilter::ByTag("urgent".to_string());
239/// let multi_tagged_filter = TaskFilter::ByTags(vec![
240/// "frontend".to_string(),
241/// "javascript".to_string()
242/// ]);
243/// ```
244#[derive(Debug, Clone)]
245pub enum TaskFilter {
246 /// Every task, no filtering.
247 All,
248
249 /// Tasks whose timestamp falls on the given local date.
250 ///
251 /// ```rust
252 /// use kasl::libs::task::TaskFilter;
253 /// use chrono::{Local, NaiveDate};
254 ///
255 /// let today = Local::now().date_naive();
256 /// let filter = TaskFilter::Date(today);
257 /// ```
258 Date(NaiveDate),
259
260 /// Tasks below 100% done; `completeness: None` counts as incomplete.
261 ///
262 /// ```rust
263 /// use kasl::libs::task::TaskFilter;
264 ///
265 /// let incomplete_filter = TaskFilter::Incomplete;
266 /// // Returns tasks with completeness: None, Some(0), Some(50), etc.
267 /// // Excludes tasks with completeness: Some(100)
268 /// ```
269 Incomplete,
270
271 /// Tasks with the given database ids.
272 ///
273 /// ```rust
274 /// use kasl::libs::task::TaskFilter;
275 ///
276 /// let specific_tasks = TaskFilter::ByIds(vec![1, 5, 10, 15]);
277 /// ```
278 ByIds(Vec<i32>),
279
280 /// Tasks carrying the tag (name matched case-sensitively).
281 ///
282 /// ```rust
283 /// use kasl::libs::task::TaskFilter;
284 ///
285 /// let urgent_filter = TaskFilter::ByTag("urgent".to_string());
286 /// ```
287 ByTag(String),
288
289 /// Tasks carrying ALL of the tags - intersection, not union.
290 ///
291 /// ```rust
292 /// use kasl::libs::task::TaskFilter;
293 ///
294 /// let complex_filter = TaskFilter::ByTags(vec![
295 /// "frontend".to_string(),
296 /// "urgent".to_string(),
297 /// "javascript".to_string()
298 /// ]);
299 /// ```
300 ByTags(Vec<String>),
301
302 /// Tasks taken from the given Jira issue.
303 ///
304 /// ```rust
305 /// use kasl::libs::task::TaskFilter;
306 ///
307 /// let from_issue = TaskFilter::ByJiraKey("PROJ-412".to_string());
308 /// ```
309 ByJiraKey(String),
310}
311
312/// Display formatting and partitioning for task collections.
313///
314/// ```rust
315/// use kasl::libs::task::{Task, FormatTasks};
316///
317/// let mut tasks = vec![
318/// Task::new("Task 1", "Description 1", Some(50)),
319/// Task::new("Task 2", "Description 2", Some(75)),
320/// Task::new("Task 3", "Description 3", Some(100)),
321/// ];
322///
323/// let formatted = tasks.format();
324/// println!("{}", formatted);
325///
326/// let groups = tasks.divide(2);
327/// for (i, group) in groups.iter().enumerate() {
328/// println!("Group {}: {} tasks", i, group.len());
329/// }
330/// ```
331pub trait FormatTasks {
332 /// Renders one `{name} ({completeness}%)` line per task.
333 ///
334 /// ```rust
335 /// use kasl::libs::task::{Task, FormatTasks};
336 ///
337 /// let mut tasks = vec![
338 /// Task::new("Review PR", "Code review for auth changes", Some(25)),
339 /// Task::new("Write tests", "Unit tests for API endpoints", Some(75)),
340 /// ];
341 ///
342 /// let output = tasks.format();
343 /// // Review PR (25%)
344 /// // Write tests (75%)
345 /// ```
346 fn format(&mut self) -> String;
347
348 /// Splits the collection into `parts` groups differing by at most one
349 /// task. A single task is duplicated into every group; fewer tasks than
350 /// parts distributes round-robin.
351 ///
352 /// ```rust
353 /// use kasl::libs::task::{Task, FormatTasks};
354 ///
355 /// let mut tasks = vec![
356 /// Task::new("Task 1", "", None),
357 /// Task::new("Task 2", "", None),
358 /// Task::new("Task 3", "", None),
359 /// Task::new("Task 4", "", None),
360 /// Task::new("Task 5", "", None),
361 /// ];
362 ///
363 /// let groups = tasks.divide(3);
364 ///
365 /// assert_eq!(groups.len(), 3);
366 /// assert_eq!(groups[0].len(), 2);
367 /// assert_eq!(groups[1].len(), 2);
368 /// assert_eq!(groups[2].len(), 1);
369 /// ```
370 fn divide(&mut self, parts: usize) -> Vec<Vec<Task>>;
371}
372
373impl FormatTasks for Vec<Task> {
374 fn divide(&mut self, parts: usize) -> Vec<Vec<Task>> {
375 let mut result: Vec<Vec<Task>> = Vec::with_capacity(parts);
376 let len = self.len();
377
378 if len == 0 || parts == 0 {
379 return result;
380 }
381
382 // A single task is broadcast to every group.
383 if len == 1 {
384 for _ in 0..parts {
385 result.push(self.to_vec());
386 }
387 return result;
388 }
389
390 // Fewer tasks than parts: round-robin so every group gets something.
391 if len < parts {
392 for i in 0..parts {
393 let mut part: Vec<Task> = Vec::with_capacity(len.div_ceil(parts));
394 for j in 0..len.div_ceil(parts) {
395 part.push(self[(i + j * len / parts) % len].clone());
396 }
397 result.push(part);
398 }
399 return result;
400 }
401
402 // General case: contiguous slices, remainder spread over the first groups.
403 let mut start = 0;
404 let mut end;
405 for i in 0..parts {
406 end = start + len / parts + if i < len % parts { 1 } else { 0 };
407 result.push(self[start..end].to_vec());
408 start = end;
409 }
410
411 result
412 }
413
414 fn format(&mut self) -> String {
415 self.iter()
416 .map(|task| {
417 let completeness_display = task.completeness.map_or("Unknown".to_string(), |comp| format!("{}%", comp));
418 format!("{} ({})", task.name, completeness_display)
419 })
420 .collect::<Vec<_>>()
421 .join("\n")
422 }
423}
424
425/// Replaces newlines and other whitespace runs with single spaces and trims.
426///
427/// Useful when pasting multi-line titles into `kasl task` prompts:
428/// ```text
429/// PROJ-42
430/// Fix login redirect
431/// ```
432/// becomes `PROJ-42 Fix login redirect`.
433pub fn collapse_whitespace(s: &str) -> String {
434 s.split_whitespace().collect::<Vec<_>>().join(" ")
435}
436
437/// Normalizes a task/commit name for near-duplicate and ignore-list comparison.
438///
439/// Trims whitespace, collapses internal spaces, lowercases, and strips
440/// trailing punctuation so variants like `"New commit"`, `"New commit."`,
441/// and `" New commit"` map to the same key.
442pub fn normalize_task_name(name: &str) -> String {
443 let mut s = collapse_whitespace(name).to_lowercase();
444
445 loop {
446 let trimmed = s.trim_end_matches(['.', ',', ';', '!', '?', ':', '…']).trim_end();
447 if trimmed.len() == s.len() {
448 break;
449 }
450 s = trimmed.to_string();
451 }
452
453 s
454}
455
456/// Returns true when `name` matches an ignore pattern exactly or by prefix
457/// (after normalization). Used for task discovery filtering.
458pub fn is_ignored_name(name: &str, ignore_names: &[String]) -> bool {
459 let n = normalize_task_name(name);
460 ignore_names.iter().any(|pat| {
461 let p = normalize_task_name(pat);
462 !p.is_empty() && (n == p || n.starts_with(&p))
463 })
464}