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 let mut entries: Vec<_> = std::fs::read_dir(&dir)?.filter_map(|e| e.ok()).collect();
84
85 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 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 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 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 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 if self.tasks_by_uuid.contains_key(&task.uuid) {
145 return Ok(());
146 }
147
148 if task.id > 0 && self.tasks_by_id.contains_key(&task.id) {
150 task.id = 0;
151 }
152
153 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 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 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 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 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 pub fn tasks(&self) -> Vec<&Task> {
212 self.tasks.iter().filter(|t| !t.filtered).collect()
213 }
214
215 pub fn all_tasks(&self) -> &[Task] {
217 &self.tasks
218 }
219
220 pub fn tasks_mut(&mut self) -> &mut Vec<Task> {
222 &mut self.tasks
223 }
224
225 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 if task.id > 0 {
236 ids.insert(task.uuid.clone(), task.id);
237 }
238 }
239
240 save_ids(&self.ids_file_path, &ids)?;
242 Ok(())
243 }
244
245 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 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 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 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 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 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 if task.status == STATUS_RESOLVED {
297 task.id = 0;
298 }
299
300 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 if task.status == STATUS_RESOLVED && task.resolved.is_none() {
313 task.resolved = Some(Utc::now());
314 }
315
316 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 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 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 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 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 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 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 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 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 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 pub fn num_total(&self) -> usize {
475 self.tasks.len()
476 }
477
478 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 pub fn must_load_task(&mut self, mut task: Task) -> Result<Task> {
488 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 Ok(self
498 .get_by_uuid(&uuid)
499 .unwrap_or_else(|| panic!("task {} not found after loading", uuid))
500 .clone())
501 }
502
503 pub fn must_update_task(&mut self, task: Task) -> Result<()> {
505 self.update_task(task)
506 }
507
508 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 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 task.delete_from_disk(&self.repo_path)?;
529
530 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 self.rebuild_indices();
540
541 Ok(())
542 }
543
544 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}