brevdash_data/
lib.rs

1#![deny(
2    missing_docs,
3    missing_debug_implementations,
4    missing_copy_implementations,
5    trivial_casts,
6    trivial_numeric_casts,
7    unsafe_code,
8    unstable_features,
9    unused_import_braces,
10    unused_qualifications
11)]
12
13//! Functionality for creating, reading and manipulating a brevdash repository.
14
15use anyhow::{bail, Context, Result};
16use chrono::naive::NaiveDate;
17use glob::glob;
18use indexmap::IndexMap;
19use log::warn;
20use serde::{Deserialize, Serialize};
21use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23use std::str::FromStr;
24
25/// Definition of a brevdash data repository.
26#[derive(Debug)]
27pub struct Repository {
28    path: PathBuf,
29    /// The root description string.
30    pub description: RootDescription,
31}
32
33impl Repository {
34    /// Open a repository from a directory path.
35    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
36        Ok(Repository {
37            path: path.as_ref().to_path_buf(),
38            description: RootDescription::load_from_file(
39                Self::initial_brevdash_toml_path(path),
40            )?,
41        })
42    }
43
44    /// Create a repository at a directory path.
45    ///
46    /// The directory must already exist and be writable.
47    pub fn create<P: AsRef<Path>>(
48        path: P,
49        description: RootDescription,
50    ) -> Result<Self> {
51        let r = Repository {
52            path: path.as_ref().to_path_buf(),
53            description,
54        };
55        r.store_description()?;
56        Ok(r)
57    }
58
59    /// Store the description file inside the repository.
60    pub fn store_description(&self) -> Result<()> {
61        self.description.store_to_file(self.brevdash_toml_path())
62    }
63
64    fn initial_brevdash_toml_path<P: AsRef<Path>>(p: P) -> PathBuf {
65        p.as_ref().join("brevdash.toml")
66    }
67
68    fn project_dir_path(&self, project_id: &str) -> PathBuf {
69        self.path.join(project_id)
70    }
71
72    fn project_toml_file_path(&self, project_id: &str) -> PathBuf {
73        self.project_dir_path(project_id).join("project.toml")
74    }
75
76    fn project_datapoint_directory_path(
77        &self,
78        project_id: &str,
79        date: NaiveDate,
80    ) -> PathBuf {
81        self.project_dir_path(project_id)
82            .join(date.format("%Y-%m-%d").to_string())
83    }
84
85    fn project_datapoint_toml_file_path(
86        &self,
87        project_id: &str,
88        date: NaiveDate,
89    ) -> PathBuf {
90        self.project_datapoint_directory_path(project_id, date)
91            .join("datapoint.toml")
92    }
93
94    /// Get the path for the artifacts of a project at a specific date.
95    pub fn project_datapoint_artifacts_directory_path(
96        &self,
97        project_id: &str,
98        date: NaiveDate,
99    ) -> PathBuf {
100        self.project_datapoint_directory_path(project_id, date)
101            .join("artifacts")
102    }
103
104    /// Get the path for the artifacts of a characteristic at a specific date.
105    pub fn project_datapoint_characteristic_artifacts_directory_path(
106        &self,
107        project_id: &str,
108        date: NaiveDate,
109        characteristic_id: &str,
110    ) -> PathBuf {
111        self.project_datapoint_artifacts_directory_path(project_id, date)
112            .join(characteristic_id)
113    }
114
115    /// Get the path for an artifact.
116    pub fn project_datapoint_characteristic_artifact_path(
117        &self,
118        project_id: &str,
119        date: NaiveDate,
120        characteristic_id: &str,
121        artifact_relative_path: &Path,
122    ) -> PathBuf {
123        self.project_datapoint_characteristic_artifacts_directory_path(
124            project_id,
125            date,
126            characteristic_id,
127        )
128        .join(artifact_relative_path)
129    }
130
131    fn brevdash_toml_path(&self) -> PathBuf {
132        self.path.join("brevdash.toml")
133    }
134
135    fn extract_project_id(
136        project_toml_file_path: &Path,
137    ) -> Result<String> {
138        let project_path =
139            project_toml_file_path.parent().with_context(|| {
140                format!(
141                    "Couldn't extract parent directory of {:?}",
142                    project_toml_file_path
143                )
144            })?;
145        let project_path_name_raw =
146            project_path.file_name().with_context(|| {
147                format!(
148                    "Couldn't extract directory name of {:?}",
149                    project_path
150                )
151            })?;
152        Ok(project_path_name_raw
153            .to_str()
154            .with_context(|| {
155                format!(
156                    "Couldn't get project directory name, \
157                    possibly invalid UTF-8: {:?}",
158                    project_path_name_raw
159                )
160            })?
161            .to_string())
162    }
163
164    /// Load the list of project ids inside the repository.
165    pub fn load_project_ids(&self) -> Result<Vec<String>> {
166        let pattern = format!("{}/*/project.toml", self.path.as_str()?);
167        let mut ids = Vec::new();
168        for entry in glob(&pattern).with_context(|| {
169            format!("Failed to read glob pattern {:?}", pattern)
170        })? {
171            match entry {
172                Ok(path) => match Self::extract_project_id(&path) {
173                    Ok(id) => ids.push(id),
174                    Err(e) => warn!("{:?}", e),
175                },
176                Err(e) => warn!("{:?}", e),
177            }
178        }
179        Ok(ids)
180    }
181
182    /// Query whether the repository contains a project with a specific id.
183    pub fn has_project(&self, project_id: &str) -> bool {
184        self.project_toml_file_path(project_id).exists()
185    }
186
187    /// Store the description of a project.
188    pub fn store_project_description(
189        &self,
190        project_id: &str,
191        description: &ProjectDescription,
192    ) -> Result<()> {
193        let project_dir_path = self.project_dir_path(&project_id);
194        std::fs::create_dir_all(&project_dir_path).with_context(|| {
195            format!(
196                "Couldn't create project directory {:?}",
197                project_dir_path
198            )
199        })?;
200        description.store_to_file(self.project_toml_file_path(project_id))
201    }
202
203    /// Load the description of a project by id.
204    pub fn load_project_description(
205        &self,
206        project_id: &str,
207    ) -> Result<ProjectDescription> {
208        ProjectDescription::load_from_file(
209            &self.project_toml_file_path(project_id),
210        )
211    }
212
213    /// Load the descriptions for all projects.
214    pub fn load_project_descriptions(
215        &self,
216    ) -> Result<BTreeMap<String, ProjectDescription>> {
217        let mut descriptions = BTreeMap::new();
218        for project_id in self.load_project_ids()?.into_iter() {
219            let description =
220                self.load_project_description(&project_id)?;
221            descriptions.insert(project_id, description);
222        }
223        Ok(descriptions)
224    }
225
226    /// Query whether the repository contains a datapoint at a specific date.
227    pub fn project_has_datapoint_date(
228        &self,
229        project_id: &str,
230        date: NaiveDate,
231    ) -> bool {
232        self.project_datapoint_toml_file_path(project_id, date)
233            .exists()
234    }
235
236    /// Load a list of all datapoint dates for a specific project id.
237    pub fn load_project_datapoint_dates(
238        &self,
239        project_id: &str,
240    ) -> Result<Vec<NaiveDate>> {
241        let pattern = format!(
242            "{}/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/datapoint.toml",
243            self.project_dir_path(project_id).as_str()?
244        );
245
246        let mut dates = Vec::new();
247
248        for entry in glob(&pattern).with_context(|| {
249            format!("Failed to read glob pattern {:?}", pattern)
250        })? {
251            match entry {
252                Ok(path) => {
253                    let full_date_path =
254                        path.parent().with_context(|| {
255                            format!(
256                                "Couldn't get parent of file {:?}",
257                                path
258                            )
259                        })?;
260                    let date_raw =
261                        full_date_path.file_name().with_context(|| {
262                            format!(
263                                "Couldn't get file name of {:?}",
264                                full_date_path
265                            )
266                        })?;
267
268                    let date_str = date_raw
269                        .to_str()
270                        .with_context(|| {
271                            format!(
272                                "Couldn't get file path string, \
273                                 possibly invalid UTF-8: {:?}",
274                                path
275                            )
276                        })?
277                        .to_string();
278                    let date =
279                        NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")?;
280                    dates.push(date);
281                }
282                Err(e) => {
283                    warn!("{:?}", e);
284                }
285            }
286        }
287        Ok(dates)
288    }
289
290    /// Store a datapoint at a specific date.
291    pub fn store_project_datapoint(
292        &self,
293        project_id: &str,
294        date: NaiveDate,
295        datapoint: &DataPoint,
296    ) -> Result<()> {
297        let project_toml_file_path =
298            self.project_toml_file_path(project_id);
299        if !project_toml_file_path.exists() {
300            bail!(
301                "Attempting to store datapoint for project \
302                {:?}, but no project.toml file is present",
303                project_id
304            );
305        }
306        let project_datapoint_directory_path =
307            self.project_datapoint_directory_path(project_id, date);
308        std::fs::create_dir_all(&project_datapoint_directory_path)
309            .with_context(|| {
310                format!(
311                    "Couldn't create datapoint directory {:?}",
312                    project_datapoint_directory_path
313                )
314            })?;
315        datapoint.store_to_file(
316            self.project_datapoint_toml_file_path(project_id, date),
317        )
318    }
319
320    /// Load a datapoint at a specific date.
321    pub fn load_project_datapoint(
322        &self,
323        project_id: &str,
324        date: NaiveDate,
325    ) -> Result<DataPoint> {
326        DataPoint::load_from_file(
327            &self.project_datapoint_toml_file_path(project_id, date),
328        )
329    }
330
331    /// Load all datapoints for a project id.
332    pub fn load_project_datapoints(
333        &self,
334        project_id: &str,
335    ) -> Result<BTreeMap<NaiveDate, DataPoint>> {
336        let mut datapoints = BTreeMap::new();
337        for date in
338            self.load_project_datapoint_dates(project_id)?.into_iter()
339        {
340            let datapoint =
341                self.load_project_datapoint(project_id, date)?;
342            datapoints.insert(date, datapoint);
343        }
344        Ok(datapoints)
345    }
346}
347
348trait LoadFromFile: Sized {
349    fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self>;
350}
351
352impl<T: serde::de::DeserializeOwned> LoadFromFile for T {
353    fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
354        let path = path.as_ref();
355        let s = std::fs::read_to_string(path).with_context(|| {
356            format!("Couldn't open file {:?}", path.to_path_buf())
357        })?;
358
359        let t: T = toml::from_str(&s).with_context(|| {
360            format!("Couldn't read file: {:?}", path.to_path_buf())
361        })?;
362        Ok(t)
363    }
364}
365
366trait StoreToFile {
367    fn store_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()>;
368}
369
370impl<T: Serialize> StoreToFile for T {
371    fn store_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
372        let path = path.as_ref();
373        let s = toml::to_string(&self).with_context(|| {
374            format!(
375                "Couldn't serialize data for file {:?}",
376                path.to_path_buf()
377            )
378        })?;
379        std::fs::write(&path, s).with_context(|| {
380            format!("Couldn't write file {:?}", path.to_path_buf())
381        })?;
382        Ok(())
383    }
384}
385
386trait PathAsStr {
387    fn as_str(&self) -> Result<&str>;
388}
389
390impl<P: AsRef<Path>> PathAsStr for P {
391    fn as_str(&self) -> Result<&str> {
392        let p = self.as_ref();
393        p.to_str().with_context(|| {
394            format!("Couldn't get path, possibly invalid UTF-8: {:?}", p)
395        })
396    }
397}
398
399/// A single datapoint containing multiple entries.
400pub type DataPoint = BTreeMap<String, DataEntry>;
401
402/// A data entry.
403#[derive(Clone, Debug, Serialize, Deserialize)]
404pub struct DataEntry {
405    /// The value of the entry.
406    pub value: DataValue,
407
408    /// The list of artifacts available for the data entry.
409    #[serde(
410        default = "Default::default",
411        skip_serializing_if = "Vec::is_empty"
412    )]
413    pub artifacts: Vec<PathBuf>,
414}
415
416/// The value of a data entry.
417#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
418#[serde(untagged)]
419pub enum DataValue {
420    /// A boolean value.
421    Boolean(bool),
422    /// An integer value.
423    Integer(i64),
424}
425
426impl FromStr for DataValue {
427    type Err = anyhow::Error;
428
429    fn from_str(s: &str) -> Result<Self, Self::Err> {
430        if let Ok(v) = s.parse::<bool>() {
431            Ok(DataValue::Boolean(v))
432        } else if let Ok(v) = s.parse::<i64>() {
433            Ok(DataValue::Integer(v))
434        } else {
435            bail!("Couldn't parse argument {:?}", s);
436        }
437    }
438}
439
440impl DataValue {
441    /// Unwrap the contained value if it is a DataValue::Boolean.
442    /// If the value is not a DataValue::Boolean, this method panics.
443    pub fn unwrap_boolean(&self) -> bool {
444        self.boolean().unwrap()
445    }
446
447    /// Unwrap the contained value if it is a DataValue::Integer.
448    /// If the value is not a DataValue::Integer, this method panics.
449    pub fn unwrap_integer(&self) -> i64 {
450        self.integer().unwrap()
451    }
452
453    /// Get the contained value if it is a DataValue::Boolean.
454    /// If the value is not a DataValue::Boolean, None is returned.
455    pub fn boolean(&self) -> Option<bool> {
456        if let DataValue::Boolean(v) = *self {
457            Some(v)
458        } else {
459            None
460        }
461    }
462
463    /// Get the contained value if it is a DataValue::Integer.
464    /// If the value is not a DataValue::Integer, None is returned.
465    pub fn integer(&self) -> Option<i64> {
466        if let DataValue::Integer(v) = *self {
467            Some(v)
468        } else {
469            None
470        }
471    }
472
473    /// Get the type of the data value.
474    pub fn datatype(&self) -> DataType {
475        match self {
476            DataValue::Integer(_) => DataType::Integer,
477            DataValue::Boolean(_) => DataType::Boolean,
478        }
479    }
480}
481
482/// Possible types for data entries.
483#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
484#[serde(rename_all = "lowercase")]
485pub enum DataType {
486    /// A boolean entry type.
487    Boolean,
488
489    /// A integer entry type.
490    Integer,
491}
492
493impl FromStr for DataType {
494    type Err = anyhow::Error;
495
496    fn from_str(s: &str) -> Result<Self, Self::Err> {
497        match s {
498            "boolean" => Ok(DataType::Boolean),
499            "integer" => Ok(DataType::Integer),
500            s => bail!("Couldn't parse argument {:?}", s),
501        }
502    }
503}
504
505/// The description for a characteristic.
506#[derive(Clone, Debug, Serialize, Deserialize)]
507pub struct CharacteristicDescription {
508    /// The type of values for datapoints of this characteristic.
509    pub datatype: DataType,
510
511    /// The human-readable name for the characteristic.
512    pub name: String,
513}
514
515/// The description for the root of a brevdash repository.
516#[derive(Clone, Debug, Serialize, Deserialize)]
517pub struct RootDescription {
518    /// The human-readable name of the repository.
519    pub name: String,
520
521    /// The caracteristics available in the repository.
522    pub characteristics: IndexMap<String, CharacteristicDescription>,
523}
524
525/// The description of a project inside a brevdash repository.
526#[derive(Clone, Debug, Serialize, Deserialize)]
527pub struct ProjectDescription {
528    /// The human-readable name of the project.
529    pub name: String,
530
531    /// A more detailed description of the project.
532    #[serde(
533        default = "Default::default",
534        skip_serializing_if = "String::is_empty"
535    )]
536    pub description: String,
537
538    /// The website URL of the project.
539    #[serde(
540        default = "Default::default",
541        skip_serializing_if = "String::is_empty"
542    )]
543    pub website: String,
544
545    /// The VCS url of the project (e.g. a git url)
546    #[serde(
547        default = "Default::default",
548        skip_serializing_if = "String::is_empty"
549    )]
550    pub vcs: String,
551}