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/// 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}