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 pub body: Vec<String>,
24 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 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}