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
10pub const HOME_DIRECTORY_REQUIRED: DiagnosticCode = DiagnosticCode::new("compose.paths.home-directory-required");
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum HostPathKind {
16 Relative,
18 UnixAbsolute,
20 WindowsDriveAbsolute,
22 WindowsUnc,
24 HomeRelative,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub enum PathPurpose {
31 ServiceBind {
33 service: String,
35 index: usize,
37 },
38 ConfigFile {
40 config: String,
42 },
43 SecretFile {
45 secret: String,
47 },
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct PathContext {
53 home_directory: Option<PathBuf>,
54}
55
56impl PathContext {
57 #[must_use]
59 pub const fn new() -> Self {
60 Self { home_directory: None }
61 }
62
63 #[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 #[must_use]
72 pub fn home_directory(&self) -> Option<&Path> {
73 self.home_directory.as_deref()
74 }
75}
76
77#[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 #[must_use]
92 pub fn raw(&self) -> &str {
93 &self.raw
94 }
95
96 #[must_use]
98 pub const fn kind(&self) -> HostPathKind {
99 self.kind
100 }
101
102 #[must_use]
104 pub const fn purpose(&self) -> &PathPurpose {
105 &self.purpose
106 }
107
108 #[must_use]
110 pub const fn source(&self) -> SourceSpan {
111 self.source
112 }
113
114 #[must_use]
116 pub fn origin(&self) -> &Path {
117 &self.origin
118 }
119
120 #[must_use]
125 pub fn resolved(&self) -> Option<&Path> {
126 self.resolved.as_deref()
127 }
128
129 #[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#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct PathResolution {
156 paths: Vec<ResolvedHostPath>,
157 diagnostics: Vec<Diagnostic>,
158}
159
160impl PathResolution {
161 #[must_use]
163 pub fn paths(&self) -> &[ResolvedHostPath] {
164 &self.paths
165 }
166
167 #[must_use]
169 pub fn diagnostics(&self) -> &[Diagnostic] {
170 &self.diagnostics
171 }
172
173 #[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#[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}