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