Skip to main content

compose_lens/resolution/
paths.rs

1use super::{selection_matches, service_entries, service_in_scope};
2use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
3use crate::merge::{MergedProject, MergedScalar, MergedValue};
4use crate::model::{Located, ShortVolumeMount};
5use crate::profiles::ProfileSelection;
6use crate::source::SourceSpan;
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10/// A home-relative path cannot be expanded without explicit caller context.
11pub const HOME_DIRECTORY_REQUIRED: DiagnosticCode = DiagnosticCode::new("compose.paths.home-directory-required");
12
13/// The lexical category of one authored host path.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum HostPathKind {
16    /// A path interpreted relative to the project base directory.
17    Relative,
18    /// A Unix-style absolute path.
19    UnixAbsolute,
20    /// A Windows drive-letter absolute path.
21    WindowsDriveAbsolute,
22    /// A Windows UNC path.
23    WindowsUnc,
24    /// `~` or a `~/`-prefixed path requiring an explicit home directory.
25    HomeRelative,
26}
27
28/// Why a host path participates in the Compose project.
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub enum PathPurpose {
31    /// A service bind-mount source.
32    ServiceBind {
33        /// Service name.
34        service: String,
35        /// Zero-based merged mount index.
36        index: usize,
37    },
38    /// A top-level config `file` source.
39    ConfigFile {
40        /// Config model name.
41        config: String,
42    },
43    /// A top-level secret `file` source.
44    SecretFile {
45        /// Secret model name.
46        secret: String,
47    },
48}
49
50/// Explicit context for host-path interpretation.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct PathContext {
53    home_directory: Option<PathBuf>,
54}
55
56impl PathContext {
57    /// Creates context without a home-directory assumption.
58    #[must_use]
59    pub const fn new() -> Self {
60        Self { home_directory: None }
61    }
62
63    /// Supplies a caller-owned home directory for `~` expansion.
64    #[must_use]
65    pub fn with_home_directory(mut self, directory: impl Into<PathBuf>) -> Self {
66        self.home_directory = Some(directory.into());
67        self
68    }
69
70    /// Returns the explicit home directory, if supplied.
71    #[must_use]
72    pub fn home_directory(&self) -> Option<&Path> {
73        self.home_directory.as_deref()
74    }
75}
76
77/// One classified host path and its explicit resolution origin.
78#[derive(Clone, PartialEq, Eq)]
79pub struct ResolvedHostPath {
80    raw: String,
81    kind: HostPathKind,
82    purpose: PathPurpose,
83    source: SourceSpan,
84    origin: PathBuf,
85    resolved: Option<PathBuf>,
86    sensitive: bool,
87}
88
89impl ResolvedHostPath {
90    /// Returns the interpolated but otherwise unmodified path value.
91    #[must_use]
92    pub fn raw(&self) -> &str {
93        &self.raw
94    }
95
96    /// Returns the lexical path category.
97    #[must_use]
98    pub const fn kind(&self) -> HostPathKind {
99        self.kind
100    }
101
102    /// Returns why the path is used.
103    #[must_use]
104    pub const fn purpose(&self) -> &PathPurpose {
105        &self.purpose
106    }
107
108    /// Returns the authored value span.
109    #[must_use]
110    pub const fn source(&self) -> SourceSpan {
111        self.source
112    }
113
114    /// Returns the first-file project directory used as the path origin.
115    #[must_use]
116    pub fn origin(&self) -> &Path {
117        &self.origin
118    }
119
120    /// Returns the path with its explicit relative or home origin applied.
121    ///
122    /// This is lexical resolution only. It does not canonicalize, follow symlinks, or access the
123    /// file system. Windows absolute paths remain representable on non-Windows hosts.
124    #[must_use]
125    pub fn resolved(&self) -> Option<&Path> {
126        self.resolved.as_deref()
127    }
128
129    /// Reports whether interpolation inserted sensitive content.
130    #[must_use]
131    pub const fn is_sensitive(&self) -> bool {
132        self.sensitive
133    }
134}
135
136impl fmt::Debug for ResolvedHostPath {
137    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
138        let raw = if self.sensitive { "<redacted>" } else { &self.raw };
139        let resolved = if self.sensitive { None } else { self.resolved.as_deref() };
140        formatter
141            .debug_struct("ResolvedHostPath")
142            .field("raw", &raw)
143            .field("kind", &self.kind)
144            .field("purpose", &self.purpose)
145            .field("source", &self.source)
146            .field("origin", &self.origin)
147            .field("resolved", &resolved)
148            .field("sensitive", &self.sensitive)
149            .finish()
150    }
151}
152
153/// A recoverable, non-destructive host-path resolution result.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct PathResolution {
156    paths: Vec<ResolvedHostPath>,
157    diagnostics: Vec<Diagnostic>,
158}
159
160impl PathResolution {
161    /// Returns paths in deterministic project traversal order.
162    #[must_use]
163    pub fn paths(&self) -> &[ResolvedHostPath] {
164        &self.paths
165    }
166
167    /// Returns path diagnostics.
168    #[must_use]
169    pub fn diagnostics(&self) -> &[Diagnostic] {
170        &self.diagnostics
171    }
172
173    /// Reports whether path processing emitted no error diagnostics.
174    #[must_use]
175    pub fn is_valid(&self) -> bool {
176        self.diagnostics
177            .iter()
178            .all(|diagnostic| diagnostic.severity() != Severity::Error)
179    }
180}
181
182/// Finds and lexically resolves paths covered by the initial conversion boundary.
183#[must_use]
184pub fn resolve_paths(
185    project: &MergedProject,
186    selection: Option<&ProfileSelection>,
187    context: &PathContext,
188) -> PathResolution {
189    let mut diagnostics = Vec::new();
190    if !selection_matches(project, selection, &mut diagnostics) {
191        return PathResolution {
192            paths: Vec::new(),
193            diagnostics,
194        };
195    }
196
197    let mut paths = Vec::new();
198    for service in service_entries(project) {
199        if !service_in_scope(selection, service.key()) {
200            continue;
201        }
202        let Some(volumes) = service.value().get("volumes").and_then(MergedValue::as_sequence) else {
203            continue;
204        };
205        for (index, volume) in volumes.iter().enumerate() {
206            let source = bind_source(volume);
207            if let Some((source, span, sensitive)) = source {
208                push_path(
209                    &mut paths,
210                    &mut diagnostics,
211                    project.base_directory(),
212                    context,
213                    &source,
214                    span,
215                    sensitive,
216                    PathPurpose::ServiceBind {
217                        service: service.key().to_owned(),
218                        index,
219                    },
220                );
221            }
222        }
223    }
224
225    collect_resource_files(project, "configs", true, context, &mut paths, &mut diagnostics);
226    collect_resource_files(project, "secrets", false, context, &mut paths, &mut diagnostics);
227    PathResolution { paths, diagnostics }
228}
229
230fn bind_source(volume: &MergedValue) -> Option<(String, SourceSpan, bool)> {
231    if let Some(scalar) = volume.as_scalar() {
232        let span = super::effective_span(volume);
233        let mount = ShortVolumeMount::new(Located::new(scalar.value().to_owned(), span));
234        let source = mount.source()?;
235        return is_path_source(source).then_some((source.to_owned(), span, scalar.is_sensitive()));
236    }
237    if volume
238        .get("type")
239        .and_then(MergedValue::as_scalar)
240        .map(MergedScalar::value)
241        != Some("bind")
242    {
243        return None;
244    }
245    let source = volume.get("source")?;
246    let scalar = source.as_scalar()?;
247    Some((
248        scalar.value().to_owned(),
249        super::effective_span(source),
250        scalar.is_sensitive(),
251    ))
252}
253
254fn collect_resource_files(
255    project: &MergedProject,
256    field: &str,
257    config: bool,
258    context: &PathContext,
259    paths: &mut Vec<ResolvedHostPath>,
260    diagnostics: &mut Vec<Diagnostic>,
261) {
262    let Some(resources) = project.root().get(field).and_then(MergedValue::as_mapping) else {
263        return;
264    };
265    for resource in resources {
266        let Some(file) = resource.value().get("file") else {
267            continue;
268        };
269        let Some(scalar) = file.as_scalar() else {
270            continue;
271        };
272        let purpose = if config {
273            PathPurpose::ConfigFile {
274                config: resource.key().to_owned(),
275            }
276        } else {
277            PathPurpose::SecretFile {
278                secret: resource.key().to_owned(),
279            }
280        };
281        push_path(
282            paths,
283            diagnostics,
284            project.base_directory(),
285            context,
286            scalar.value(),
287            super::effective_span(file),
288            scalar.is_sensitive(),
289            purpose,
290        );
291    }
292}
293
294#[allow(clippy::too_many_arguments)]
295fn push_path(
296    paths: &mut Vec<ResolvedHostPath>,
297    diagnostics: &mut Vec<Diagnostic>,
298    base: &Path,
299    context: &PathContext,
300    raw: &str,
301    source: SourceSpan,
302    sensitive: bool,
303    purpose: PathPurpose,
304) {
305    let kind = classify(raw);
306    let resolved = match kind {
307        HostPathKind::Relative => Some(base.join(raw)),
308        HostPathKind::UnixAbsolute | HostPathKind::WindowsDriveAbsolute | HostPathKind::WindowsUnc => {
309            Some(PathBuf::from(raw))
310        }
311        HostPathKind::HomeRelative => context.home_directory.as_ref().map(|home| {
312            raw.strip_prefix("~/")
313                .map_or_else(|| home.clone(), |suffix| home.join(suffix))
314        }),
315    };
316    if kind == HostPathKind::HomeRelative && resolved.is_none() {
317        diagnostics.push(
318            Diagnostic::new(
319                HOME_DIRECTORY_REQUIRED,
320                Severity::Warning,
321                "home-relative path requires an explicit home directory",
322            )
323            .with_label(DiagnosticLabel::primary(source, "home directory not supplied")),
324        );
325    }
326    paths.push(ResolvedHostPath {
327        raw: raw.to_owned(),
328        kind,
329        purpose,
330        source,
331        origin: base.to_path_buf(),
332        resolved,
333        sensitive,
334    });
335}
336
337fn classify(value: &str) -> HostPathKind {
338    if value == "~" || value.starts_with("~/") {
339        HostPathKind::HomeRelative
340    } else if is_windows_drive_absolute(value) {
341        HostPathKind::WindowsDriveAbsolute
342    } else if value.starts_with("\\\\") {
343        HostPathKind::WindowsUnc
344    } else if value.starts_with('/') {
345        HostPathKind::UnixAbsolute
346    } else {
347        HostPathKind::Relative
348    }
349}
350
351pub(crate) fn is_path_source(value: &str) -> bool {
352    value == "."
353        || value == ".."
354        || value.starts_with("./")
355        || value.starts_with("../")
356        || value.starts_with('/')
357        || value == "~"
358        || value.starts_with("~/")
359        || value.starts_with("\\\\")
360        || is_windows_drive_absolute(value)
361}
362
363fn is_windows_drive_absolute(value: &str) -> bool {
364    let bytes = value.as_bytes();
365    bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'\\' | b'/')
366}