Skip to main content

spec_driven_docs/landing/
path.rs

1//! The target-relative path every write is bounded by.
2//!
3//! A destination is a path inside the target, and this type is where that
4//! is proven once. Empty, absolute, climbing out, or carrying a NUL are
5//! refusals rather than values a later writer has to re-check.
6
7use camino::{Utf8Path, Utf8PathBuf};
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11/// A path an operation may name: relative, inside the target, and ordinary.
12#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(try_from = "String", into = "String")]
14pub struct TargetPath(Utf8PathBuf);
15
16/// A path no operation may name.
17#[derive(Debug, Clone, PartialEq, Eq, Error)]
18#[error("{0}")]
19pub struct TargetPathError(String);
20
21impl TargetPath {
22    /// Validate one target-relative path.
23    ///
24    /// # Errors
25    ///
26    /// [`TargetPathError`] for an empty path, an absolute path, a path
27    /// that climbs out, a path carrying a NUL, or one with a trailing or
28    /// repeated separator. A plan that could name any of those is a plan
29    /// whose reach the type no longer bounds.
30    pub fn new(value: &str) -> Result<Self, TargetPathError> {
31        let refuse = |why: &str| TargetPathError(format!("{value}: {why}"));
32        if value.is_empty() {
33            return Err(refuse("the path is empty"));
34        }
35        if value.contains('\0') {
36            return Err(refuse("the path carries a NUL"));
37        }
38        let path = Utf8Path::new(value);
39        if path.is_absolute() {
40            return Err(refuse("the path is absolute"));
41        }
42        let mut normalized = Utf8PathBuf::new();
43        for component in path.components() {
44            match component {
45                camino::Utf8Component::Normal(part) => normalized.push(part),
46                camino::Utf8Component::CurDir => {}
47                camino::Utf8Component::ParentDir => {
48                    return Err(refuse("the path climbs out of the target"));
49                }
50                camino::Utf8Component::RootDir | camino::Utf8Component::Prefix(_) => {
51                    return Err(refuse("the path is absolute"));
52                }
53            }
54        }
55        if normalized.as_str().is_empty() {
56            return Err(refuse("the path names no file"));
57        }
58        Ok(Self(normalized))
59    }
60
61    /// The path, relative to the target root.
62    #[must_use]
63    pub fn as_path(&self) -> &Utf8Path {
64        &self.0
65    }
66
67    /// The path as it is written.
68    #[must_use]
69    pub fn as_str(&self) -> &str {
70        self.0.as_str()
71    }
72}
73
74impl TryFrom<String> for TargetPath {
75    type Error = TargetPathError;
76
77    fn try_from(value: String) -> Result<Self, Self::Error> {
78        Self::new(&value)
79    }
80}
81
82impl From<TargetPath> for String {
83    fn from(value: TargetPath) -> Self {
84        value.0.into_string()
85    }
86}
87
88impl std::fmt::Display for TargetPath {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.write_str(self.0.as_str())
91    }
92}