vkdocs_rs/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5// See https://github.com/vk-cs/docs-public/blob/master/guides/how-it-works.md.
6
7#[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}