Skip to main content

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