local_issues_lib/
lib.rs

1//! ## Example
2//! 
3//! [more](https://github.com/Uliboooo/local_issues_lib/blob/main/examples/main.rs)
4//!
5//! ```rust
6//! use std::env;
7//! use local_issues_lib::{Project, ProjectManager, config::Config, display_options::DisplayOptions};
8//!
9//! fn main() -> Result<(), local_issues_lib::Error> {
10//!     let project_path = env::current_dir().unwrap().join("examples").join("project");
11//!     let mut config = Config::new(); //! to actually use it, save it to a file with the load function and then use the
12//!     config.change_current_user("test_user");
13//!
14//!     let current_user = config.get_current_user().unwrap();
15//!
16//!     let mut p = Project::open_or_create(&project_path, "example_project")?;
17//!
18//!     //! 👇's id is 1
19//!     p.add_issue("issue1", current_user.clone());
20//!     p.add_comment(1, "first comment", current_user.clone());
21//!     p.save()?;
22//!
23//!     let mut p = Project::open(&project_path)?;
24//!     p.add_comment(1, "second comment", current_user.clone());
25//!
26//!     p.add_issue("will close by resolve", "test_author");
27//!     p.add_issue("will close by unresolved", "test_author");
28//!
29//!     p.to_close_issue(2, true);
30//!     p.to_close_issue(3, false);
31//!
32//!     println!(
33//!         "{}",
34//!         DisplayOptions::new().contain_close_issues(true).content(&p)
35//!     );
36//!     p.save()?;
37//!     Ok(())
38//! }
39//!```
40//!
41
42pub mod config;
43pub mod display_options;
44pub mod storage;
45mod users;
46
47use chrono::{DateTime, Local};
48use serde::{Deserialize, Serialize};
49use std::{
50    collections::HashMap,
51    fmt::Display,
52    io,
53    path::{Path, PathBuf},
54};
55// use users::ManageUsers;
56pub use users::{User, Users};
57
58pub const VERSION: &str = env!("CARGO_PKG_VERSION");
59pub const DB_NAME: &str = "db.json";
60
61#[derive(Debug)]
62pub enum Error {
63    DbError(storage::Error),
64    SomeError,
65    NotFound,
66    Io(io::Error),
67}
68
69impl Display for Error {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Error::DbError(e) => write!(f, "db error: {}", e),
73            Error::SomeError => write!(f, "some error. please retry."),
74            Error::NotFound => write!(f, "not found"),
75            Error::Io(error) => write!(f, "io error: {}", error),
76        }
77    }
78}
79
80impl Error {
81    pub fn is_file_is_zero(&self) -> bool {
82        match self {
83            Error::DbError(e) => matches!(e, storage::Error::FileIsZero),
84            _ => false,
85        }
86    }
87}
88
89#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
90pub struct Message {
91    message: String,
92    show: bool,
93    created_at: DateTime<Local>,
94    author: User,
95}
96impl Message {
97    pub fn new<S: AsRef<str>, U: Into<User>>(message: S, show: bool, author: U) -> Self {
98        Self {
99            message: message.as_ref().to_string(),
100            show,
101            created_at: Local::now(),
102            author: author.into(),
103        }
104    }
105    fn hide(&mut self) {
106        self.show = false;
107    }
108    fn show(&mut self) {
109        self.show = true
110    }
111}
112
113impl Display for Message {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        if self.show {
116            writeln!(f, "{}\n\t{}\n", self.created_at.to_rfc2822(), self.message)
117        } else {
118            write!(f, "")
119        }
120    }
121}
122
123trait ManageMessage {
124    fn new() -> Self
125    where
126        Self: Sized;
127    fn hide_message_by_id(&mut self, id: u64);
128    fn show_message_by_id(&mut self, id: u64);
129    fn add_message_to(&mut self, new_message: Message);
130    fn rm_message(&mut self, id: u64);
131}
132
133#[derive(Debug, Default, Serialize, Deserialize, Clone)]
134pub struct Messages(Vec<Message>);
135
136impl Messages {
137    fn count_messages(&self) -> i32 {
138        self.0.len() as i32
139    }
140}
141
142impl ManageMessage for Messages {
143    fn new() -> Self
144    where
145        Self: Sized,
146    {
147        Self(Vec::new())
148    }
149
150    fn hide_message_by_id(&mut self, id: u64) {
151        if let Some(f) = self.0.get_mut(id as usize) {
152            f.hide()
153        }
154    }
155
156    fn show_message_by_id(&mut self, id: u64) {
157        if let Some(f) = self.0.get_mut(id as usize) {
158            f.show()
159        }
160    }
161
162    fn add_message_to(&mut self, new_message: Message) {
163        // self.id_increment();
164        self.0.push(new_message);
165    }
166
167    /// ⚠️ this fn remove message in Vec and rewrite index.
168    fn rm_message(&mut self, id: u64) {
169        self.0.remove(id as usize);
170    }
171}
172
173// Tue, 29 Apr 2025 18:12:31 +0900
174// message
175//
176// Tue, 29 Apr 2025 18:12:31 +0900
177// message2
178impl Display for Messages {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        write!(
181            f,
182            "{}",
183            self.0
184                .iter()
185                .map(|f| { format!("{}", f) })
186                .collect::<String>()
187        )
188    }
189}
190
191pub trait StatusT {
192    fn is_opened(&self) -> bool;
193    fn to_emoji(&self) -> String;
194}
195
196#[derive(Debug, Serialize, Deserialize, Clone)]
197enum Status {
198    Open,
199    Closed(Closed),
200}
201
202impl StatusT for Status {
203    fn is_opened(&self) -> bool {
204        matches!(self, Status::Open)
205    }
206
207    fn to_emoji(&self) -> String {
208        match self {
209            Status::Open => "🟢",
210            Status::Closed(closed) => match closed {
211                Closed::Resolved => "✅🔴",
212                Closed::UnResolved => "❌🔴",
213            },
214        }
215        .to_string()
216    }
217}
218
219impl Display for Status {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        match self {
222            Status::Open => write!(f, "Open"),
223            Status::Closed(closed) => match closed {
224                Closed::Resolved => write!(f, "Resolved Closed"),
225                Closed::UnResolved => write!(f, "UnResolved Closed"),
226            },
227        }
228    }
229}
230
231#[derive(Debug, Serialize, Deserialize, Clone)]
232enum Closed {
233    Resolved,
234    UnResolved,
235}
236
237trait IssueTrait {
238    fn update(&mut self);
239    fn comment<S: AsRef<str>, U: Into<User>>(&mut self, msg_str: S, author: U);
240    fn rename<S: AsRef<str>>(&mut self, new_title: S);
241    fn edit_due(&mut self, new_due: DateTime<Local>);
242    fn rm_comment(&mut self, id: u64);
243    fn hide_message(&mut self, id: u64);
244    fn show_message(&mut self, id: u64);
245    fn search<S: AsRef<str>>(&self, target_title: S) -> Option<u64>;
246    fn search_list<S: AsRef<str>>(&self, target_title: S) -> Option<Vec<u64>>;
247    fn close(&mut self, is_resolved: bool);
248    fn open(&mut self);
249    fn is_opened(&self) -> bool;
250    fn get_message(&self) -> &Messages;
251    fn change_author<U: Into<User>>(&mut self, new_author: U);
252    fn assign_user<U: Into<User>>(&mut self, user: U);
253}
254
255#[derive(Debug, Serialize, Deserialize, Clone)]
256pub struct Issue {
257    name: String,
258    messages: Messages,
259    status: Status,
260    created_at: DateTime<Local>,
261    updated_at: DateTime<Local>,
262    due_date: Option<DateTime<Local>>,
263    author: User,
264    assigned_member: Option<Users>,
265}
266
267impl Issue {
268    pub fn new<S: AsRef<str>, U: Into<User>>(name: S, author: U) -> Self {
269        Self {
270            name: name.as_ref().to_string(),
271            messages: Messages::new(),
272            status: Status::Open,
273            created_at: Local::now(),
274            updated_at: Local::now(),
275            due_date: None,
276            author: author.into(),
277            assigned_member: None,
278        }
279    }
280    pub fn count_message(&self) -> i32 {
281        self.messages.count_messages()
282    }
283}
284
285impl IssueTrait for Issue {
286    fn update(&mut self) {
287        self.updated_at = Local::now();
288    }
289
290    fn comment<S: AsRef<str>, U: Into<User>>(&mut self, msg_str: S, author: U) {
291        self.update();
292        self.messages
293            .add_message_to(Message::new(msg_str, true, author));
294    }
295
296    fn rename<S: AsRef<str>>(&mut self, new_title: S) {
297        self.update();
298        self.name = new_title.as_ref().to_string();
299    }
300
301    /// set due_date as new_due. if it is `None`, change to Some(DateTime<Local>)
302    fn edit_due(&mut self, new_due: DateTime<Local>) {
303        self.update();
304        self.due_date = Some(new_due);
305    }
306
307    /// ⚠️ this fn remove message in Vec and rewrite index.
308    /// recommend hide_message().
309    fn rm_comment(&mut self, id: u64) {
310        self.update();
311        self.messages.rm_message(id);
312    }
313
314    fn hide_message(&mut self, id: u64) {
315        self.update();
316        self.messages.hide_message_by_id(id);
317    }
318
319    fn show_message(&mut self, id: u64) {
320        self.update();
321        self.messages.show_message_by_id(id);
322    }
323
324    /// return first id found.
325    fn search<S: AsRef<str>>(&self, target_title: S) -> Option<u64> {
326        self.messages
327            .0
328            .iter()
329            .position(|f| f.message == *target_title.as_ref())
330            .map(|f| f as u64)
331    }
332
333    fn search_list<S: AsRef<str>>(&self, target_title: S) -> Option<Vec<u64>> {
334        let a = self
335            .messages
336            .0
337            .iter()
338            .enumerate()
339            .filter(|f| f.1.message == *target_title.as_ref())
340            .map(|f| f.0 as u64)
341            .collect::<Vec<u64>>();
342        if a.is_empty() { Some(a) } else { None }
343    }
344
345    fn close(&mut self, is_resolved: bool) {
346        self.update();
347        if is_resolved {
348            self.status = Status::Closed(Closed::Resolved);
349        } else {
350            self.status = Status::Closed(Closed::UnResolved);
351        }
352    }
353
354    fn open(&mut self) {
355        self.update();
356        self.status = Status::Open;
357    }
358
359    fn is_opened(&self) -> bool {
360        self.status.is_opened()
361    }
362
363    fn get_message(&self) -> &Messages {
364        &self.messages
365    }
366
367    fn change_author<U: Into<User>>(&mut self, new_author: U) {
368        self.update();
369        self.author = new_author.into();
370    }
371
372    fn assign_user<U: Into<User>>(&mut self, user: U) {
373        self.update();
374        match &mut self.assigned_member {
375            Some(v) => v.add_user(user),
376            None => {
377                self.assigned_member = Some({
378                    let mut n = Users::new();
379                    n.add_user(user);
380                    n
381                })
382            }
383        }
384    }
385}
386
387impl Display for Issue {
388    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389        let issue_info = format!(
390            "issue: {}\n  status:\t{}\n  created:\t{}\n  update_at:\t{}\n  due date:\t{}",
391            self.name,
392            self.status,
393            self.created_at.to_rfc2822(),
394            self.updated_at.to_rfc2822(),
395            self.due_date
396                .map(|f| f.to_rfc2822())
397                .unwrap_or("None".to_string())
398        );
399        write!(f, "{}\n\n{}\n", issue_info, self.messages)
400    }
401}
402
403impl Issue {
404    pub fn fmt_only_open(&self) -> String {
405        if self.is_opened() {
406            let issue_info = format!(
407                "issue:\t\t{}\nstatus:\t\t{}\ncreated:\t{}\nupdate_at:\t{}\ndue date:\t{}",
408                self.name,
409                self.status,
410                self.created_at.to_rfc2822(),
411                self.updated_at.to_rfc2822(),
412                self.due_date
413                    .map(|f| f.to_rfc2822())
414                    .unwrap_or("None".to_string())
415            );
416            format!("{}\n\n{}\n", issue_info, self.messages)
417        } else {
418            String::new()
419        }
420    }
421    pub fn fmt_only_prop(&self) -> String {
422        let issue_info = format!(
423            "issue: {}\n  status:\t{}\n  created:\t{}\n  update_at:\t{}\n  due date:\t{}",
424            self.name,
425            self.status,
426            self.created_at.to_rfc2822(),
427            self.updated_at.to_rfc2822(),
428            self.due_date
429                .map(|f| f.to_rfc2822())
430                .unwrap_or("None".to_string())
431        );
432        format!("{}\n\n", issue_info)
433    }
434
435    pub fn fmt_only_open_prop(&self) -> String {
436        if self.is_opened() {
437            let issue_info = format!(
438                "issue:\t\t{}\nstatus:\t\t{}\ncreated:\t{}\nupdate_at:\t{}\ndue date:\t{}",
439                self.name,
440                self.status,
441                self.created_at.to_rfc2822(),
442                self.updated_at.to_rfc2822(),
443                self.due_date
444                    .map(|f| f.to_rfc2822())
445                    .unwrap_or("None".to_string())
446            );
447            format!("{}\n\n", issue_info)
448        } else {
449            String::new()
450        }
451    }
452}
453
454pub trait ProjectManager {
455    fn new<S: AsRef<str>, P: AsRef<Path>>(name: S, project_path: P) -> Self;
456    fn open<P: AsRef<Path>>(project_path: P) -> Result<Self, Error>
457    where
458        Self: Sized;
459    fn open_or_create<P: AsRef<Path>, S: AsRef<str>>(
460        project_path: P,
461        name: S,
462        // author: User,
463    ) -> Result<Self, Error>
464    where
465        Self: Sized;
466    fn save(&self) -> Result<(), Error>;
467    fn update(&mut self);
468}
469
470/// create a new path added `.local_issue` dir path to arg: `path`.
471pub fn storage_path<P: AsRef<Path>>(path: &P) -> PathBuf {
472    path.as_ref().join(".local_issue")
473}
474
475#[derive(Debug, Serialize, Deserialize, Clone)]
476pub struct Project {
477    project_name: String,
478    issues: HashMap<u64, Issue>,
479    current_id: u64,
480    created_at: DateTime<Local>,
481    updated_at: DateTime<Local>,
482    project_path: PathBuf,
483    storage_path: PathBuf,
484    db_path: PathBuf,
485    users: Users,
486}
487
488impl ProjectManager for Project {
489    /// * create `storage_path` or `db_path` based on `project_path`.
490    /// * and times info generated based on the current time.
491    fn new<S: AsRef<str>, P: AsRef<Path>>(name: S, project_path: P) -> Self {
492        let storage_path = storage_path(&project_path);
493        let db_path = storage_path.join(DB_NAME);
494
495        Self {
496            project_name: name.as_ref().to_string(),
497            issues: HashMap::new(),
498            current_id: 0,
499            created_at: Local::now(),
500            updated_at: Local::now(),
501            project_path: project_path.as_ref().to_path_buf(),
502            storage_path,
503            db_path,
504            users: Users::new(),
505        }
506    }
507
508    /// return loaded `Project` if file(db) is empty, error
509    fn open<P: AsRef<Path>>(project_path: P) -> Result<Self, Error>
510    where
511        Self: Sized,
512    {
513        let storage_path = storage_path(&project_path);
514        let db_path = storage_path.join(DB_NAME);
515
516        storage::load::<Project, _>(db_path, false).map_err(Error::DbError)
517    }
518
519    /// if db.json not found, create new.
520    fn open_or_create<P: AsRef<Path>, S: AsRef<str>>(
521        project_path: P,
522        name: S,
523    ) -> Result<Self, Error>
524    where
525        Self: Sized,
526    {
527        let storage_path = storage_path(&project_path);
528        let db_path = storage_path.join(DB_NAME);
529
530        storage::load::<Project, _>(db_path, true)
531            // if create new, file size is 0,
532            .or_else(|e| {
533                if e.is_file_is_zero() {
534                    Ok(Project::new(name, project_path))
535                } else {
536                    Err(e)
537                }
538            })
539            .map_err(Error::DbError)
540    }
541
542    /// save to db based on a path of Self
543    fn save(&self) -> Result<(), Error> {
544        storage::save(self, &self.db_path).map_err(Error::DbError)
545    }
546
547    fn update(&mut self) {
548        self.updated_at = Local::now();
549    }
550}
551
552/// manage users
553impl Project {
554    pub fn add_user<U: Into<User>>(&mut self, new_user: U) {
555        self.update();
556        self.users.add_user(new_user);
557    }
558    pub fn list_users(&self) -> Vec<String> {
559        self.users.users_list()
560    }
561
562    pub fn change_author_of_issue<U: Into<User>>(&mut self, new_author: U, issue_id: u64) {
563        if let Some(f) = self.issues.get_mut(&issue_id) {
564            f.change_author(new_author);
565            self.update();
566        }
567    }
568
569    pub fn assign_new_user_to_issue<U: Into<User>>(&mut self, new_user: U, issue_id: u64) {
570        if let Some(f) = self.issues.get_mut(&issue_id) {
571            f.assign_user(new_user);
572            self.update();
573        }
574    }
575}
576
577pub trait SearchIssue {
578    fn search_issue<S: AsRef<str>>(&self, target_title: S) -> Option<u64>;
579    fn search_issues<S: AsRef<str>>(&self, target_title: S) -> Option<Vec<u64>>;
580}
581
582impl SearchIssue for Project {
583    fn search_issue<S: AsRef<str>>(&self, target_title: S) -> Option<u64> {
584        self.issues
585            .iter()
586            .find(|f| f.1.name == *target_title.as_ref())
587            .map(|f| *f.0)
588    }
589
590    fn search_issues<S: AsRef<str>>(&self, target_title: S) -> Option<Vec<u64>> {
591        let a = self
592            .issues
593            .iter()
594            .filter(|f| f.1.name == *target_title.as_ref())
595            .map(|f| *f.0)
596            .collect::<Vec<u64>>();
597        if a.is_empty() { Some(a) } else { None }
598    }
599}
600
601impl Project {
602    pub fn count_issues(&self) -> i32 {
603        self.issues.iter().len() as i32
604    }
605    pub fn count_comments(&self, issue_id: u64) -> Option<i32> {
606        self.issues.get(&issue_id).map(|f| f.count_message())
607    }
608}
609
610// ctrl Project
611impl Project {
612    pub fn rename<T: AsRef<str>>(&mut self, title: T) {
613        self.update();
614        self.project_name = title.as_ref().to_string();
615    }
616}
617
618impl Project {
619    pub fn add_issue<T: AsRef<str>, U: Into<User>>(&mut self, new_name: T, author: U) {
620        self.update();
621        self.current_id += 1;
622        self.issues
623            .insert(self.current_id, Issue::new(new_name, author));
624    }
625
626    pub fn rename_issue<T: AsRef<str>>(&mut self, issue_id: u64, new_name: T) {
627        self.update();
628        if let Some(f) = self.issues.get_mut(&issue_id) {
629            f.rename(new_name);
630        }
631    }
632
633    pub fn edit_issue_due(&mut self, id: u64, due: DateTime<Local>) {
634        self.update();
635        if let Some(f) = self.issues.get_mut(&id) {
636            f.edit_due(due);
637        }
638    }
639
640    pub fn to_open_issue(&mut self, issue_id: u64) {
641        if let Some(f) = self.issues.get_mut(&issue_id) {
642            f.open();
643            self.update();
644        }
645    }
646
647    pub fn to_close_issue(&mut self, issue_id: u64, is_resolved: bool) {
648        if let Some(f) = self.issues.get_mut(&issue_id) {
649            f.close(is_resolved);
650            self.update();
651        }
652    }
653
654    pub fn is_opened_issue(&self, issue_id: u64) -> Option<bool> {
655        self.issues.get(&issue_id).map(|f| f.is_opened())
656    }
657
658    pub fn exist(&self, issue_id: u64) -> bool {
659        // `is_opened_issue()` return true when it exist.
660        self.is_opened_issue(issue_id).is_some()
661    }
662
663    pub fn remove_issue(&mut self, issue_id: u64) {
664        self.update();
665        self.issues.remove(&issue_id);
666    }
667}
668
669pub trait SearchComment {
670    fn search_comment_position<T: AsRef<str>>(&self, issue_id: u64, target_title: T)
671    -> Option<u64>;
672    fn search_comments<T: AsRef<str>>(&self, issue_id: u64, target_title: T) -> Option<&Messages>;
673    fn search_comments_positions<T: AsRef<str>>(
674        &self,
675        issue_id: u64,
676        target_title: T,
677    ) -> Option<Vec<u64>>;
678}
679
680impl SearchComment for Project {
681    fn search_comment_position<T: AsRef<str>>(
682        &self,
683        issue_id: u64,
684        target_title: T,
685    ) -> Option<u64> {
686        self.issues
687            .get(&issue_id)
688            .and_then(|f| f.search(target_title))
689    }
690
691    fn search_comments<T: AsRef<str>>(&self, issue_id: u64, target_title: T) -> Option<&Messages> {
692        self.search_comment_position(issue_id, target_title)
693            .and_then(|f| self.issues.get(&f).map(|f| f.get_message()))
694    }
695
696    fn search_comments_positions<T: AsRef<str>>(
697        &self,
698        issue_id: u64,
699        target_title: T,
700    ) -> Option<Vec<u64>> {
701        self.issues
702            .get(&issue_id)
703            .and_then(|f| f.search_list(target_title))
704    }
705}
706
707/// edit comment msg
708impl Project {
709    pub fn add_comment<T: AsRef<str>, U: Into<User>>(
710        &mut self,
711        issue_id: u64,
712        comment: T,
713        author: U,
714    ) {
715        if let Some(f) = self.issues.get_mut(&issue_id) {
716            f.comment(comment, author);
717            self.update();
718        }
719    }
720
721    /// ⚠️ this fn remove message in Vec and rewrite index.
722    /// recommend hide_message().
723    pub fn rm_comment(&mut self, issue_id: u64, comment_id: u64) {
724        if let Some(f) = self.issues.get_mut(&issue_id) {
725            f.rm_comment(comment_id);
726            self.update();
727        }
728    }
729
730    pub fn set_comment_as_visible(&mut self, comment_id: u64, issue_id: u64) {
731        if let Some(f) = self.issues.get_mut(&issue_id) {
732            f.show_message(comment_id);
733            self.update();
734        }
735    }
736
737    pub fn set_comment_as_hidden(&mut self, comment_id: u64, issue_id: u64) {
738        if let Some(f) = self.issues.get_mut(&issue_id) {
739            f.hide_message(comment_id);
740            self.update();
741        }
742    }
743}
744
745impl Display for Project {
746    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
747        let sub_prop = format!(
748            "  created at: {}\n  updated at: {}\n  Path: {}",
749            self.created_at.to_rfc2822(),
750            self.updated_at.to_rfc2822(),
751            self.project_path.to_string_lossy(),
752        );
753        let iss = self
754            .issues
755            .iter()
756            .map(|f| (f.0, f.1))
757            .collect::<Vec<(&u64, &Issue)>>()
758            .iter()
759            .map(|f| format!("#{}: {}", f.0, f.1))
760            .collect::<String>();
761
762        let r = format!("Project: {}\n{}\n\n{}", self.project_name, sub_prop, iss);
763        write!(f, "{}", r)
764    }
765}
766
767impl Project {
768    pub fn fmt_only_prop(&self) -> String {
769        self.issues.iter().map(|f| f.1.fmt_only_prop()).collect()
770    }
771    pub fn fmt_only_open(&self) -> String {
772        self.issues.iter().map(|f| f.1.fmt_only_open()).collect()
773    }
774    pub fn fmt_only_open_prop(&self) -> String {
775        self.issues
776            .iter()
777            .map(|f| f.1.fmt_only_open_prop())
778            .collect()
779    }
780}
781
782pub fn db_path(work_path: PathBuf) -> PathBuf {
783    storage_path(&work_path).join(DB_NAME)
784    // work_path.join(".local_issue").join(DB_NAME)
785}