Skip to main content

knowledge_base_crud/write/references/
mod.rs

1use crate::write::WriteMode;
2use crate::write::execution::{FileEdit, MutationDisposition, PlannedMutation, execute};
3use crate::{Error, KnowledgeBaseRepository, filesystem};
4use chrono::{DateTime, NaiveDate};
5use knowledge_base_models::{IdAllocation, Reference, ReferenceId};
6use language_tags::LanguageTag;
7use serde::Serialize;
8use std::fs;
9use std::str::FromStr;
10use url::Url;
11
12#[derive(Clone, Copy, Debug)]
13pub struct References<'a> {
14    repository: &'a KnowledgeBaseRepository,
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct ReferenceDraft {
19    pub url: String,
20    pub title: String,
21    pub publisher: Option<String>,
22    pub publication_date: Option<String>,
23    pub source_language: Option<String>,
24    pub retrieved_at: String,
25    pub archive_url: Option<String>,
26}
27
28#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ReferenceRegistrationStatus {
31    Previewed,
32    Registered,
33    Existing,
34}
35
36#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
37pub struct ReferenceRegistrationOutcome {
38    pub status: ReferenceRegistrationStatus,
39    pub reference: ReferenceId,
40}
41
42impl<'a> References<'a> {
43    pub(crate) fn new(repository: &'a KnowledgeBaseRepository) -> Self {
44        Self { repository }
45    }
46
47    pub fn register(&self, draft: &ReferenceDraft, mode: WriteMode) -> Result<ReferenceRegistrationOutcome, Error> {
48        validate_draft(draft)?;
49        let (plan, disposition) = execute(self.repository, mode, || {
50            if let Some(reference) = find_reference_by_url(self.repository.root(), &draft.url)? {
51                return Ok(PlannedMutation::new(RegistrationPlan::Existing(reference), Vec::new(), false));
52            }
53            let (reference, edits) = plan_registration(self.repository.root(), draft)?;
54            Ok(PlannedMutation::new(RegistrationPlan::New(reference), edits, true))
55        })?;
56        Ok(match (plan, disposition) {
57            (RegistrationPlan::Existing(reference), MutationDisposition::NotCommitted) => ReferenceRegistrationOutcome {
58                status: ReferenceRegistrationStatus::Existing,
59                reference,
60            },
61            (RegistrationPlan::New(reference), MutationDisposition::Previewed) => ReferenceRegistrationOutcome {
62                status: ReferenceRegistrationStatus::Previewed,
63                reference,
64            },
65            (RegistrationPlan::New(reference), MutationDisposition::Committed) => ReferenceRegistrationOutcome {
66                status: ReferenceRegistrationStatus::Registered,
67                reference,
68            },
69            (_, disposition) => {
70                return Err(Error::Commit {
71                    message: format!("invalid reference registration disposition: {disposition:?}"),
72                });
73            }
74        })
75    }
76}
77
78enum RegistrationPlan {
79    Existing(ReferenceId),
80    New(ReferenceId),
81}
82
83fn validate_draft(draft: &ReferenceDraft) -> Result<(), Error> {
84    validate_url("url", &draft.url)?;
85    validate_nonempty("title", &draft.title)?;
86    if let Some(value) = &draft.publisher {
87        validate_nonempty("publisher", value)?;
88    }
89    if let Some(value) = &draft.publication_date {
90        validate_nonempty("publication_date", value)?;
91        if !valid_partial_date(value) {
92            return Err(Error::InvalidRequest("publication_date must be a valid YYYY, YYYY-MM, or YYYY-MM-DD date".to_owned()));
93        }
94    }
95    if let Some(value) = &draft.source_language {
96        validate_nonempty("source_language", value)?;
97        if value.parse::<LanguageTag>().is_err() {
98            return Err(Error::InvalidRequest("source_language must be a well-formed BCP 47 tag".to_owned()));
99        }
100    }
101    if let Some(value) = &draft.archive_url {
102        validate_url("archive_url", value)?;
103    }
104    if DateTime::parse_from_rfc3339(&draft.retrieved_at).is_err() {
105        return Err(Error::InvalidRequest("retrieved_at must be an RFC 3339 timestamp".to_owned()));
106    }
107    Ok(())
108}
109
110fn validate_nonempty(field: &str, value: &str) -> Result<(), Error> {
111    if value.trim().is_empty() {
112        Err(Error::InvalidRequest(format!("{field} must not be empty")))
113    } else {
114        Ok(())
115    }
116}
117
118fn validate_url(field: &str, value: &str) -> Result<(), Error> {
119    if Url::parse(value).is_err() {
120        Err(Error::InvalidRequest(format!("{field} must be an absolute URL")))
121    } else {
122        Ok(())
123    }
124}
125
126fn valid_partial_date(value: &str) -> bool {
127    match value.len() {
128        4 => value.bytes().all(|byte| byte.is_ascii_digit()),
129        7 => value
130            .get(..4)
131            .zip(value.get(5..))
132            .filter(|(year, month)| year.bytes().all(|byte| byte.is_ascii_digit()) && month.bytes().all(|byte| byte.is_ascii_digit()))
133            .and_then(|(year, month)| year.parse::<i32>().ok().zip(month.parse::<u32>().ok()))
134            .is_some_and(|(year, month)| value.as_bytes().get(4) == Some(&b'-') && NaiveDate::from_ymd_opt(year, month, 1).is_some()),
135        10 => NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok(),
136        _ => false,
137    }
138}
139
140fn find_reference_by_url(root: &std::path::Path, url: &str) -> Result<Option<ReferenceId>, Error> {
141    let directory = root.join("references");
142    let mut references = Vec::new();
143    for entry in fs::read_dir(&directory).map_err(|source| Error::Read { path: directory.clone(), source })? {
144        let entry = entry.map_err(|source| Error::Read { path: directory.clone(), source })?;
145        let path = entry.path();
146        if entry.file_type().map_err(|source| Error::Read { path: path.clone(), source })?.is_file() && path.extension().and_then(|extension| extension.to_str()) == Some("yaml") {
147            let source = fs::read(&path).map_err(|source| Error::Read { path: path.clone(), source })?;
148            let reference = serde_yaml::from_slice::<Reference>(&source).map_err(|source| Error::ParseReference { path, source })?;
149            references.push(reference);
150        }
151    }
152    references.sort_by(|left, right| left.id.cmp(&right.id));
153    Ok(references.into_iter().find(|reference| reference.url == url).map(|reference| reference.id))
154}
155
156fn plan_registration(root: &std::path::Path, draft: &ReferenceDraft) -> Result<(ReferenceId, Vec<FileEdit>), Error> {
157    let allocation_path = root.join("id_allocation.yaml");
158    let allocation_source = fs::read(&allocation_path).map_err(|source| Error::Read {
159        path: allocation_path.clone(),
160        source,
161    })?;
162    let mut allocation = serde_yaml::from_slice::<IdAllocation>(&allocation_source).map_err(|source| Error::ParseAllocation {
163        path: allocation_path.clone(),
164        source,
165    })?;
166    let next = allocation.next.reference;
167    let incremented = next
168        .checked_add(1)
169        .ok_or_else(|| Error::InvalidRequest("cannot allocate another reference identifier".to_owned()))?;
170    let reference = ReferenceId::from_str(&format!("R{next}")).expect("positive allocation counters form valid reference identifiers");
171    let reference_path = filesystem::path(root, "references", reference.as_str(), "yaml");
172    allocation.next.reference = incremented;
173    let reference_source = serde_yaml::to_string(&Reference {
174        id: reference.clone(),
175        url: draft.url.clone(),
176        title: draft.title.clone(),
177        publisher: draft.publisher.clone(),
178        publication_date: draft.publication_date.clone(),
179        source_language: draft.source_language.clone(),
180        retrieved_at: draft.retrieved_at.clone(),
181        archive_url: draft.archive_url.clone(),
182    })
183    .map_err(|error| Error::Edit {
184        path: reference_path.clone(),
185        message: format!("cannot serialize reference: {error}"),
186    })?;
187    let updated_allocation = serde_yaml::to_string(&allocation).map_err(|error| Error::Edit {
188        path: allocation_path.clone(),
189        message: format!("cannot serialize identifier allocation: {error}"),
190    })?;
191    Ok((
192        reference,
193        vec![
194            FileEdit {
195                path: reference_path,
196                original: None,
197                replacement: reference_source.into_bytes(),
198            },
199            FileEdit {
200                path: allocation_path,
201                original: Some(allocation_source),
202                replacement: updated_allocation.into_bytes(),
203            },
204        ],
205    ))
206}