gittask/
lib.rs

1use std::borrow::ToOwned;
2use std::collections::HashMap;
3use std::ops::Deref;
4use std::time::{SystemTime, UNIX_EPOCH};
5use git2::*;
6use serde_json;
7use serde::{Deserialize, Serialize};
8
9const NAME: &'static str = "name";
10const DESCRIPTION: &'static str = "description";
11const STATUS: &'static str = "status";
12const CREATED: &'static str = "created";
13
14#[derive(Clone, Serialize, Deserialize)]
15pub struct Task {
16    id: Option<String>,
17    props: HashMap<String, String>,
18    comments: Option<Vec<Comment>>,
19    labels: Option<Vec<Label>>,
20}
21
22#[derive(Clone, PartialEq, Serialize, Deserialize)]
23pub struct Comment {
24    id: Option<String>,
25    props: HashMap<String, String>,
26    text: String,
27}
28
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30pub struct Label {
31    name: String,
32    color: Option<String>,
33    description: Option<String>,
34}
35
36impl Task {
37    pub fn new(name: String, description: String, status: String) -> Result<Task, &'static str> {
38        if !name.is_empty() && !status.is_empty() {
39            Ok(Self::construct_task(name, description, status, None))
40        } else {
41            Err("Name or status is empty")
42        }
43    }
44
45    pub fn from_properties(id: String, mut props: HashMap<String, String>) -> Result<Task, &'static str> {
46        let name = props.get(NAME).unwrap_or(&"".to_owned()).to_owned();
47        let status = props.get(STATUS).unwrap_or(&"".to_owned()).to_owned();
48
49        if !name.is_empty() && !status.is_empty() {
50            if !props.contains_key("created") {
51                props.insert("created".to_string(), get_current_timestamp().to_string());
52            }
53
54            Ok(Task{ id: Some(id), props, comments: None, labels: None })
55        } else {
56            Err("Name or status is empty")
57        }
58    }
59
60    fn construct_task(name: String, description: String, status: String, created: Option<u64>) -> Task {
61        let mut props = HashMap::from([
62            (NAME.to_owned(), name),
63            (DESCRIPTION.to_owned(), description),
64            (STATUS.to_owned(), status),
65            (CREATED.to_owned(), created.unwrap_or(get_current_timestamp()).to_string()),
66        ]);
67
68        if let Ok(Some(current_user)) = get_current_user() {
69            props.insert("author".to_string(), current_user);
70        }
71
72        Task {
73            id: None,
74            props,
75            comments: None,
76            labels: None,
77        }
78    }
79
80    pub fn get_id(&self) -> Option<String> {
81        match &self.id {
82            Some(id) => Some(id.clone()),
83            _ => None
84        }
85    }
86
87    pub fn set_id(&mut self, id: String) {
88        self.id = Some(id);
89    }
90
91    pub fn get_property(&self, prop: &str) -> Option<&String> {
92        self.props.get(prop)
93    }
94
95    pub fn get_all_properties(&self) -> &HashMap<String, String> {
96        &self.props
97    }
98
99    pub fn set_property(&mut self, prop: &str, value: &str) {
100        self.props.insert(prop.to_string(), value.to_string());
101    }
102
103    pub fn has_property(&self, prop: &str) -> bool {
104        self.props.contains_key(prop)
105    }
106
107    pub fn delete_property(&mut self, prop: &str) -> bool {
108        self.props.remove(prop).is_some()
109    }
110
111    pub fn get_comments(&self) -> &Option<Vec<Comment>> {
112        &self.comments
113    }
114
115    pub fn add_comment(&mut self, id: Option<String>, mut props: HashMap<String, String>, text: String) -> Comment {
116        if self.comments.is_none() {
117            self.comments = Some(vec![]);
118        }
119
120        let id = Some(id.unwrap_or_else(|| (self.comments.as_ref().unwrap().len() + 1).to_string()));
121
122        if !props.contains_key("created") {
123            props.insert("created".to_string(), get_current_timestamp().to_string());
124        }
125
126        if !props.contains_key("author") {
127            if let Ok(Some(current_user)) = get_current_user() {
128                props.insert("author".to_string(), current_user);
129            }
130        }
131
132        let comment = Comment {
133            id,
134            props,
135            text,
136        };
137
138        self.comments.as_mut().unwrap().push(comment.clone());
139
140        comment
141    }
142
143    pub fn set_comments(&mut self, comments: Vec<Comment>) {
144        self.comments = Some(comments);
145    }
146
147    pub fn delete_comment(&mut self, id: &String) -> Result<(), String> {
148        if self.comments.is_none() {
149            return Err("Task has no comments".to_string());
150        }
151
152        let index = self.comments.as_ref().unwrap().iter().position(|comment| comment.get_id().unwrap() == id.deref());
153
154        if index.is_none() {
155            return Err(format!("Comment ID {id} not found"));
156        }
157
158        self.comments.as_mut().unwrap().remove(index.unwrap());
159
160        Ok(())
161    }
162
163    pub fn get_labels(&self) -> &Option<Vec<Label>> {
164        &self.labels
165    }
166
167    pub fn add_label(&mut self, name: String, description: Option<String>, color: Option<String>) -> Label {
168        if self.labels.is_none() {
169            self.labels = Some(vec![]);
170        }
171
172        let label = Label {
173            name: name.clone(),
174            description,
175            color,
176        };
177
178        self.labels.as_mut().unwrap().push(label.clone());
179
180        label
181    }
182
183    pub fn set_labels(&mut self, labels: Vec<Label>) {
184        self.labels = Some(labels);
185    }
186
187    pub fn delete_label(&mut self, name: &str) -> Result<(), String> {
188        if self.labels.is_none() {
189            return Err("Task has no labels".to_string());
190        }
191
192        let index = self.labels.as_ref().unwrap().iter().position(|label| label.name == name);
193
194        if index.is_none() {
195            return Err(format!("Label with name '{name}' not found"));
196        }
197
198        self.labels.as_mut().unwrap().remove(index.unwrap());
199
200        Ok(())
201    }
202
203    pub fn get_label_by_name(&self, name: &str) -> Option<&Label> {
204        self.labels
205            .as_ref()
206            .and_then(|labels| labels.iter().find(|label| label.name == name))
207    }
208}
209
210impl Comment {
211    pub fn new(id: String, props: HashMap<String, String>, text: String) -> Comment {
212        Comment {
213            id: Some(id),
214            props,
215            text,
216        }
217    }
218
219    pub fn get_id(&self) -> Option<String> {
220        match &self.id {
221            Some(id) => Some(id.clone()),
222            _ => None
223        }
224    }
225
226    pub fn set_id(&mut self, id: String) {
227        self.id = Some(id);
228    }
229
230    pub fn get_all_properties(&self) -> &HashMap<String, String> {
231        &self.props
232    }
233
234    pub fn get_text(&self) -> String {
235        self.text.to_string()
236    }
237
238    pub fn set_text(&mut self, text: String) {
239        self.text = text;
240    }
241}
242
243impl Label {
244    pub fn new(name: String, color: Option<String>, description: Option<String>) -> Label {
245        Label {
246            name, color, description
247        }
248    }
249
250    pub fn get_name(&self) -> String {
251        self.name.to_string()
252    }
253
254    pub fn get_color(&self) -> String {
255        self.color.clone().unwrap_or_else(|| String::from(""))
256    }
257
258    pub fn set_color(&mut self, color: String) {
259        self.color = Some(color);
260    }
261
262    pub fn get_description(&self) -> Option<String> {
263        self.description.clone()
264    }
265
266    pub fn set_description(&mut self, description: String) {
267        self.description = Some(description);
268    }
269}
270
271macro_rules! map_err {
272    ($expr:expr) => {
273        $expr.map_err(|e| e.message().to_owned())?
274    }
275}
276
277pub fn list_tasks() -> Result<Vec<Task>, String> {
278    let repo = map_err!(Repository::discover("."));
279    let task_ref = map_err!(repo.find_reference(&get_ref_path()));
280    let task_tree = map_err!(task_ref.peel_to_tree());
281
282    let mut result = vec![];
283
284    let _ = map_err!(task_tree.walk(TreeWalkMode::PreOrder, |_, entry| {
285        let oid = entry.id();
286        let blob = repo.find_blob(oid).unwrap();
287        let content = blob.content();
288
289        let task = serde_json::from_slice(content).unwrap();
290        result.push(task);
291
292        TreeWalkResult::Ok
293    }));
294
295    Ok(result)
296}
297
298pub fn find_task(id: &str) -> Result<Option<Task>, String> {
299    let repo = map_err!(Repository::discover("."));
300    let task_ref = repo.find_reference(&get_ref_path());
301    match task_ref {
302        Ok(task_ref) => {
303            let task_tree = map_err!(task_ref.peel_to_tree());
304            let result = match task_tree.get_name(id) {
305                Some(entry) => {
306                    let oid = entry.id();
307                    let blob = map_err!(repo.find_blob(oid));
308                    let content = blob.content();
309                    let task = serde_json::from_slice(content).unwrap();
310
311                    Some(task)
312                },
313                None => None,
314            };
315
316            Ok(result)
317        },
318        Err(_) => Ok(None)
319    }
320}
321
322pub fn delete_tasks(ids: &[&str]) -> Result<(), String> {
323    let repo = map_err!(Repository::discover("."));
324    let task_ref = map_err!(repo.find_reference(&get_ref_path()));
325    let task_tree = map_err!(task_ref.peel_to_tree());
326
327    let mut treebuilder = map_err!(repo.treebuilder(Some(&task_tree)));
328    for id in ids {
329        map_err!(treebuilder.remove(id));
330    }
331    let tree_oid = map_err!(treebuilder.write());
332
333    let parent_commit = map_err!(task_ref.peel_to_commit());
334    let parents = vec![parent_commit];
335    let me = &map_err!(repo.signature());
336
337    let mut ids = ids.iter().map(|id| id.parse::<u64>().unwrap()).collect::<Vec<_>>();
338    ids.sort();
339    let ids = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(", ");
340    map_err!(repo.commit(Some(&get_ref_path()), me, me, format!("Delete task {}", ids).as_str(), &map_err!(repo.find_tree(tree_oid)), &parents.iter().collect::<Vec<_>>()));
341
342    Ok(())
343}
344
345pub fn clear_tasks() -> Result<u64, String> {
346    let repo = map_err!(Repository::discover("."));
347    let task_ref = map_err!(repo.find_reference(&get_ref_path()));
348    let task_tree = map_err!(task_ref.peel_to_tree());
349
350    let mut treebuilder = map_err!(repo.treebuilder(Some(&task_tree)));
351    let task_count = treebuilder.len() as u64;
352    map_err!(treebuilder.clear());
353    let tree_oid = map_err!(treebuilder.write());
354
355    let parent_commit = map_err!(task_ref.peel_to_commit());
356    let parents = vec![parent_commit];
357    let me = &map_err!(repo.signature());
358
359    map_err!(repo.commit(Some(&get_ref_path()), me, me, "Clear tasks", &map_err!(repo.find_tree(tree_oid)), &parents.iter().collect::<Vec<_>>()));
360
361    Ok(task_count)
362}
363
364pub fn create_task(mut task: Task) -> Result<Task, String> {
365    let repo = map_err!(Repository::discover("."));
366    let task_ref_result = repo.find_reference(&get_ref_path());
367    let source_tree = match task_ref_result {
368        Ok(ref reference) => {
369            match reference.peel_to_tree() {
370                Ok(tree) => Some(tree),
371                _ => None
372            }
373        }
374        _ => { None }
375    };
376
377    if task.get_id().is_none() {
378        let id = get_next_id().unwrap_or_else(|_| "1".to_string());
379        task.set_id(id);
380    }
381    let string_content = serde_json::to_string(&task).unwrap();
382    let content = string_content.as_bytes();
383    let oid = map_err!(repo.blob(content));
384    let mut treebuilder = map_err!(repo.treebuilder(source_tree.as_ref()));
385    map_err!(treebuilder.insert(&task.get_id().unwrap(), oid, FileMode::Blob.into()));
386    let tree_oid = map_err!(treebuilder.write());
387
388    let me = &map_err!(repo.signature());
389    let mut parents = vec![];
390    if task_ref_result.is_ok() {
391        let parent_commit = map_err!(task_ref_result).peel_to_commit();
392        if parent_commit.is_ok() {
393            parents.push(map_err!(parent_commit));
394        }
395    }
396    map_err!(repo.commit(Some(&get_ref_path()), me, me, format!("Create task {}", &task.get_id().unwrap_or_else(|| String::from("?"))).as_str(), &map_err!(repo.find_tree(tree_oid)), &parents.iter().collect::<Vec<_>>()));
397
398    Ok(task)
399}
400
401pub fn update_task(task: Task) -> Result<String, String> {
402    let repo = map_err!(Repository::discover("."));
403    let task_ref_result = map_err!(repo.find_reference(&get_ref_path()));
404    let parent_commit = map_err!(task_ref_result.peel_to_commit());
405    let source_tree = map_err!(task_ref_result.peel_to_tree());
406    let string_content = serde_json::to_string(&task).unwrap();
407    let content = string_content.as_bytes();
408    let oid = map_err!(repo.blob(content));
409    let mut treebuilder = map_err!(repo.treebuilder(Some(&source_tree)));
410    map_err!(treebuilder.insert(&task.get_id().unwrap(), oid, FileMode::Blob.into()));
411    let tree_oid = map_err!(treebuilder.write());
412
413    let me = &map_err!(repo.signature());
414    let parents = vec![parent_commit];
415    map_err!(repo.commit(Some(&get_ref_path()), me, me, format!("Update task {}", &task.get_id().unwrap()).as_str(), &map_err!(repo.find_tree(tree_oid)), &parents.iter().collect::<Vec<_>>()));
416
417    Ok(task.get_id().unwrap())
418}
419
420fn get_next_id() -> Result<String, String> {
421    let repo = map_err!(Repository::discover("."));
422    let task_ref = map_err!(repo.find_reference(&get_ref_path()));
423    let task_tree = map_err!(task_ref.peel_to_tree());
424
425    let mut result = 0;
426
427    let _ = map_err!(task_tree.walk(TreeWalkMode::PreOrder, |_, entry| {
428        let entry_name = entry.name().unwrap();
429        match entry_name.parse::<i64>() {
430            Ok(id) => {
431                if id > result {
432                    result = id;
433                }
434            },
435            _ => return TreeWalkResult::Skip
436        };
437
438        TreeWalkResult::Ok
439    }));
440
441    Ok((result + 1).to_string())
442}
443
444pub fn update_task_id(id: &str, new_id: &str) -> Result<(), String> {
445    let mut task = find_task(&id)?.unwrap();
446    task.set_id(new_id.to_string());
447    create_task(task)?;
448    delete_tasks(&[&id])?;
449
450    Ok(())
451}
452
453pub fn update_comment_id(task_id: &str, id: &str, new_id: &str) -> Result<(), String> {
454    let mut task = find_task(&task_id)?.unwrap().clone();
455    let comments = task.get_comments();
456    match comments {
457        Some(comments) => {
458            let updated_comments = comments.iter().map(|c| {
459                if c.get_id().unwrap() == id {
460                    let mut c = c.clone();
461                    c.set_id(new_id.to_string());
462                    c
463                } else {
464                    c.clone()
465                }
466            }).collect::<Vec<_>>();
467            task.set_comments(updated_comments);
468            update_task(task)?;
469        },
470        None => {}
471    }
472
473    Ok(())
474}
475
476pub fn list_remotes(remote: &Option<String>) -> Result<Vec<String>, String> {
477    let repo = map_err!(Repository::discover("."));
478    let remotes = map_err!(repo.remotes());
479    Ok(remotes.iter()
480        .filter(|s| remote.is_none() || remote.as_ref().unwrap().as_str() == s.unwrap())
481        .map(|s| repo.find_remote(s.unwrap()).unwrap().url().unwrap().to_owned())
482        .collect())
483}
484
485fn get_current_timestamp() -> u64 {
486    SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
487}
488
489fn get_current_user() -> Result<Option<String>, String> {
490    let repo = map_err!(Repository::discover("."));
491    let me = &map_err!(repo.signature());
492    match me.name() {
493        Some(name) => Ok(Some(String::from(name))),
494        _ => match me.email() {
495            Some(email) => Ok(Some(String::from(email))),
496            _ => Ok(None),
497        }
498    }
499}
500
501pub fn get_ref_path() -> String {
502    get_config_value("task.ref").unwrap_or_else(|_| "refs/tasks/tasks".to_string())
503}
504
505pub fn get_config_value(key: &str) -> Result<String, String> {
506    let repo = map_err!(Repository::discover("."));
507    let config = map_err!(repo.config());
508    Ok(map_err!(config.get_string(key)))
509}
510
511pub fn set_config_value(key: &str, value: &str) -> Result<(), String> {
512    let repo = map_err!(Repository::discover("."));
513    let mut config = map_err!(repo.config());
514    map_err!(config.set_str(key, value));
515    Ok(())
516}
517
518pub fn set_ref_path(ref_path: &str, move_ref: bool) -> Result<(), String> {
519    let repo = map_err!(Repository::discover("."));
520
521    let current_reference = repo.find_reference(&get_ref_path());
522    if let Ok(current_reference) = &current_reference {
523        let commit = map_err!(current_reference.peel_to_commit());
524        map_err!(repo.reference(ref_path, commit.id(), true, "task.ref migrated"));
525    }
526
527    let mut config = map_err!(repo.config());
528    map_err!(config.set_str("task.ref", ref_path));
529
530    if move_ref && current_reference.is_ok() {
531        map_err!(current_reference.unwrap().delete());
532    }
533
534    Ok(())
535}
536
537#[cfg(test)]
538mod test {
539    use std::collections::HashMap;
540    use crate::*;
541
542    #[test]
543    fn test_ref_path() {
544        let ref_path = get_ref_path();
545        assert!(set_ref_path("refs/heads/test-git-task", true).is_ok());
546        assert_eq!(get_ref_path(), "refs/heads/test-git-task");
547        assert!(set_ref_path(&ref_path, true).is_ok());
548        assert_eq!(get_ref_path(), ref_path);
549    }
550
551    #[test]
552    fn test_create_update_delete_task() {
553        let id = get_next_id().unwrap_or_else(|_| "1".to_string());
554        let task = Task::construct_task("Test task".to_string(), "Description goes here".to_string(), "OPEN".to_string(), Some(get_current_timestamp()));
555        let create_result = create_task(task);
556        assert!(create_result.is_ok());
557        let mut task = create_result.unwrap();
558        assert_eq!(task.get_id(), Some(id.clone()));
559        assert_eq!(task.get_property("name").unwrap(), "Test task");
560        assert_eq!(task.get_property("description").unwrap(), "Description goes here");
561        assert_eq!(task.get_property("status").unwrap(), "OPEN");
562        assert!(task.has_property("created"));
563
564        task.set_property("description", "Updated description");
565        let comment_props = HashMap::from([("author".to_string(), "Some developer".to_string())]);
566        task.add_comment(None, comment_props, "This is a comment".to_string());
567        task.set_property("custom_prop", "Custom content");
568        let update_result = update_task(task);
569        assert!(update_result.is_ok());
570        assert_eq!(update_result.unwrap(), id.clone());
571
572        let find_result = find_task(&id);
573        assert!(find_result.is_ok());
574        let task = find_result.unwrap();
575        assert!(task.is_some());
576        let task = task.unwrap();
577        assert_eq!(task.get_id(), Some(id.clone()));
578        assert_eq!(task.get_property("description").unwrap(), "Updated description");
579        let comments = task.get_comments().clone();
580        assert!(comments.is_some());
581        let comments = comments.unwrap();
582        assert_eq!(comments.len(), 1);
583        let comment = comments.first().unwrap();
584        assert_eq!(comment.get_text(), "This is a comment".to_string());
585        let comment_props = comment.clone().props;
586        assert_eq!(comment_props.get("author").unwrap(), &"Some developer".to_string());
587        assert_eq!(task.get_property("custom_prop").unwrap(), "Custom content");
588
589        let delete_result = delete_tasks(&[&id]);
590        assert!(delete_result.is_ok());
591
592        let find_result = find_task(&id);
593        assert!(find_result.is_ok());
594        let task = find_result.unwrap();
595        assert!(task.is_none());
596    }
597
598    #[test]
599    fn test_update_comment_id() {
600        // Create a task first
601        let id = get_next_id().unwrap_or_else(|_| "1".to_string());
602        let task = Task::construct_task(
603            "Test task".to_string(),
604            "Description goes here".to_string(),
605            "OPEN".to_string(),
606            Some(get_current_timestamp())
607        );
608        let create_result = create_task(task);
609        assert!(create_result.is_ok());
610        let mut task = create_result.unwrap();
611
612        // Add a comment to the task
613        let comment_props = HashMap::from([("author".to_string(), "Some developer".to_string())]);
614        let comment = task.add_comment(Some("1".to_string()), comment_props, "Test comment".to_string());
615        assert_eq!(comment.get_id().unwrap(), "1");
616        let update_result = update_task(task);
617        assert!(update_result.is_ok());
618
619        // Update the comment ID
620        let result = update_comment_id(&id, "1", "2");
621        assert!(result.is_ok());
622
623        // Verify the comment ID was updated
624        let updated_task = find_task(&id).unwrap().unwrap();
625        let updated_comments = updated_task.get_comments().as_ref().unwrap();
626        assert_eq!(updated_comments.len(), 1);
627        assert_eq!(updated_comments[0].get_id().unwrap(), "2");
628
629        // Clean up
630        let delete_result = delete_tasks(&[&id]);
631        assert!(delete_result.is_ok());
632    }
633
634    #[test]
635    fn test_clear_tasks() {
636        let id = get_next_id().unwrap_or_else(|_| "1".to_string());
637        let task = Task::construct_task("Test task".to_string(), "Description goes here".to_string(), "OPEN".to_string(), Some(get_current_timestamp()));
638        let create_result = create_task(task);
639        assert!(create_result.is_ok());
640        let task = create_result.unwrap();
641        assert_eq!(task.get_id(), Some(id.clone()));
642
643        let id = get_next_id().unwrap_or_else(|_| "2".to_string());
644        let task2 = Task::construct_task("Another task".to_string(), "Another description".to_string(), "IN_PROGRESS".to_string(), Some(get_current_timestamp()));
645        let create_result2 = create_task(task2);
646        assert!(create_result2.is_ok());
647        let task2 = create_result2.unwrap();
648        assert_eq!(task2.get_id(), Some(id.clone()));
649
650        let id = get_next_id().unwrap_or_else(|_| "3".to_string());
651        let task3 = Task::construct_task("Third task".to_string(), "Third description".to_string(), "CLOSED".to_string(), Some(get_current_timestamp()));
652        let create_result3 = create_task(task3);
653        assert!(create_result3.is_ok());
654        let task3 = create_result3.unwrap();
655        assert_eq!(task3.get_id(), Some(id.clone()));
656
657        let clear_result = crate::clear_tasks();
658        assert!(clear_result.is_ok());
659        assert_eq!(clear_result.unwrap(), 3);
660
661        let find_result = find_task(&id);
662        assert!(find_result.is_ok());
663        let task = find_result.unwrap();
664        assert!(task.is_none());
665
666        let find_result = find_task(&task2.get_id().unwrap());
667        assert!(find_result.is_ok());
668        let task = find_result.unwrap();
669        assert!(task.is_none());
670
671        let find_result = find_task(&task3.get_id().unwrap());
672        assert!(find_result.is_ok());
673        let task = find_result.unwrap();
674        assert!(task.is_none());
675    }
676}