1use 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 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 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 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 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 if self.tasks_by_uuid.contains_key(&task.uuid) {
127 return Ok(());
128 }
129
130 if task.id > 0 && self.tasks_by_id.contains_key(&task.id) {
132 task.id = 0;
133 }
134
135 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 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 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 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 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 pub fn tasks(&self) -> Vec<&Task> {
194 self.tasks.iter().filter(|t| !t.filtered).collect()
195 }
196
197 pub fn all_tasks(&self) -> &[Task] {
199 &self.tasks
200 }
201
202 pub fn tasks_mut(&mut self) -> &mut Vec<Task> {
204 &mut self.tasks
205 }
206
207 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 if task.id > 0 {
218 ids.insert(task.uuid.clone(), task.id);
219 }
220 }
221
222 save_ids(&self.ids_file_path, &ids)?;
224 Ok(())
225 }
226
227 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 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 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 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 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 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 if task.status == STATUS_RESOLVED {
279 task.id = 0;
280 }
281
282 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 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 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 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 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 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 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 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 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 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 pub fn num_total(&self) -> usize {
441 self.tasks.len()
442 }
443
444 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 pub fn must_load_task(&mut self, mut task: Task) -> Result<Task> {
454 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 Ok(self
464 .get_by_uuid(&uuid)
465 .unwrap_or_else(|| panic!("task {} not found after loading", uuid))
466 .clone())
467 }
468
469 pub fn must_update_task(&mut self, task: Task) -> Result<()> {
471 self.update_task(task)
472 }
473
474 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 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 task.delete_from_disk(&self.repo_path)?;
495
496 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 self.rebuild_indices();
506
507 Ok(())
508 }
509
510 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}