rstask_core/
taskset.rs

1// TaskSet - collection of tasks with filtering and loading capabilities
2use crate::Result;
3use crate::constants::*;
4use crate::local_state::{load_ids, save_ids};
5use crate::query::Query;
6use crate::table::RowStyle;
7use crate::task::{Task, unmarshal_task};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Project {
15    pub name: String,
16    pub tasks: usize,
17    pub tasks_resolved: usize,
18    pub active: bool,
19    #[serde(with = "chrono::serde::ts_seconds")]
20    pub created: DateTime<Utc>,
21    #[serde(with = "chrono::serde::ts_seconds")]
22    pub resolved: DateTime<Utc>,
23    pub priority: String,
24}
25
26impl Project {
27    pub fn style(&self) -> RowStyle {
28        let mut style = RowStyle::default();
29
30        if self.active {
31            style.fg = FG_ACTIVE;
32            style.bg = BG_ACTIVE;
33        } else if self.priority == PRIORITY_CRITICAL {
34            style.fg = FG_PRIORITY_CRITICAL;
35        } else if self.priority == PRIORITY_HIGH {
36            style.fg = FG_PRIORITY_HIGH;
37        } else if self.priority == PRIORITY_LOW {
38            style.fg = FG_PRIORITY_LOW;
39        }
40
41        style
42    }
43}
44
45pub struct TaskSet {
46    tasks: Vec<Task>,
47    tasks_by_id: HashMap<i32, usize>,
48    tasks_by_uuid: HashMap<String, usize>,
49    ids_file_path: PathBuf,
50    repo_path: PathBuf,
51}
52
53impl TaskSet {
54    pub fn new(repo_path: PathBuf, ids_file_path: PathBuf) -> Self {
55        TaskSet {
56            tasks: Vec::new(),
57            tasks_by_id: HashMap::new(),
58            tasks_by_uuid: HashMap::new(),
59            ids_file_path,
60            repo_path,
61        }
62    }
63
64    /// Loads tasks from the repository
65    pub fn load(repo_path: &Path, ids_file_path: &Path, include_resolved: bool) -> Result<Self> {
66        let mut ts = TaskSet::new(repo_path.to_path_buf(), ids_file_path.to_path_buf());
67        let ids = load_ids(ids_file_path);
68
69        let statuses = if include_resolved {
70            ALL_STATUSES
71        } else {
72            NON_RESOLVED_STATUSES
73        };
74
75        for status in statuses {
76            let dir = repo_path.join(status);
77
78            if !dir.exists() {
79                continue;
80            }
81
82            for entry in std::fs::read_dir(&dir)? {
83                let entry = entry?;
84                let filename = entry.file_name();
85                let filename_str = filename.to_string_lossy();
86
87                // Skip hidden files
88                if filename_str.starts_with('.') {
89                    continue;
90                }
91
92                let path = entry.path();
93                match unmarshal_task(&path, &filename_str, &ids, status) {
94                    Ok(task) => {
95                        ts.load_task(task)?;
96                    }
97                    Err(e) => {
98                        eprintln!("Warning: error loading task: {}", e);
99                    }
100                }
101            }
102        }
103
104        // hide some tasks by default. This is useful for things like templates and
105        // recurring tasks which are shown either directly or with show- commands
106        for task in &mut ts.tasks {
107            if HIDDEN_STATUSES.contains(&task.status.as_str()) {
108                task.filtered = true;
109            }
110        }
111
112        Ok(ts)
113    }
114
115    /// Loads a task into the set
116    pub fn load_task(&mut self, mut task: Task) -> Result<()> {
117        task.normalise();
118
119        if task.uuid.is_empty() {
120            task.uuid = crate::util::must_get_uuid4_string();
121        }
122
123        task.validate()?;
124
125        // Don't overwrite existing tasks
126        if self.tasks_by_uuid.contains_key(&task.uuid) {
127            return Ok(());
128        }
129
130        // Remove ID if already taken
131        if task.id > 0 && self.tasks_by_id.contains_key(&task.id) {
132            task.id = 0;
133        }
134
135        // Assign ID if needed (for non-resolved tasks)
136        if task.id == 0 && task.status != STATUS_RESOLVED {
137            for id in 1..=MAX_TASKS_OPEN as i32 {
138                if !self.tasks_by_id.contains_key(&id) {
139                    task.id = id;
140                    break;
141                }
142            }
143        }
144
145        // Set created time if not set
146        if task.created == DateTime::<Utc>::from_timestamp(0, 0).unwrap() {
147            task.created = Utc::now();
148            task.write_pending = true;
149        }
150
151        let idx = self.tasks.len();
152        self.tasks_by_uuid.insert(task.uuid.clone(), idx);
153        if task.id > 0 {
154            self.tasks_by_id.insert(task.id, idx);
155        }
156        self.tasks.push(task);
157        Ok(())
158    }
159
160    /// Assigns IDs to tasks
161    pub fn assign_ids(&mut self) -> Result<()> {
162        let mut ids = load_ids(&self.ids_file_path);
163        let mut next_id = 1;
164
165        // Find next available ID
166        while ids.values().any(|&id| id == next_id) {
167            next_id += 1;
168        }
169
170        for (idx, task) in self.tasks.iter_mut().enumerate() {
171            if task.status != STATUS_RESOLVED && task.id == 0 {
172                ids.insert(task.uuid.clone(), next_id);
173                task.id = next_id;
174                self.tasks_by_id.insert(next_id, idx);
175                next_id += 1;
176            }
177        }
178
179        save_ids(&self.ids_file_path, &ids)?;
180        Ok(())
181    }
182
183    /// Filters tasks by a query
184    pub fn filter(&mut self, query: &Query) {
185        for task in &mut self.tasks {
186            if !task.matches_filter(query) {
187                task.filtered = true;
188            }
189        }
190    }
191
192    /// Returns unfiltered tasks only
193    pub fn tasks(&self) -> Vec<&Task> {
194        self.tasks.iter().filter(|t| !t.filtered).collect()
195    }
196
197    /// Returns all tasks regardless of filtered status
198    pub fn all_tasks(&self) -> &[Task] {
199        &self.tasks
200    }
201
202    /// Returns mutable reference to tasks
203    pub fn tasks_mut(&mut self) -> &mut Vec<Task> {
204        &mut self.tasks
205    }
206
207    /// Saves all pending changes
208    pub fn save_pending_changes(&mut self) -> Result<()> {
209        let mut ids = std::collections::HashMap::new();
210
211        for task in &mut self.tasks {
212            if task.write_pending {
213                task.save_to_disk(&self.repo_path)?;
214            }
215
216            // Build IDs map for all tasks with IDs
217            if task.id > 0 {
218                ids.insert(task.uuid.clone(), task.id);
219            }
220        }
221
222        // Save IDs map to disk
223        save_ids(&self.ids_file_path, &ids)?;
224        Ok(())
225    }
226
227    /// Gets a task by ID
228    pub fn get_by_id(&self, id: i32) -> Option<&Task> {
229        self.tasks_by_id.get(&id).map(|&idx| &self.tasks[idx])
230    }
231
232    /// Gets a mutable task by ID
233    pub fn get_by_id_mut(&mut self, id: i32) -> Option<&mut Task> {
234        self.tasks_by_id
235            .get(&id)
236            .copied()
237            .map(move |idx| &mut self.tasks[idx])
238    }
239
240    /// Gets a task by UUID
241    pub fn get_by_uuid(&self, uuid: &str) -> Option<&Task> {
242        self.tasks_by_uuid.get(uuid).map(|&idx| &self.tasks[idx])
243    }
244
245    /// Updates an existing task
246    pub fn update_task(&mut self, mut task: Task) -> Result<()> {
247        task.normalise();
248        task.validate()?;
249
250        let idx = *self
251            .tasks_by_uuid
252            .get(&task.uuid)
253            .ok_or_else(|| crate::RstaskError::TaskNotFound(task.uuid.clone()))?;
254
255        let old = &self.tasks[idx];
256
257        // Validate status transition
258        if old.status != task.status
259            && !crate::constants::is_valid_status_transition(&old.status, &task.status)
260        {
261            return Err(crate::RstaskError::InvalidStatusTransition(
262                old.status.clone(),
263                task.status.clone(),
264            ));
265        }
266
267        // Check for incomplete checklist
268        if old.status != task.status
269            && task.status == STATUS_RESOLVED
270            && task.notes.contains("- [ ] ")
271        {
272            return Err(crate::RstaskError::Other(
273                "Refusing to resolve task with incomplete checklist".to_string(),
274            ));
275        }
276
277        // Clear ID for resolved tasks
278        if task.status == STATUS_RESOLVED {
279            task.id = 0;
280        }
281
282        // Set resolved time
283        if task.status == STATUS_RESOLVED && task.resolved.is_none() {
284            task.resolved = Some(Utc::now());
285        }
286
287        task.write_pending = true;
288        self.tasks[idx] = task;
289
290        Ok(())
291    }
292
293    /// Sorts tasks by creation date (then by ID for stability)
294    pub fn sort_by_created_ascending(&mut self) {
295        self.tasks
296            .sort_by(|a, b| a.created.cmp(&b.created).then_with(|| a.id.cmp(&b.id)));
297    }
298
299    pub fn sort_by_created_descending(&mut self) {
300        self.tasks.sort_by(|a, b| b.created.cmp(&a.created));
301    }
302
303    /// Sorts tasks by priority (P0 > P1 > P2 > P3)
304    pub fn sort_by_priority_ascending(&mut self) {
305        self.tasks.sort_by(|a, b| a.priority.cmp(&b.priority));
306    }
307
308    pub fn sort_by_priority_descending(&mut self) {
309        self.tasks.sort_by(|a, b| b.priority.cmp(&a.priority));
310    }
311
312    /// Sorts tasks by resolved date
313    pub fn sort_by_resolved_ascending(&mut self) {
314        self.tasks.sort_by(|a, b| match (a.resolved, b.resolved) {
315            (Some(ar), Some(br)) => ar.cmp(&br),
316            (Some(_), None) => std::cmp::Ordering::Less,
317            (None, Some(_)) => std::cmp::Ordering::Greater,
318            (None, None) => std::cmp::Ordering::Equal,
319        });
320    }
321
322    pub fn sort_by_resolved_descending(&mut self) {
323        self.tasks.sort_by(|a, b| match (a.resolved, b.resolved) {
324            (Some(ar), Some(br)) => br.cmp(&ar),
325            (Some(_), None) => std::cmp::Ordering::Greater,
326            (None, Some(_)) => std::cmp::Ordering::Less,
327            (None, None) => std::cmp::Ordering::Equal,
328        });
329    }
330
331    /// Filters to show only specified status
332    pub fn filter_by_status(&mut self, status: &str) {
333        for task in &mut self.tasks {
334            if task.status != status {
335                task.filtered = true;
336            }
337        }
338    }
339
340    /// Filters to show only organized tasks (with tags or project)
341    pub fn filter_organised(&mut self) {
342        for task in &mut self.tasks {
343            if task.tags.is_empty() && task.project.is_empty() {
344                task.filtered = true;
345            }
346        }
347    }
348
349    /// Filters to show only unorganized tasks
350    pub fn filter_unorganised(&mut self) {
351        for task in &mut self.tasks {
352            if !task.tags.is_empty() || !task.project.is_empty() {
353                task.filtered = true;
354            }
355        }
356    }
357
358    /// Unhides tasks with hidden statuses
359    pub fn unhide(&mut self) {
360        for task in &mut self.tasks {
361            if HIDDEN_STATUSES.contains(&task.status.as_str()) {
362                task.filtered = false;
363            }
364        }
365    }
366
367    /// Gets all tags in use
368    pub fn get_tags(&self) -> Vec<String> {
369        let mut tagset = std::collections::HashSet::new();
370
371        for task in self.tasks() {
372            for tag in &task.tags {
373                tagset.insert(tag.clone());
374            }
375        }
376
377        let mut tags: Vec<String> = tagset.into_iter().collect();
378        tags.sort();
379        tags
380    }
381
382    /// Gets all projects with statistics
383    pub fn get_projects(&self) -> Vec<Project> {
384        let mut projects_map: HashMap<String, Project> = HashMap::new();
385
386        for task in &self.tasks {
387            if task.project.is_empty() {
388                continue;
389            }
390
391            let project = projects_map
392                .entry(task.project.clone())
393                .or_insert_with(|| Project {
394                    name: task.project.clone(),
395                    tasks: 0,
396                    tasks_resolved: 0,
397                    active: false,
398                    created: Utc::now(),
399                    resolved: DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
400                    priority: PRIORITY_LOW.to_string(),
401                });
402
403            project.tasks += 1;
404
405            if project.created == DateTime::<Utc>::from_timestamp(0, 0).unwrap()
406                || task.created < project.created
407            {
408                project.created = task.created;
409            }
410
411            if let Some(task_resolved) = task.resolved
412                && task_resolved > project.resolved
413            {
414                project.resolved = task_resolved;
415            }
416
417            if task.status == STATUS_RESOLVED {
418                project.tasks_resolved += 1;
419            }
420
421            if task.status == STATUS_ACTIVE {
422                project.active = true;
423            }
424
425            if task.status != STATUS_RESOLVED && task.priority < project.priority {
426                project.priority = task.priority.clone();
427            }
428        }
429
430        let mut names: Vec<String> = projects_map.keys().cloned().collect();
431        names.sort();
432
433        names
434            .into_iter()
435            .map(|name| projects_map.remove(&name).unwrap())
436            .collect()
437    }
438
439    /// Returns the total number of tasks
440    pub fn num_total(&self) -> usize {
441        self.tasks.len()
442    }
443
444    // "Must" helper methods that panic on error (for commands that should exit on failure)
445
446    /// Gets a task by ID, panics if not found
447    pub fn must_get_by_id(&self, id: i32) -> &Task {
448        self.get_by_id(id)
449            .unwrap_or_else(|| panic!("task with ID {} not found", id))
450    }
451
452    /// Loads a task into the set, returns the loaded task, panics on error
453    pub fn must_load_task(&mut self, mut task: Task) -> Result<Task> {
454        // Generate UUID if needed before loading
455        if task.uuid.is_empty() {
456            task.uuid = crate::util::must_get_uuid4_string();
457        }
458        let uuid = task.uuid.clone();
459
460        self.load_task(task)?;
461
462        // Return the newly loaded task
463        Ok(self
464            .get_by_uuid(&uuid)
465            .unwrap_or_else(|| panic!("task {} not found after loading", uuid))
466            .clone())
467    }
468
469    /// Updates a task, panics on error
470    pub fn must_update_task(&mut self, task: Task) -> Result<()> {
471        self.update_task(task)
472    }
473
474    /// Apply modifications from a query to filtered tasks
475    pub fn apply_modifications(&mut self, query: &Query) -> Result<()> {
476        for task in &mut self.tasks {
477            if !task.filtered {
478                task.modify(query);
479            }
480        }
481        Ok(())
482    }
483
484    /// Delete a task by UUID
485    pub fn delete_task(&mut self, uuid: &str) -> Result<()> {
486        let idx = *self
487            .tasks_by_uuid
488            .get(uuid)
489            .ok_or_else(|| crate::RstaskError::TaskNotFound(uuid.to_string()))?;
490
491        let task = &self.tasks[idx];
492
493        // Delete from disk
494        task.delete_from_disk(&self.repo_path)?;
495
496        // Remove from in-memory structures
497        let id = task.id;
498        self.tasks.remove(idx);
499        self.tasks_by_uuid.remove(uuid);
500        if id > 0 {
501            self.tasks_by_id.remove(&id);
502        }
503
504        // Rebuild indices since we removed an element
505        self.rebuild_indices();
506
507        Ok(())
508    }
509
510    /// Rebuild task indices after removal
511    fn rebuild_indices(&mut self) {
512        self.tasks_by_uuid.clear();
513        self.tasks_by_id.clear();
514
515        for (idx, task) in self.tasks.iter().enumerate() {
516            self.tasks_by_uuid.insert(task.uuid.clone(), idx);
517            if task.id > 0 {
518                self.tasks_by_id.insert(task.id, idx);
519            }
520        }
521    }
522}