Skip to main content

compose_lens/render/
mod.rs

1//! Deterministic rendering and preservation-oriented editing of ComposeLens-owned documents.
2
3mod generated;
4mod preserved;
5
6pub use generated::{
7    ComposeDocumentBuilder, GeneratedAnnotation, GeneratedCommand, GeneratedComposeDocument,
8    GeneratedConfigFileDefinition, GeneratedCpuRtRuntime, GeneratedDevice, GeneratedDns, GeneratedDnsSearch,
9    GeneratedEntrypoint, GeneratedEnvironment, GeneratedEnvironmentFile, GeneratedEnvironmentFileFormat,
10    GeneratedExtraHost, GeneratedHostname, GeneratedLabel, GeneratedLogging, GeneratedLoggingOption,
11    GeneratedLoggingOptionValue, GeneratedLongDevice, GeneratedMemLimit, GeneratedMount, GeneratedNetworkAttachment,
12    GeneratedNetworkDefinition, GeneratedNetworkDriverOption, GeneratedNetworkDriverOptionValue, GeneratedPidsLimit,
13    GeneratedPort, GeneratedProtocol, GeneratedPullPolicy, GeneratedResource, GeneratedRestartPolicy,
14    GeneratedSecretFileDefinition, GeneratedSelinux, GeneratedService, GeneratedServiceRuntimeField, GeneratedShmSize,
15    GeneratedString, GeneratedSysctl, GeneratedSysctls, GeneratedTmpfs, GeneratedUlimit, GeneratedUlimitValue,
16    GeneratedUlimits, GeneratedVolumeDefinition, GeneratedVolumeDriverOption, GeneratedVolumeDriverOptionValue,
17    GenerationError,
18};
19
20pub use preserved::{
21    EDIT_INVALID_NUMBER, EDIT_OVERLAP, EDIT_SOURCE_MISMATCH, EDIT_TARGET_NOT_SCALAR, EDIT_UNSUPPORTED_SCALAR_STYLE,
22    PreservationEditResult, ReplacementScalar, ScalarEdit, apply_preservation_edits,
23};
24
25use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
26use crate::merge::{MergedEntry, MergedProject, MergedScalar, MergedScalarKind, MergedValue, MergedValueKind};
27use crate::profiles::ProfileSelection;
28use crate::resolution::{effective_span, selection_matches};
29use std::fmt;
30
31/// An unresolved alias cannot be represented in a standalone canonical document.
32pub const UNRENDERABLE_ALIAS: DiagnosticCode = DiagnosticCode::new("compose.render.unresolved-alias");
33
34/// A retained YAML tag is not safe to emit as a canonical tag token.
35pub const UNRENDERABLE_TAG: DiagnosticCode = DiagnosticCode::new("compose.render.invalid-tag");
36
37/// A valid number of spaces used for one canonical YAML indentation level.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct IndentWidth(u8);
40
41impl IndentWidth {
42    /// The smallest supported indentation width.
43    pub const MIN: u8 = 1;
44
45    /// The fixed width used by canonical-v1 output.
46    pub const CANONICAL: Self = Self(2);
47
48    /// Creates a validated indentation width.
49    #[must_use]
50    pub const fn new(spaces: u8) -> Option<Self> {
51        if spaces >= Self::MIN { Some(Self(spaces)) } else { None }
52    }
53
54    /// Returns the number of spaces in one indentation level.
55    #[must_use]
56    pub const fn spaces(self) -> u8 {
57        self.0
58    }
59}
60
61/// A presentation-only line-ending choice for rendered YAML.
62#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum LineEnding {
64    /// A single line-feed byte.
65    #[default]
66    Lf,
67    /// A carriage return followed by a line feed.
68    CrLf,
69}
70
71/// Presentation-only options for deterministic merged-project rendering.
72///
73/// These options cannot interpolate, merge, select profiles, apply defaults, reorder mappings,
74/// or change retained Compose short/long forms. [`Self::default`] is the fixed canonical-v1 format.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub struct CanonicalFormatting {
77    indent_width: IndentWidth,
78    line_ending: LineEnding,
79    document_marker: bool,
80    final_newline: bool,
81}
82
83impl CanonicalFormatting {
84    /// Returns the number of spaces used for each nested YAML level.
85    #[must_use]
86    pub const fn indent_width(self) -> IndentWidth {
87        self.indent_width
88    }
89
90    /// Returns the selected line-ending convention.
91    #[must_use]
92    pub const fn line_ending(self) -> LineEnding {
93        self.line_ending
94    }
95
96    /// Reports whether output starts with a YAML document marker.
97    #[must_use]
98    pub const fn document_marker(self) -> bool {
99        self.document_marker
100    }
101
102    /// Reports whether non-empty output ends with the selected line ending.
103    #[must_use]
104    pub const fn final_newline(self) -> bool {
105        self.final_newline
106    }
107
108    /// Returns options with a different validated indentation width.
109    #[must_use]
110    pub const fn with_indent_width(mut self, indent_width: IndentWidth) -> Self {
111        self.indent_width = indent_width;
112        self
113    }
114
115    /// Returns options with a different line-ending convention.
116    #[must_use]
117    pub const fn with_line_ending(mut self, line_ending: LineEnding) -> Self {
118        self.line_ending = line_ending;
119        self
120    }
121
122    /// Returns options with YAML document-marker emission enabled or disabled.
123    #[must_use]
124    pub const fn with_document_marker(mut self, document_marker: bool) -> Self {
125        self.document_marker = document_marker;
126        self
127    }
128
129    /// Returns options with a final line ending enabled or disabled.
130    #[must_use]
131    pub const fn with_final_newline(mut self, final_newline: bool) -> Self {
132        self.final_newline = final_newline;
133        self
134    }
135}
136
137impl Default for CanonicalFormatting {
138    fn default() -> Self {
139        Self {
140            indent_width: IndentWidth::CANONICAL,
141            line_ending: LineEnding::Lf,
142            document_marker: false,
143            final_newline: true,
144        }
145    }
146}
147
148/// The result of deterministic canonical rendering.
149///
150/// The output can contain interpolated secrets and is therefore available only through explicit
151/// accessors. Its `Debug` representation redacts the complete output when any rendered value is
152/// sensitive.
153#[derive(Clone, PartialEq, Eq)]
154pub struct CanonicalRender {
155    output: String,
156    diagnostics: Vec<Diagnostic>,
157    sensitive: bool,
158}
159
160impl CanonicalRender {
161    /// Returns the rendered UTF-8 YAML document.
162    #[must_use]
163    pub fn output(&self) -> &str {
164        &self.output
165    }
166
167    /// Consumes the result and returns the rendered document.
168    #[must_use]
169    pub fn into_output(self) -> String {
170        self.output
171    }
172
173    /// Returns canonical-rendering diagnostics.
174    #[must_use]
175    pub fn diagnostics(&self) -> &[Diagnostic] {
176        &self.diagnostics
177    }
178
179    /// Reports whether the renderer emitted no error diagnostics.
180    #[must_use]
181    pub fn is_valid(&self) -> bool {
182        self.diagnostics
183            .iter()
184            .all(|diagnostic| diagnostic.severity() != Severity::Error)
185    }
186
187    /// Reports whether any rendered value contains sensitive interpolation output.
188    #[must_use]
189    pub const fn is_sensitive(&self) -> bool {
190        self.sensitive
191    }
192}
193
194impl fmt::Debug for CanonicalRender {
195    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196        formatter
197            .debug_struct("CanonicalRender")
198            .field(
199                "output",
200                &if self.sensitive {
201                    "<redacted>"
202                } else {
203                    self.output.as_str()
204                },
205            )
206            .field("diagnostics", &self.diagnostics)
207            .field("sensitive", &self.sensitive)
208            .finish()
209    }
210}
211
212/// Renders a merged project as deterministic `ComposeLens` canonical YAML.
213///
214/// The renderer preserves merged mapping/sequence order and retained short/long forms. It does not
215/// interpolate, resolve paths, apply defaults, normalize syntax variants, or invoke a Compose
216/// implementation. When a matching selection is supplied, profile-inactive services are omitted
217/// while top-level resources remain available.
218#[must_use]
219pub fn render_canonical(project: &MergedProject, selection: Option<&ProfileSelection>) -> CanonicalRender {
220    render_canonical_with_formatting(project, selection, &CanonicalFormatting::default())
221}
222
223/// Renders a merged project with explicit presentation-only formatting choices.
224///
225/// Semantic processing remains identical to [`render_canonical`]. The default formatting value
226/// produces byte-identical canonical-v1 output.
227#[must_use]
228pub fn render_canonical_with_formatting(
229    project: &MergedProject,
230    selection: Option<&ProfileSelection>,
231    formatting: &CanonicalFormatting,
232) -> CanonicalRender {
233    let mut diagnostics = Vec::new();
234    if !selection_matches(project, selection, &mut diagnostics) {
235        return CanonicalRender {
236            output: String::new(),
237            diagnostics,
238            sensitive: false,
239        };
240    }
241
242    let mut renderer = Renderer {
243        output: String::new(),
244        diagnostics,
245        sensitive: false,
246        formatting: *formatting,
247    };
248    renderer.write_project(project, selection);
249    renderer.finish_formatting();
250    CanonicalRender {
251        output: renderer.output,
252        diagnostics: renderer.diagnostics,
253        sensitive: renderer.sensitive,
254    }
255}
256
257struct Renderer {
258    output: String,
259    diagnostics: Vec<Diagnostic>,
260    sensitive: bool,
261    formatting: CanonicalFormatting,
262}
263
264impl Renderer {
265    fn write_project(&mut self, project: &MergedProject, selection: Option<&ProfileSelection>) {
266        if self.formatting.document_marker {
267            self.output.push_str("---\n");
268        }
269        let Some(entries) = project.root().as_mapping() else {
270            self.write_inline(project.root());
271            self.output.push('\n');
272            return;
273        };
274        if entries.is_empty() {
275            self.output.push_str("{}\n");
276            return;
277        }
278        for entry in entries {
279            if entry.key() == "services" && selection.is_some() {
280                self.write_selected_services(entry, selection);
281            } else {
282                self.write_entry(entry, 0);
283            }
284        }
285    }
286
287    fn write_selected_services(&mut self, entry: &MergedEntry, selection: Option<&ProfileSelection>) {
288        let Some(services) = entry.value().as_mapping() else {
289            self.write_entry(entry, 0);
290            return;
291        };
292        self.write_indent(0);
293        write_quoted(&mut self.output, entry.key());
294        self.output.push(':');
295        let active: Vec<_> = services
296            .iter()
297            .filter(|service| selection.is_none_or(|selection| selection.is_active(service.key())))
298            .collect();
299        if active.is_empty() {
300            self.output.push_str(" {}\n");
301            return;
302        }
303        self.output.push('\n');
304        for service in active {
305            self.write_entry(service, self.indent_width());
306        }
307    }
308
309    fn write_entry(&mut self, entry: &MergedEntry, indent: usize) {
310        self.write_indent(indent);
311        write_quoted(&mut self.output, entry.key());
312        self.output.push(':');
313        self.write_after_indicator(entry.value(), indent + self.indent_width());
314    }
315
316    fn write_sequence_item(&mut self, value: &MergedValue, indent: usize) {
317        self.write_indent(indent);
318        self.output.push('-');
319        self.write_after_indicator(value, indent + self.indent_width());
320    }
321
322    fn write_after_indicator(&mut self, value: &MergedValue, nested_indent: usize) {
323        self.sensitive |= value.is_sensitive();
324        let core = self.write_tag_prefixes(value);
325        if non_empty_collection(core) {
326            self.output.push('\n');
327            self.write_block(core, nested_indent);
328        } else {
329            self.output.push(' ');
330            self.write_inline(core);
331            self.output.push('\n');
332        }
333    }
334
335    fn write_tag_prefixes<'a>(&mut self, mut value: &'a MergedValue) -> &'a MergedValue {
336        while let MergedValueKind::Tagged { tag, value: inner } = value.kind() {
337            if valid_tag(tag) {
338                self.output.push(' ');
339                self.output.push_str(tag);
340            } else {
341                self.diagnostics.push(
342                    Diagnostic::new(
343                        UNRENDERABLE_TAG,
344                        Severity::Error,
345                        "retained YAML tag cannot be emitted canonically",
346                    )
347                    .with_label(DiagnosticLabel::primary(
348                        effective_span(value),
349                        "invalid canonical tag token",
350                    )),
351                );
352            }
353            value = inner;
354        }
355        value
356    }
357
358    fn write_block(&mut self, value: &MergedValue, indent: usize) {
359        match value.kind() {
360            MergedValueKind::Mapping(entries) => {
361                for entry in entries {
362                    self.write_entry(entry, indent);
363                }
364            }
365            MergedValueKind::Sequence(values) => {
366                for value in values {
367                    self.write_sequence_item(value, indent);
368                }
369            }
370            MergedValueKind::Tagged { .. } => {
371                self.write_indent(indent);
372                self.write_after_indicator(value, indent + self.indent_width());
373            }
374            MergedValueKind::Null(_) | MergedValueKind::Scalar(_) | MergedValueKind::Alias(_) => {
375                self.write_indent(indent);
376                self.write_inline(value);
377                self.output.push('\n');
378            }
379        }
380    }
381
382    fn write_inline(&mut self, value: &MergedValue) {
383        match value.kind() {
384            MergedValueKind::Null(_) => self.output.push_str("null"),
385            MergedValueKind::Scalar(scalar) => self.write_scalar(scalar),
386            MergedValueKind::Mapping(entries) if entries.is_empty() => self.output.push_str("{}"),
387            MergedValueKind::Sequence(values) if values.is_empty() => self.output.push_str("[]"),
388            MergedValueKind::Alias(_) => {
389                self.diagnostics.push(
390                    Diagnostic::new(
391                        UNRENDERABLE_ALIAS,
392                        Severity::Error,
393                        "unresolved YAML alias cannot be emitted in a standalone canonical document",
394                    )
395                    .with_label(DiagnosticLabel::primary(
396                        effective_span(value),
397                        "alias has no resolved canonical value",
398                    )),
399                );
400                self.output.push_str("null");
401            }
402            MergedValueKind::Tagged { .. } => {
403                let core = self.write_tag_prefixes(value);
404                self.output.push(' ');
405                self.write_inline(core);
406            }
407            MergedValueKind::Mapping(_) | MergedValueKind::Sequence(_) => {}
408        }
409    }
410
411    fn write_scalar(&mut self, scalar: &MergedScalar) {
412        self.sensitive |= scalar.is_sensitive();
413        match scalar.kind() {
414            MergedScalarKind::Boolean if scalar.value().eq_ignore_ascii_case("true") => {
415                self.output.push_str("true");
416            }
417            MergedScalarKind::Boolean if scalar.value().eq_ignore_ascii_case("false") => {
418                self.output.push_str("false");
419            }
420            MergedScalarKind::String | MergedScalarKind::Boolean => {
421                write_quoted(&mut self.output, scalar.value());
422            }
423            MergedScalarKind::Number => self.output.push_str(scalar.value()),
424        }
425    }
426
427    fn write_indent(&mut self, indent: usize) {
428        self.output.extend(std::iter::repeat_n(' ', indent));
429    }
430
431    fn indent_width(&self) -> usize {
432        usize::from(self.formatting.indent_width.spaces())
433    }
434
435    fn finish_formatting(&mut self) {
436        if !self.formatting.final_newline && self.output.ends_with('\n') {
437            let _ = self.output.pop();
438        }
439        if self.formatting.line_ending == LineEnding::CrLf {
440            self.output = self.output.replace('\n', "\r\n");
441        }
442    }
443}
444
445fn non_empty_collection(value: &MergedValue) -> bool {
446    match value.kind() {
447        MergedValueKind::Mapping(entries) => !entries.is_empty(),
448        MergedValueKind::Sequence(values) => !values.is_empty(),
449        _ => false,
450    }
451}
452
453fn valid_tag(tag: &str) -> bool {
454    if let Some(verbatim) = tag.strip_prefix("!<").and_then(|tag| tag.strip_suffix('>')) {
455        return !verbatim.is_empty()
456            && verbatim
457                .bytes()
458                .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'<' | b'>'));
459    }
460    tag.starts_with('!')
461        && tag.len() > 1
462        && tag
463            .bytes()
464            .skip(1)
465            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'!' | b'_' | b'-' | b'.' | b':' | b'/'))
466}
467
468fn write_quoted(output: &mut String, value: &str) {
469    output.push('"');
470    for character in value.chars() {
471        match character {
472            '"' => output.push_str("\\\""),
473            '\\' => output.push_str("\\\\"),
474            '\u{08}' => output.push_str("\\b"),
475            '\t' => output.push_str("\\t"),
476            '\n' => output.push_str("\\n"),
477            '\u{0c}' => output.push_str("\\f"),
478            '\r' => output.push_str("\\r"),
479            character
480                if character.is_control() || matches!(character, '\u{85}' | '\u{2028}' | '\u{2029}' | '\u{feff}') =>
481            {
482                push_unicode_escape(output, character);
483            }
484            character => output.push(character),
485        }
486    }
487    output.push('"');
488}
489
490fn push_unicode_escape(output: &mut String, character: char) {
491    const HEX: &[u8; 16] = b"0123456789ABCDEF";
492    let value = character as u32;
493    if value <= 0xffff {
494        output.push_str("\\u");
495        for shift in [12, 8, 4, 0] {
496            output.push(HEX[((value >> shift) & 0xf) as usize] as char);
497        }
498    } else {
499        output.push_str("\\U");
500        for shift in [28, 24, 20, 16, 12, 8, 4, 0] {
501            output.push(HEX[((value >> shift) & 0xf) as usize] as char);
502        }
503    }
504}