notes/
note.rs

1extern crate regex;
2
3use std::cmp::min;
4use std::fs;
5use std::path::PathBuf;
6
7use lazy_static::lazy_static;
8use regex::{Regex, RegexBuilder};
9
10use crate::default_error::DefaultError;
11use crate::search_match::{MatchedLine, SearchMatch};
12
13lazy_static! {
14    static ref HAS_CONTENT: Regex = RegexBuilder::new("\\w").case_insensitive(true).build().unwrap();
15}
16
17#[derive(Debug, Eq, PartialEq, Clone)]
18pub struct Note {
19    pub id: usize,
20    pub path: PathBuf,
21    pub title: String,
22    /// Contains only non empty lines of note, without title
23    pub body: Vec<String>,
24    /// Contains all note lines
25    pub raw: Vec<String>,
26}
27
28impl Note {
29    pub fn from(id: usize, path: PathBuf, raw_content: String) -> Result<Note, DefaultError> {
30        let all_lines: Vec<String> = raw_content.split('\n').map(String::from).collect();
31        let non_empty_lines: Vec<String> = all_lines.iter().filter(|l| !l.is_empty()).map(String::from).collect();
32
33        if non_empty_lines.is_empty() {
34            return Err(DefaultError {
35                message: "Not enough lines".to_string(),
36                backtrace: None,
37            });
38        }
39
40        let title = non_empty_lines.get(0).unwrap().to_string();
41        let body = non_empty_lines.into_iter().skip(1).collect();
42
43        Ok(Note {
44            id,
45            path,
46            title,
47            body,
48            raw: all_lines,
49        })
50    }
51
52    pub fn from_file(id: usize, path: PathBuf) -> Result<Note, DefaultError> {
53        let content = fs::read_to_string(&path)?;
54        Note::from(id, path, content)
55    }
56
57    pub fn search_match(&self, needle_regex: &Regex) -> SearchMatch {
58        let score = self.match_score(needle_regex);
59        let title_position = self.raw.iter().position(|l| &self.title == l).unwrap();
60
61        let mut matching_lines: Vec<MatchedLine> = self
62            .raw
63            .iter()
64            .enumerate()
65            .skip(title_position + 1)
66            .filter_map(|(idx, line)| match needle_regex.captures(line) {
67                Some(captures) => {
68                    let matched = String::from(captures.get(1).map_or("", |m| m.as_str()));
69                    let previous: Option<String> = if idx > 1 {
70                        self.raw.get(idx - 1).filter(|s| HAS_CONTENT.is_match(s)).map(String::from)
71                    } else {
72                        None
73                    };
74                    let next: Option<String> = self.raw.get(idx + 1).filter(|s| HAS_CONTENT.is_match(s)).map(String::from);
75                    Some(MatchedLine {
76                        display_number: idx + 1,
77                        line_number: idx,
78                        content: String::from(line),
79                        matched,
80                        previous,
81                        next,
82                    })
83                }
84                None => None,
85            })
86            .collect();
87
88        // Title can match without match in content. In this case we return the first lines of note.
89        if score > 0 && matching_lines.is_empty() {
90            let show_lines = 6;
91            let first_lines = min(title_position + show_lines, self.raw.len());
92
93            matching_lines = self
94                .raw
95                .iter()
96                .enumerate()
97                .skip(title_position + 1)
98                .take(first_lines)
99                .filter(|(_, line)| HAS_CONTENT.is_match(line))
100                .map(|(idx, line)| MatchedLine {
101                    display_number: idx + 1,
102                    line_number: idx,
103                    content: String::from(line),
104                    matched: "".to_string(),
105                    previous: None,
106                    next: None,
107                })
108                .collect();
109        }
110
111        SearchMatch {
112            id: self.id,
113            score,
114            path: self.path.clone(),
115            title: self.title.clone(),
116            matched_lines: matching_lines,
117        }
118    }
119
120    fn match_score(&self, needle_regex: &Regex) -> usize {
121        let match_in_title = if needle_regex.is_match(&self.title) { 4 } else { 0 };
122        let match_in_body: usize = self.body.iter().map(|line| if needle_regex.is_match(line) { 1 } else { 0 }).sum();
123        match_in_title + match_in_body
124    }
125
126    pub fn content(&self) -> String {
127        self.raw.join("\n")
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use std::path::PathBuf;
134
135    use regex::{Regex, RegexBuilder};
136
137    use super::*;
138
139    const SAMPLE_NOTE_1: &str = "\
140
141# SSH
142
143A note about SSH
144
145";
146
147    const SAMPLE_NOTE_2: &str = "\
148
149# Rsync
150
151A very interesting note
152About Rsync
153With very interesting things inside
154
155";
156
157    const SAMPLE_NOTE_3: &str = "\
158
159# What a note !
160
161A very interesting one
162With very interesting things inside
163
164";
165
166    pub fn needle_regexp(needle: &str) -> Regex {
167        RegexBuilder::new(&format!("({})", needle)).case_insensitive(true).build().unwrap()
168    }
169
170    #[test]
171    pub fn from() {
172        let note = Note::from(0, "/tmp/note-1.txt".into(), SAMPLE_NOTE_1.to_string()).unwrap();
173        assert_eq!(note.id, 0);
174        assert_eq!(note.title, "# SSH");
175        assert_eq!(note.body.len(), 1);
176        assert_eq!(note.body[0], "A note about SSH");
177        assert_eq!(note.path, PathBuf::from("/tmp/note-1.txt"));
178    }
179
180    #[test]
181    pub fn from_title_only() {
182        let note = Note::from(0, "/tmp/note-1.txt".into(), "# Title only".to_string()).unwrap();
183        assert_eq!(note.id, 0);
184        assert_eq!(note.title, "# Title only");
185        assert_eq!(note.body.len(), 0);
186        assert_eq!(note.path, PathBuf::from("/tmp/note-1.txt"));
187    }
188
189    #[test]
190    pub fn match_score() {
191        let note = Note::from(0, "/tmp/note-1.txt".into(), SAMPLE_NOTE_1.to_string()).unwrap();
192        let needle_regex = needle_regexp("ssh");
193        assert_eq!(note.match_score(&needle_regex), 5);
194    }
195
196    #[test]
197    pub fn match_score_should_score_0() {
198        let note = Note::from(0, "/tmp/note-1.txt".into(), SAMPLE_NOTE_1.to_string()).unwrap();
199        let needle_regex = needle_regexp("something-else");
200        assert_eq!(note.match_score(&needle_regex), 0);
201    }
202
203    #[test]
204    pub fn search_match() {
205        let note = Note::from(0, "/tmp/note-1.txt".into(), SAMPLE_NOTE_2.to_string()).unwrap();
206        let needle_regex = needle_regexp("rsync");
207        let actual = note.search_match(&needle_regex);
208        let expected = SearchMatch {
209            id: 0,
210            score: 5,
211            path: "/tmp/note-1.txt".into(),
212            title: "# Rsync".into(),
213            matched_lines: vec![MatchedLine {
214                display_number: 4,
215                line_number: 3,
216                content: "About Rsync".into(),
217                matched: "Rsync".into(),
218                previous: Some("A very interesting note".into()),
219                next: Some("With very interesting things inside".into()),
220            }],
221        };
222        assert_eq!(actual, expected);
223    }
224
225    #[test]
226    pub fn search_match_only_title() {
227        let note = Note::from(0, "/tmp/note-1.txt".into(), SAMPLE_NOTE_3.to_string()).unwrap();
228        let needle_regex = needle_regexp("note");
229        let actual = note.search_match(&needle_regex);
230        let expected = SearchMatch {
231            id: 0,
232            score: 4,
233            path: "/tmp/note-1.txt".into(),
234            title: "# What a note !".into(),
235            matched_lines: vec![
236                MatchedLine {
237                    display_number: 3,
238                    line_number: 2,
239                    content: "A very interesting one".into(),
240                    matched: "".to_string(),
241                    previous: None,
242                    next: None,
243                },
244                MatchedLine {
245                    display_number: 4,
246                    line_number: 3,
247                    content: "With very interesting things inside".into(),
248                    matched: "".to_string(),
249                    previous: None,
250                    next: None,
251                },
252            ],
253        };
254        assert_eq!(actual, expected);
255    }
256}