1use camino::{Utf8Path, Utf8PathBuf};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::domain::ownership::Sha256;
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(try_from = "String", into = "String")]
21pub struct TargetPath(Utf8PathBuf);
22
23#[derive(Debug, Clone, PartialEq, Eq, Error)]
25#[error("{0}")]
26pub struct TargetPathError(String);
27
28impl TargetPath {
29 pub fn new(value: &str) -> Result<Self, TargetPathError> {
38 let refuse = |why: &str| TargetPathError(format!("{value}: {why}"));
39 if value.is_empty() {
40 return Err(refuse("the path is empty"));
41 }
42 if value.contains('\0') {
43 return Err(refuse("the path carries a NUL"));
44 }
45 let path = Utf8Path::new(value);
46 if path.is_absolute() {
47 return Err(refuse("the path is absolute"));
48 }
49 let mut normalized = Utf8PathBuf::new();
50 for component in path.components() {
51 match component {
52 camino::Utf8Component::Normal(part) => normalized.push(part),
53 camino::Utf8Component::CurDir => {}
54 camino::Utf8Component::ParentDir => {
55 return Err(refuse("the path climbs out of the target"));
56 }
57 camino::Utf8Component::RootDir | camino::Utf8Component::Prefix(_) => {
58 return Err(refuse("the path is absolute"));
59 }
60 }
61 }
62 if normalized.as_str().is_empty() {
63 return Err(refuse("the path names no file"));
64 }
65 Ok(Self(normalized))
66 }
67
68 #[must_use]
70 pub fn as_path(&self) -> &Utf8Path {
71 &self.0
72 }
73
74 #[must_use]
76 pub fn as_str(&self) -> &str {
77 self.0.as_str()
78 }
79}
80
81impl TryFrom<String> for TargetPath {
82 type Error = TargetPathError;
83
84 fn try_from(value: String) -> Result<Self, Self::Error> {
85 Self::new(&value)
86 }
87}
88
89impl From<TargetPath> for String {
90 fn from(value: TargetPath) -> Self {
91 value.0.into_string()
92 }
93}
94
95impl std::fmt::Display for TargetPath {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.write_str(self.0.as_str())
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "kebab-case")]
104pub enum Class {
105 Managed,
107 Adopted,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(tag = "kind", rename_all = "kebab-case")]
114pub enum Operation {
115 WriteFile {
117 path: TargetPath,
119 class: Class,
121 before: Option<Sha256>,
123 after: Sha256,
125 },
126 KeepFile {
133 path: TargetPath,
135 held: Sha256,
137 baseline_before: Sha256,
139 baseline_after: Sha256,
141 },
142 SpliceBlock {
144 path: TargetPath,
146 marker: String,
148 before: Option<Sha256>,
150 after: Sha256,
152 },
153 RemoveOwnedFile {
163 path: TargetPath,
165 before: Sha256,
167 },
168 WriteDebt {
170 path: TargetPath,
172 before: Option<Sha256>,
174 after: Sha256,
176 },
177 WriteRecord {
179 path: TargetPath,
181 before: Option<Sha256>,
183 after: Sha256,
185 },
186}
187
188impl Operation {
189 #[must_use]
191 pub const fn path(&self) -> &TargetPath {
192 match self {
193 Self::WriteFile { path, .. }
194 | Self::KeepFile { path, .. }
195 | Self::SpliceBlock { path, .. }
196 | Self::RemoveOwnedFile { path, .. }
197 | Self::WriteDebt { path, .. }
198 | Self::WriteRecord { path, .. } => path,
199 }
200 }
201
202 #[must_use]
204 pub const fn kind(&self) -> &'static str {
205 match self {
206 Self::WriteFile { .. } => "write-file",
207 Self::KeepFile { .. } => "keep-file",
208 Self::SpliceBlock { .. } => "splice-block",
209 Self::RemoveOwnedFile { .. } => "remove-owned-file",
210 Self::WriteDebt { .. } => "write-debt",
211 Self::WriteRecord { .. } => "write-record",
212 }
213 }
214
215 #[must_use]
218 pub const fn before(&self) -> Option<&Sha256> {
219 match self {
220 Self::WriteFile { before, .. }
221 | Self::SpliceBlock { before, .. }
222 | Self::WriteDebt { before, .. }
223 | Self::WriteRecord { before, .. } => before.as_ref(),
224 Self::RemoveOwnedFile { before, .. } => Some(before),
225 Self::KeepFile { held, .. } => Some(held),
226 }
227 }
228
229 #[must_use]
231 pub const fn after(&self) -> Option<&Sha256> {
232 match self {
233 Self::WriteFile { after, .. }
234 | Self::SpliceBlock { after, .. }
235 | Self::WriteDebt { after, .. }
236 | Self::WriteRecord { after, .. } => Some(after),
237 Self::KeepFile { held, .. } => Some(held),
238 Self::RemoveOwnedFile { .. } => None,
239 }
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Error)]
245#[error("{0} is written by two operations, {1} and {2}")]
246pub struct DuplicateDestination(TargetPath, &'static str, &'static str);
247
248pub fn no_duplicate_destination(operations: &[Operation]) -> Result<(), DuplicateDestination> {
255 for (index, operation) in operations.iter().enumerate() {
256 for other in &operations[index + 1..] {
257 if operation.path() == other.path() {
258 return Err(DuplicateDestination(
259 operation.path().clone(),
260 operation.kind(),
261 other.kind(),
262 ));
263 }
264 }
265 }
266 Ok(())
267}
268
269#[cfg(test)]
270mod tests {
271 #![allow(
272 clippy::unwrap_used,
273 reason = "a test panics as its failure signal, not as control flow"
274 )]
275
276 use super::*;
277
278 fn path(value: &str) -> TargetPath {
279 TargetPath::new(value).unwrap()
280 }
281
282 #[test]
283 fn an_ordinary_relative_path_is_accepted_and_normalized() {
284 assert_eq!(
285 path("docs/specs/SPEC-x.md").as_str(),
286 "docs/specs/SPEC-x.md"
287 );
288 assert_eq!(path("./docs/x.md").as_str(), "docs/x.md");
289 assert_eq!(path("docs//x.md").as_str(), "docs/x.md");
290 }
291
292 #[test]
293 fn a_path_a_plan_may_not_name_is_refused() {
294 for value in ["", "/etc/passwd", "../escape.md", "docs/../../out.md"] {
295 assert!(TargetPath::new(value).is_err(), "{value} was accepted");
296 }
297 assert!(TargetPath::new("docs/\0.md").is_err());
298 assert!(TargetPath::new("./").is_err());
299 }
300
301 #[test]
302 fn a_path_round_trips_through_its_string_form() {
303 let held = path("docs/x.md");
304 let json = serde_json::to_string(&held).unwrap();
305 assert_eq!(json, "\"docs/x.md\"");
306 assert_eq!(serde_json::from_str::<TargetPath>(&json).unwrap(), held);
307 assert!(serde_json::from_str::<TargetPath>("\"../x.md\"").is_err());
308 }
309
310 #[test]
311 fn every_operation_names_its_path_and_its_kind() {
312 let write = Operation::WriteFile {
313 path: path("a.md"),
314 class: Class::Managed,
315 before: None,
316 after: Sha256::of(b"a"),
317 };
318 assert_eq!(write.kind(), "write-file");
319 assert_eq!(write.path().as_str(), "a.md");
320 assert_eq!(write.before(), None);
321 assert_eq!(write.after(), Some(&Sha256::of(b"a")));
322
323 let remove = Operation::RemoveOwnedFile {
324 path: path("b.md"),
325 before: Sha256::of(b"b"),
326 };
327 assert_eq!(remove.after(), None);
328 assert_eq!(remove.before(), Some(&Sha256::of(b"b")));
329 }
330
331 #[test]
332 fn a_kept_file_holds_its_bytes_and_moves_only_the_baseline() {
333 let kept = Operation::KeepFile {
334 path: path("docs/specs/SPEC-x.md"),
335 held: Sha256::of(b"mine"),
336 baseline_before: Sha256::of(b"old seed"),
337 baseline_after: Sha256::of(b"new seed"),
338 };
339 assert_eq!(kept.before(), kept.after());
340 }
341
342 #[test]
343 fn one_destination_written_twice_is_refused() {
344 let operations = vec![
345 Operation::WriteFile {
346 path: path("a.md"),
347 class: Class::Managed,
348 before: None,
349 after: Sha256::of(b"a"),
350 },
351 Operation::WriteDebt {
352 path: path("a.md"),
353 before: None,
354 after: Sha256::of(b"b"),
355 },
356 ];
357 let error = no_duplicate_destination(&operations).unwrap_err();
358 assert!(error.to_string().contains("a.md"), "{error}");
359 assert!(no_duplicate_destination(&operations[..1]).is_ok());
360 }
361}