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