Skip to main content

intentional_core/
projection_file.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Projection file paths and their one resolver.
7
8use crate::error::{Error, Result};
9use crate::model::Adapter;
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use std::path::{Component, Path, PathBuf};
13
14/// The `file` of a projection, kept as written in configuration.
15///
16/// A file that starts with `/` is a root file: it resolves from the
17/// workspace root. Any other file resolves below the release-unit path.
18///
19/// The text is private, so that [`ProjectionFile::workspace_path`] is the
20/// only way to turn it into a path. No caller joins a release-unit path and
21/// a projection file by hand. That matters for a root file: joining `/x`
22/// onto any path gives the filesystem root, outside the workspace.
23#[derive(Debug, Clone, Deserialize, Serialize)]
24#[serde(transparent)]
25pub struct ProjectionFile(String);
26
27impl ProjectionFile {
28    /// A file relative to the release-unit path.
29    pub fn unit_relative(path: &Path) -> Self {
30        Self(path.to_string_lossy().into_owned())
31    }
32
33    /// Whether this file resolves from the workspace root.
34    pub fn is_root(&self) -> bool {
35        self.0.starts_with('/')
36    }
37
38    /// Workspace-relative path of this file for a release unit at `unit_path`.
39    pub fn workspace_path(&self, unit_path: &Path) -> PathBuf {
40        match self.0.strip_prefix('/') {
41            Some(segments) => segments.split('/').collect(),
42            None => normalized(&unit_path.join(&self.0)),
43        }
44    }
45
46    /// Refuse a file that this release unit and adapter cannot project.
47    ///
48    /// A root file must be in its one canonical form, and only the generic
49    /// `json`, `toml`, and `yaml` adapters may use it. The check reads the
50    /// text, not a `Path`, so that every platform gives the same result. A
51    /// unit-relative file must be non-empty, relative, and free of `..`. A
52    /// leading `\`, or a drive prefix on any segment, is refused in either
53    /// form, because on Windows it leaves the workspace.
54    pub(crate) fn validate(&self, release_unit_id: &str, adapter: Adapter) -> Result<()> {
55        let description = format!("release unit {release_unit_id} projection file");
56        if self.is_root() || self.0.starts_with('\\') || has_drive_prefix(&self.0) {
57            if !is_canonical_root(&self.0) {
58                return Err(Error::Validation(format!(
59                    "{description} {} must be a root path of the form /dir/file with no empty, . or .. segments, no \\ and no drive prefix",
60                    self.0
61                )));
62            }
63            if !adapter.requires_pointer() {
64                return Err(Error::Validation(format!(
65                    "release unit {release_unit_id} {} projection file must be relative to the release-unit path; only json, toml, and yaml projections may use a root path",
66                    format!("{adapter:?}").to_lowercase()
67                )));
68            }
69            return Ok(());
70        }
71        validate_relative_path(Path::new(&self.0), &description)
72    }
73
74    fn as_path(&self) -> &Path {
75        Path::new(&self.0)
76    }
77}
78
79impl PartialEq for ProjectionFile {
80    fn eq(&self, other: &Self) -> bool {
81        self.as_path() == other.as_path()
82    }
83}
84
85impl Eq for ProjectionFile {}
86
87impl PartialEq<&str> for ProjectionFile {
88    fn eq(&self, other: &&str) -> bool {
89        self.as_path() == Path::new(other)
90    }
91}
92
93impl fmt::Display for ProjectionFile {
94    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95        formatter.write_str(&self.0)
96    }
97}
98
99/// Exactly one `/`, then `/`-separated segments that are not empty, `.`,
100/// or `..`, and do not start with a drive prefix, with no `\` anywhere.
101fn is_canonical_root(text: &str) -> bool {
102    text.strip_prefix('/').is_some_and(|segments| {
103        !segments.contains('\\')
104            && segments
105                .split('/')
106                .all(|segment| !matches!(segment, "" | "." | "..") && !has_drive_prefix(segment))
107    })
108}
109
110/// `C:` and similar: a Windows drive prefix. On Windows, a path segment
111/// with a drive prefix replaces the path it is pushed onto.
112fn has_drive_prefix(text: &str) -> bool {
113    let bytes = text.as_bytes();
114    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
115}
116
117/// One path per file, so `./docs/x` and `docs/x` are the same key.
118pub(crate) fn normalized(relative: &Path) -> PathBuf {
119    relative
120        .components()
121        .filter(|component| !matches!(component, Component::CurDir))
122        .collect()
123}
124
125/// Refuse a configured relative path that is empty, absolute, contains
126/// `..`, or has a segment that starts with a drive prefix. Segments split on
127/// both `/` and `\`, so that every platform refuses the same text.
128pub(crate) fn validate_relative_path(path: &Path, description: &str) -> Result<()> {
129    if path.as_os_str().is_empty()
130        || path.is_absolute()
131        || path
132            .components()
133            .any(|component| matches!(component, Component::ParentDir))
134    {
135        return Err(Error::Validation(format!(
136            "{description} must be a non-empty relative path without .."
137        )));
138    }
139    let text = path.to_string_lossy();
140    if let Some(segment) = text
141        .split(['/', '\\'])
142        .find(|segment| has_drive_prefix(segment))
143    {
144        return Err(Error::Validation(format!(
145            "{description} {text} has a segment {segment} that starts with a drive prefix; on Windows the path would leave the repository"
146        )));
147    }
148    Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn unit_relative_file_resolves_below_the_unit_path() {
157        let file = ProjectionFile::unit_relative(Path::new("./meta.yml"));
158        assert_eq!(
159            file.workspace_path(Path::new("site")),
160            PathBuf::from("site/meta.yml")
161        );
162        assert_eq!(
163            file.workspace_path(Path::new(".")),
164            PathBuf::from("meta.yml")
165        );
166    }
167}