Skip to main content

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}