1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5#[derive(Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9struct Meta {
10 title: String,
11 meta_title: String,
12 section_title: String,
13 short_description: String,
14 page_description: String,
15 meta_description: String,
16 weight: i32,
17 uuid: String,
18}
19
20impl Meta {
21 fn apply(self, page: Page) -> (Self, bool) {
22 let mut updated = false;
23
24 let title = page
25 .title
26 .inspect(|title| updated |= self.title.ne(title))
27 .unwrap_or(self.title);
28 let meta_title = page
29 .meta_title
30 .inspect(|meta_title| updated |= self.meta_title.ne(meta_title))
31 .unwrap_or(self.meta_title);
32 let section_title = page
33 .section_title
34 .inspect(|section_title| updated |= self.section_title.ne(section_title))
35 .unwrap_or(self.section_title);
36 let short_description = page
37 .short_description
38 .inspect(|short_description| updated |= self.short_description.ne(short_description))
39 .unwrap_or(self.short_description);
40 let page_description = page
41 .page_description
42 .inspect(|page_description| updated |= self.page_description.ne(page_description))
43 .unwrap_or(self.page_description);
44 let meta_description = page
45 .meta_description
46 .inspect(|meta_description| updated |= self.meta_description.ne(meta_description))
47 .unwrap_or(self.meta_description);
48 let weight = page
49 .weight
50 .inspect(|weight| updated |= self.weight.ne(weight))
51 .unwrap_or(self.weight);
52
53 let uuid = self.uuid;
54
55 let meta = Meta {
56 title,
57 meta_title,
58 section_title,
59 short_description,
60 page_description,
61 meta_description,
62 weight,
63 uuid,
64 };
65
66 (meta, updated)
67 }
68}
69
70#[derive(Default)]
71pub struct Vkdoc {
72 path: PathBuf,
73}
74
75pub struct Page {
76 title: Option<String>,
77 meta_title: Option<String>,
78 meta_description: Option<String>,
79 short_description: Option<String>,
80 page_description: Option<String>,
81 section_title: Option<String>,
82 content: Option<String>,
83 weight: Option<i32>,
84}
85
86const DEFAULT_WEIGHT: i32 = 1;
87
88impl Page {
89 pub fn new() -> Page {
90 Page {
91 title: None,
92 meta_title: None,
93 section_title: None,
94 meta_description: None,
95 short_description: None,
96 page_description: None,
97 content: None,
98 weight: None,
99 }
100 }
101
102 pub fn with_title(self, title: String) -> Page {
103 Page {
104 title: Some(title),
105 ..self
106 }
107 }
108
109 pub fn with_meta_title(self, meta_title: String) -> Page {
110 Page {
111 meta_title: Some(meta_title),
112 ..self
113 }
114 }
115
116 pub fn with_section_title(self, section_title: String) -> Page {
117 Page {
118 section_title: Some(section_title),
119 ..self
120 }
121 }
122
123 pub fn with_short_description(self, short_description: String) -> Page {
124 Page {
125 short_description: Some(short_description),
126 ..self
127 }
128 }
129
130 pub fn with_page_description(self, page_description: String) -> Page {
131 Page {
132 page_description: Some(page_description),
133 ..self
134 }
135 }
136
137 pub fn with_content(self, content: String) -> Page {
138 Page {
139 content: Some(content),
140 ..self
141 }
142 }
143
144 pub fn with_weight(self, weight: i32) -> Page {
145 Page {
146 weight: Some(weight),
147 ..self
148 }
149 }
150}
151
152impl TryInto<Meta> for Page {
153 type Error = String;
154
155 fn try_into(self) -> Result<Meta, Self::Error> {
156 let title = self
157 .title
158 .ok_or("A title is required for a newly created page")?;
159 let meta_title = self.meta_title.unwrap_or(title.clone());
160 let section_title = self.section_title.unwrap_or(title.clone());
161
162 let short_description = self.short_description.unwrap_or("".to_string());
163 let page_description = self.page_description.unwrap_or(short_description.clone());
164 let meta_description = self.meta_description.unwrap_or(short_description.clone());
165
166 let weight = self.weight.unwrap_or(DEFAULT_WEIGHT);
167 let uuid = uuid::Uuid::new_v4().to_string();
168
169 Ok(Meta {
170 title,
171 meta_title,
172 section_title,
173 short_description,
174 page_description,
175 meta_description,
176 weight,
177 uuid,
178 })
179 }
180}
181
182impl Vkdoc {
183 pub fn new(path: &Path) -> Result<Vkdoc, String> {
184 fs::create_dir_all(path).map_err(|err| {
185 format!(
186 "Unable to initialize a vkdoc project at the specified dir {}: {}",
187 path.display(),
188 err
189 )
190 })?;
191
192 Ok(Vkdoc {
193 path: path.to_path_buf(),
194 })
195 }
196
197 fn upsert_sections(&self, path: Option<&Path>) -> Result<(), String> {
198 match path {
199 Some(path) => match path.file_name() {
200 Some(name) => {
201 let name = name
202 .to_os_string()
203 .into_string()
204 .map_err(|_| "Unable to convert filename to string".to_string())?;
205 let meta_path = self.path.join(path).join(format!("{}.meta.json", name));
206 if !meta_path.exists() {
207 let meta: Meta = Page::new().with_title(name).try_into()?;
208 let meta_content = serde_json::to_string_pretty(&meta).map_err(|err| {
209 format!("Unable to serialize entry meta json: {}", err)
210 })?;
211 fs::write(meta_path, meta_content).map_err(|err| {
212 format!("Unable to write a vkdoc meta at the specified dir: {}", err)
213 })?;
214 }
215 self.upsert_sections(path.parent())
216 }
217 None => Ok(()),
218 },
219 None => Ok(()),
220 }
221 }
222
223 pub fn upsert(&self, path: &Path, page: Page) -> Result<bool, String> {
224 if !path.is_relative() {
225 return Err("Only relative paths are supported".to_string());
226 }
227
228 let full_path = self.path.join(path);
229 fs::create_dir_all(&full_path)
230 .map_err(|err| format!("Unable to create an entry at the specified dir: {}", err))?;
231
232 self.upsert_sections(path.parent())?;
233
234 let name = full_path
235 .file_name()
236 .ok_or("Unable to load an entry at the specified dir")?
237 .to_os_string()
238 .into_string()
239 .map_err(|_| "Unable to load an entry at the specified dir")?;
240 let doc_path = full_path.join(format!("{}.md", name));
241
242 let content_updated = if doc_path.exists() {
243 let old_content = fs::read_to_string(&doc_path).map_err(|err| {
244 format!(
245 "Unable to read entry content at {}: {}",
246 doc_path.display(),
247 err
248 )
249 })?;
250 page.content.as_ref().is_none_or(|content| {
251 let old_checksum = md5::compute(&old_content);
252 let checksum = md5::compute(&content);
253
254 old_checksum != checksum
255 })
256 } else {
257 page.content.is_some()
258 };
259
260 if content_updated {
261 page.content.as_ref().map_or_else(
262 || Ok::<(), String>(()),
263 |content| {
264 fs::write(doc_path, content).map_err(|err| {
265 format!(
266 "Unable to write a vkdoc entry at the specified dir: {}",
267 err
268 )
269 })?;
270 Ok(())
271 },
272 )?;
273 }
274
275 let meta_path = full_path.join(format!("{}.meta.json", name));
276 let (meta, meta_updated) = if meta_path.exists() {
277 let meta_content = &fs::read_to_string(&meta_path).map_err(|err| {
278 format!(
279 "Unable to read entry meta at {}: {}",
280 full_path.display(),
281 err
282 )
283 })?;
284 let meta: Meta = serde_json::from_str(meta_content).map_err(|err| {
285 format!(
286 "Unable to parse entry meta json at {}: {}",
287 full_path.display(),
288 err
289 )
290 })?;
291 let (meta, updated) = meta.apply(page);
292 (meta, updated)
293 } else {
294 (page.try_into()?, true)
295 };
296
297 let updated = meta_updated || content_updated;
298
299 if updated {
300 let meta_content = serde_json::to_string(&meta)
301 .map_err(|err| format!("Unable to serialize entry meta json: {}", err))?;
302 fs::write(meta_path, meta_content).map_err(|err| {
303 format!("Unable to write a vkdoc meta at the specified dir: {}", err)
304 })?;
305 }
306
307 Ok(updated)
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use tempdir::TempDir;
315
316 #[test]
317 fn basic_upsert_content() {
318 let tempdir = TempDir::new("vkdoc").unwrap();
319 let root_path = tempdir.path();
320 let entry_path = Path::new("test");
321 let full_entry_path = root_path.join(entry_path);
322 let content = "content".to_string();
323 let page = Page::new()
324 .with_title("test".to_string())
325 .with_content(content.clone());
326
327 let vkdoc = Vkdoc::new(root_path).unwrap();
328 assert!(root_path.exists() && root_path.is_dir());
329
330 assert_eq!(vkdoc.upsert(&entry_path, page).unwrap(), true);
331
332 let content_path = full_entry_path.join("test.md");
333 let real_content = fs::read_to_string(&content_path).unwrap();
334 assert_eq!(real_content, content);
335
336 let meta_path = full_entry_path.join("test.meta.json");
337 let real_meta: Meta =
338 serde_json::from_str(fs::read_to_string(&meta_path).unwrap().as_str()).unwrap();
339 assert_eq!(real_meta.title, "test");
340 assert_eq!(real_meta.meta_title, "test");
341 assert_eq!(real_meta.section_title, "test");
342 assert_eq!(real_meta.short_description, "");
343 assert_eq!(real_meta.page_description, "");
344
345 assert_eq!(
346 vkdoc
347 .upsert(&entry_path, Page::new().with_content(content.clone()))
348 .unwrap(),
349 false
350 );
351 let real_content = fs::read_to_string(&content_path).unwrap();
352 assert_eq!(real_content, content);
353
354 let content = "new content".to_string();
355 assert_eq!(
356 vkdoc
357 .upsert(&entry_path, Page::new().with_content(content.clone()))
358 .unwrap(),
359 true
360 );
361
362 let real_content = fs::read_to_string(&content_path).unwrap();
363 assert_eq!(real_content, content);
364 }
365
366 #[test]
367 fn basic_upsert_meta() {
368 let tempdir = TempDir::new("vkdoc").unwrap();
369 let root_path = tempdir.path();
370 let entry_path = Path::new("test");
371 let full_entry_path = root_path.join(entry_path);
372 let page = Page::new().with_title("test".to_string());
373
374 let vkdoc = Vkdoc::new(&root_path).unwrap();
375
376 assert_eq!(vkdoc.upsert(&entry_path, page).unwrap(), true);
377
378 let meta_path = full_entry_path.join("test.meta.json");
379 let real_meta: Meta =
380 serde_json::from_str(fs::read_to_string(&meta_path).unwrap().as_str()).unwrap();
381 assert_eq!(real_meta.title, "test");
382
383 let page = Page::new().with_title("test".to_string());
384 assert_eq!(vkdoc.upsert(&entry_path, page).unwrap(), false);
385
386 let page = Page::new().with_title("test1".to_string());
387 assert_eq!(vkdoc.upsert(&entry_path, page).unwrap(), true);
388
389 let real_meta: Meta =
390 serde_json::from_str(fs::read_to_string(&meta_path).unwrap().as_str()).unwrap();
391 assert_eq!(real_meta.title, "test1");
392 }
393
394 #[test]
395 fn basic_upsert_with_sections() {
396 let tempdir = TempDir::new("vkdoc").unwrap();
397 let root_path = tempdir.path();
398 let entry_path = Path::new("section/test");
399 let full_entry_path = root_path.join(entry_path);
400 let section_path = full_entry_path.parent().unwrap();
401 let page = Page::new().with_title("test".to_string());
402
403 let vkdoc = Vkdoc::new(&root_path).unwrap();
404
405 assert_eq!(vkdoc.upsert(&entry_path, page).unwrap(), true);
406
407 let meta_path = full_entry_path.join("test.meta.json");
408 assert!(meta_path.exists());
409 let section_meta_path = section_path.join("section.meta.json");
410 assert!(section_meta_path.exists())
411 }
412}