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