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