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;
30use yaml_edit::{ScalarStyle, ScalarType, ScalarValue, YamlFile};
31
32/// An unresolved alias cannot be represented in a standalone canonical document.
33pub const UNRENDERABLE_ALIAS: DiagnosticCode = DiagnosticCode::new("compose.render.unresolved-alias");
34
35/// A retained YAML tag is not safe to emit as a canonical tag token.
36pub const UNRENDERABLE_TAG: DiagnosticCode = DiagnosticCode::new("compose.render.invalid-tag");
37
38/// A valid number of spaces used for one canonical YAML indentation level.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct IndentWidth(u8);
41
42impl IndentWidth {
43    /// The smallest supported indentation width.
44    pub const MIN: u8 = 1;
45
46    /// The fixed width used by canonical-v2 output.
47    pub const CANONICAL: Self = Self(2);
48
49    /// Creates a validated indentation width.
50    #[must_use]
51    pub const fn new(spaces: u8) -> Option<Self> {
52        if spaces >= Self::MIN { Some(Self(spaces)) } else { None }
53    }
54
55    /// Returns the number of spaces in one indentation level.
56    #[must_use]
57    pub const fn spaces(self) -> u8 {
58        self.0
59    }
60}
61
62/// A presentation-only line-ending choice for rendered YAML.
63#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum LineEnding {
65    /// A single line-feed byte.
66    #[default]
67    Lf,
68    /// A carriage return followed by a line feed.
69    CrLf,
70}
71
72/// Presentation-only options for deterministic merged-project rendering.
73///
74/// These options cannot interpolate, merge, select profiles, apply defaults, reorder mappings,
75/// or change retained Compose short/long forms. [`Self::default`] is the fixed canonical-v2 format.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub struct CanonicalFormatting {
78    indent_width: IndentWidth,
79    line_ending: LineEnding,
80    document_marker: bool,
81    final_newline: bool,
82}
83
84impl CanonicalFormatting {
85    /// Returns the number of spaces used for each nested YAML level.
86    #[must_use]
87    pub const fn indent_width(self) -> IndentWidth {
88        self.indent_width
89    }
90
91    /// Returns the selected line-ending convention.
92    #[must_use]
93    pub const fn line_ending(self) -> LineEnding {
94        self.line_ending
95    }
96
97    /// Reports whether output starts with a YAML document marker.
98    #[must_use]
99    pub const fn document_marker(self) -> bool {
100        self.document_marker
101    }
102
103    /// Reports whether non-empty output ends with the selected line ending.
104    #[must_use]
105    pub const fn final_newline(self) -> bool {
106        self.final_newline
107    }
108
109    /// Returns options with a different validated indentation width.
110    #[must_use]
111    pub const fn with_indent_width(mut self, indent_width: IndentWidth) -> Self {
112        self.indent_width = indent_width;
113        self
114    }
115
116    /// Returns options with a different line-ending convention.
117    #[must_use]
118    pub const fn with_line_ending(mut self, line_ending: LineEnding) -> Self {
119        self.line_ending = line_ending;
120        self
121    }
122
123    /// Returns options with YAML document-marker emission enabled or disabled.
124    #[must_use]
125    pub const fn with_document_marker(mut self, document_marker: bool) -> Self {
126        self.document_marker = document_marker;
127        self
128    }
129
130    /// Returns options with a final line ending enabled or disabled.
131    #[must_use]
132    pub const fn with_final_newline(mut self, final_newline: bool) -> Self {
133        self.final_newline = final_newline;
134        self
135    }
136}
137
138impl Default for CanonicalFormatting {
139    fn default() -> Self {
140        Self {
141            indent_width: IndentWidth::CANONICAL,
142            line_ending: LineEnding::Lf,
143            document_marker: true,
144            final_newline: true,
145        }
146    }
147}
148
149/// The result of deterministic canonical rendering.
150///
151/// The output can contain interpolated secrets and is therefore available only through explicit
152/// accessors. Its `Debug` representation redacts the complete output when any rendered value is
153/// sensitive.
154#[derive(Clone, PartialEq, Eq)]
155pub struct CanonicalRender {
156    output: String,
157    diagnostics: Vec<Diagnostic>,
158    sensitive: bool,
159}
160
161impl CanonicalRender {
162    /// Returns the rendered UTF-8 YAML document.
163    #[must_use]
164    pub fn output(&self) -> &str {
165        &self.output
166    }
167
168    /// Consumes the result and returns the rendered document.
169    #[must_use]
170    pub fn into_output(self) -> String {
171        self.output
172    }
173
174    /// Returns canonical-rendering diagnostics.
175    #[must_use]
176    pub fn diagnostics(&self) -> &[Diagnostic] {
177        &self.diagnostics
178    }
179
180    /// Reports whether the renderer emitted no error diagnostics.
181    #[must_use]
182    pub fn is_valid(&self) -> bool {
183        self.diagnostics
184            .iter()
185            .all(|diagnostic| diagnostic.severity() != Severity::Error)
186    }
187
188    /// Reports whether any rendered value contains sensitive interpolation output.
189    #[must_use]
190    pub const fn is_sensitive(&self) -> bool {
191        self.sensitive
192    }
193}
194
195impl fmt::Debug for CanonicalRender {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        formatter
198            .debug_struct("CanonicalRender")
199            .field(
200                "output",
201                &if self.sensitive {
202                    "<redacted>"
203                } else {
204                    self.output.as_str()
205                },
206            )
207            .field("diagnostics", &self.diagnostics)
208            .field("sensitive", &self.sensitive)
209            .finish()
210    }
211}
212
213/// Renders a merged project as deterministic `ComposeLens` canonical YAML.
214///
215/// The renderer preserves merged mapping/sequence order and retained short/long forms. It does not
216/// interpolate, resolve paths, apply defaults, normalize syntax variants, or invoke a Compose
217/// implementation. When a matching selection is supplied, profile-inactive services are omitted
218/// while top-level resources remain available.
219#[must_use]
220pub fn render_canonical(project: &MergedProject, selection: Option<&ProfileSelection>) -> CanonicalRender {
221    render_canonical_with_formatting(project, selection, &CanonicalFormatting::default())
222}
223
224/// Renders a merged project with explicit presentation-only formatting choices.
225///
226/// Semantic processing remains identical to [`render_canonical`]. The default formatting value
227/// produces byte-identical canonical-v2 output.
228#[must_use]
229pub fn render_canonical_with_formatting(
230    project: &MergedProject,
231    selection: Option<&ProfileSelection>,
232    formatting: &CanonicalFormatting,
233) -> CanonicalRender {
234    let mut diagnostics = Vec::new();
235    if !selection_matches(project, selection, &mut diagnostics) {
236        return CanonicalRender {
237            output: String::new(),
238            diagnostics,
239            sensitive: false,
240        };
241    }
242
243    let mut renderer = Renderer {
244        output: String::new(),
245        diagnostics,
246        sensitive: false,
247        formatting: *formatting,
248    };
249    renderer.write_project(project, selection);
250    renderer.finish_formatting();
251    CanonicalRender {
252        output: renderer.output,
253        diagnostics: renderer.diagnostics,
254        sensitive: renderer.sensitive,
255    }
256}
257
258struct Renderer {
259    output: String,
260    diagnostics: Vec<Diagnostic>,
261    sensitive: bool,
262    formatting: CanonicalFormatting,
263}
264
265impl Renderer {
266    fn write_project(&mut self, project: &MergedProject, selection: Option<&ProfileSelection>) {
267        if self.formatting.document_marker {
268            self.output.push_str("---\n");
269        }
270        let Some(entries) = project.root().as_mapping() else {
271            self.write_inline(project.root());
272            self.output.push('\n');
273            return;
274        };
275        if entries.is_empty() {
276            self.output.push_str("{}\n");
277            return;
278        }
279        for entry in entries {
280            if entry.key() == "services" && selection.is_some() {
281                self.write_selected_services(entry, selection);
282            } else {
283                self.write_entry(entry, 0);
284            }
285        }
286    }
287
288    fn write_selected_services(&mut self, entry: &MergedEntry, selection: Option<&ProfileSelection>) {
289        let Some(services) = entry.value().as_mapping() else {
290            self.write_entry(entry, 0);
291            return;
292        };
293        self.write_indent(0);
294        write_quoted(&mut self.output, entry.key());
295        self.output.push(':');
296        let active: Vec<_> = services
297            .iter()
298            .filter(|service| selection.is_none_or(|selection| selection.is_active(service.key())))
299            .collect();
300        if active.is_empty() {
301            self.output.push_str(" {}\n");
302            return;
303        }
304        self.output.push('\n');
305        for service in active {
306            self.write_entry(service, self.indent_width());
307        }
308    }
309
310    fn write_entry(&mut self, entry: &MergedEntry, indent: usize) {
311        self.write_indent(indent);
312        write_quoted(&mut self.output, entry.key());
313        self.output.push(':');
314        self.write_after_indicator(entry.value(), indent + self.indent_width());
315    }
316
317    fn write_sequence_item(&mut self, value: &MergedValue, indent: usize) {
318        self.write_indent(indent);
319        self.output.push('-');
320        self.sensitive |= value.is_sensitive();
321        let core = self.write_tag_prefixes(value);
322        if let MergedValueKind::Mapping(entries) = core.kind() {
323            if let Some((first, remaining)) = entries.split_first() {
324                self.output.push(' ');
325                write_quoted(&mut self.output, first.key());
326                self.output.push(':');
327                self.write_after_indicator(first.value(), indent + (self.indent_width() * 2));
328                for entry in remaining {
329                    self.write_entry(entry, indent + self.indent_width());
330                }
331                return;
332            }
333        }
334        self.write_after_indicator(value, indent + self.indent_width());
335    }
336
337    fn write_after_indicator(&mut self, value: &MergedValue, nested_indent: usize) {
338        self.sensitive |= value.is_sensitive();
339        let core = self.write_tag_prefixes(value);
340        if non_empty_collection(core) {
341            self.output.push('\n');
342            self.write_block(core, nested_indent);
343        } else {
344            self.output.push(' ');
345            self.write_inline(core);
346            self.output.push('\n');
347        }
348    }
349
350    fn write_tag_prefixes<'a>(&mut self, mut value: &'a MergedValue) -> &'a MergedValue {
351        while let MergedValueKind::Tagged { tag, value: inner } = value.kind() {
352            if valid_tag(tag) {
353                self.output.push(' ');
354                self.output.push_str(tag);
355            } else {
356                self.diagnostics.push(
357                    Diagnostic::new(
358                        UNRENDERABLE_TAG,
359                        Severity::Error,
360                        "retained YAML tag cannot be emitted canonically",
361                    )
362                    .with_label(DiagnosticLabel::primary(
363                        effective_span(value),
364                        "invalid canonical tag token",
365                    )),
366                );
367            }
368            value = inner;
369        }
370        value
371    }
372
373    fn write_block(&mut self, value: &MergedValue, indent: usize) {
374        match value.kind() {
375            MergedValueKind::Mapping(entries) => {
376                for entry in entries {
377                    self.write_entry(entry, indent);
378                }
379            }
380            MergedValueKind::Sequence(values) => {
381                for value in values {
382                    self.write_sequence_item(value, indent);
383                }
384            }
385            MergedValueKind::Tagged { .. } => {
386                self.write_indent(indent);
387                self.write_after_indicator(value, indent + self.indent_width());
388            }
389            MergedValueKind::Null(_) | MergedValueKind::Scalar(_) | MergedValueKind::Alias(_) => {
390                self.write_indent(indent);
391                self.write_inline(value);
392                self.output.push('\n');
393            }
394        }
395    }
396
397    fn write_inline(&mut self, value: &MergedValue) {
398        match value.kind() {
399            MergedValueKind::Null(_) => self.output.push_str("null"),
400            MergedValueKind::Scalar(scalar) => self.write_scalar(scalar),
401            MergedValueKind::Mapping(entries) if entries.is_empty() => self.output.push_str("{}"),
402            MergedValueKind::Sequence(values) if values.is_empty() => self.output.push_str("[]"),
403            MergedValueKind::Alias(_) => {
404                self.diagnostics.push(
405                    Diagnostic::new(
406                        UNRENDERABLE_ALIAS,
407                        Severity::Error,
408                        "unresolved YAML alias cannot be emitted in a standalone canonical document",
409                    )
410                    .with_label(DiagnosticLabel::primary(
411                        effective_span(value),
412                        "alias has no resolved canonical value",
413                    )),
414                );
415                self.output.push_str("null");
416            }
417            MergedValueKind::Tagged { .. } => {
418                let core = self.write_tag_prefixes(value);
419                self.output.push(' ');
420                self.write_inline(core);
421            }
422            MergedValueKind::Mapping(_) | MergedValueKind::Sequence(_) => {}
423        }
424    }
425
426    fn write_scalar(&mut self, scalar: &MergedScalar) {
427        self.sensitive |= scalar.is_sensitive();
428        match scalar.kind() {
429            MergedScalarKind::Boolean if scalar.value().eq_ignore_ascii_case("true") => {
430                self.output.push_str("true");
431            }
432            MergedScalarKind::Boolean if scalar.value().eq_ignore_ascii_case("false") => {
433                self.output.push_str("false");
434            }
435            MergedScalarKind::String | MergedScalarKind::Boolean => {
436                write_quoted(&mut self.output, scalar.value());
437            }
438            MergedScalarKind::Number => self.output.push_str(scalar.value()),
439        }
440    }
441
442    fn write_indent(&mut self, indent: usize) {
443        self.output.extend(std::iter::repeat_n(' ', indent));
444    }
445
446    fn indent_width(&self) -> usize {
447        usize::from(self.formatting.indent_width.spaces())
448    }
449
450    fn finish_formatting(&mut self) {
451        if !self.formatting.final_newline && self.output.ends_with('\n') {
452            let _ = self.output.pop();
453        }
454        if self.formatting.line_ending == LineEnding::CrLf {
455            self.output = self.output.replace('\n', "\r\n");
456        }
457    }
458}
459
460fn non_empty_collection(value: &MergedValue) -> bool {
461    match value.kind() {
462        MergedValueKind::Mapping(entries) => !entries.is_empty(),
463        MergedValueKind::Sequence(values) => !values.is_empty(),
464        _ => false,
465    }
466}
467
468fn valid_tag(tag: &str) -> bool {
469    if let Some(verbatim) = tag.strip_prefix("!<").and_then(|tag| tag.strip_suffix('>')) {
470        return !verbatim.is_empty()
471            && verbatim
472                .bytes()
473                .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'<' | b'>'));
474    }
475    tag.starts_with('!')
476        && tag.len() > 1
477        && tag
478            .bytes()
479            .skip(1)
480            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'!' | b'_' | b'-' | b'.' | b':' | b'/'))
481}
482
483/// Writes a YAML string with the least quoting needed to keep it a string.
484///
485/// A plain candidate must round-trip through the private YAML parser as one complete plain string.
486/// YAML 1.1's boolean and null spellings are additionally quoted even though the parser uses newer
487/// scalar rules: generated Compose must remain a string when read by older YAML consumers.
488fn write_quoted(output: &mut String, value: &str) {
489    if plain_string_safe(value) {
490        output.push_str(value);
491        return;
492    }
493
494    write_double_quoted(output, value);
495}
496
497fn plain_string_safe(value: &str) -> bool {
498    if value.is_empty() || value.trim() != value || value.contains(['\n', '\r']) || yaml_1_1_ambiguous_string(value) {
499        return false;
500    }
501
502    let parse = YamlFile::parse(value);
503    if !parse.ok() {
504        return false;
505    }
506    let file = parse.tree();
507    let Some(document) = file.document() else {
508        return false;
509    };
510    let Some(scalar) = document.as_scalar() else {
511        return false;
512    };
513    let range = scalar.byte_range();
514    range.start == 0
515        && range.end as usize == value.len()
516        && ScalarValue::from_scalar(&scalar).style() == ScalarStyle::Plain
517        && ScalarValue::from_scalar(&scalar).scalar_type() == ScalarType::String
518}
519
520fn yaml_1_1_ambiguous_string(value: &str) -> bool {
521    matches!(
522        value,
523        "y" | "Y"
524            | "yes"
525            | "Yes"
526            | "YES"
527            | "n"
528            | "N"
529            | "no"
530            | "No"
531            | "NO"
532            | "true"
533            | "True"
534            | "TRUE"
535            | "false"
536            | "False"
537            | "FALSE"
538            | "on"
539            | "On"
540            | "ON"
541            | "off"
542            | "Off"
543            | "OFF"
544            | "null"
545            | "Null"
546            | "NULL"
547            | "~"
548    ) || yaml_1_1_sexagesimal(value)
549        || yaml_1_1_special_float(value)
550        || yaml_1_1_timestamp(value)
551}
552
553fn yaml_1_1_sexagesimal(value: &str) -> bool {
554    let mut components = value.split(':');
555    let Some(first) = components.next() else {
556        return false;
557    };
558    if components.clone().next().is_none() || first.is_empty() || !first.bytes().all(|byte| byte.is_ascii_digit()) {
559        return false;
560    }
561    components.all(|component| !component.is_empty() && component.bytes().all(|byte| byte.is_ascii_digit()))
562}
563
564fn yaml_1_1_special_float(value: &str) -> bool {
565    matches!(value.to_ascii_lowercase().as_str(), ".inf" | "+.inf" | "-.inf" | ".nan")
566}
567
568fn yaml_1_1_timestamp(value: &str) -> bool {
569    let date_end = value.find(['T', 't', ' ']).unwrap_or(value.len());
570    let date = &value[..date_end];
571    let mut components = date.split('-');
572    let (Some(year), Some(month), Some(day), None) = (
573        components.next(),
574        components.next(),
575        components.next(),
576        components.next(),
577    ) else {
578        return false;
579    };
580
581    year.len() == 4
582        && (1..=2).contains(&month.len())
583        && (1..=2).contains(&day.len())
584        && year.bytes().all(|byte| byte.is_ascii_digit())
585        && month.bytes().all(|byte| byte.is_ascii_digit())
586        && day.bytes().all(|byte| byte.is_ascii_digit())
587}
588
589fn write_double_quoted(output: &mut String, value: &str) {
590    output.push('"');
591    for character in value.chars() {
592        match character {
593            '"' => output.push_str("\\\""),
594            '\\' => output.push_str("\\\\"),
595            '\u{08}' => output.push_str("\\b"),
596            '\t' => output.push_str("\\t"),
597            '\n' => output.push_str("\\n"),
598            '\u{0c}' => output.push_str("\\f"),
599            '\r' => output.push_str("\\r"),
600            character
601                if character.is_control() || matches!(character, '\u{85}' | '\u{2028}' | '\u{2029}' | '\u{feff}') =>
602            {
603                push_unicode_escape(output, character);
604            }
605            character => output.push(character),
606        }
607    }
608    output.push('"');
609}
610
611fn push_unicode_escape(output: &mut String, character: char) {
612    const HEX: &[u8; 16] = b"0123456789ABCDEF";
613    let value = character as u32;
614    if value <= 0xffff {
615        output.push_str("\\u");
616        for shift in [12, 8, 4, 0] {
617            output.push(HEX[((value >> shift) & 0xf) as usize] as char);
618        }
619    } else {
620        output.push_str("\\U");
621        for shift in [28, 24, 20, 16, 12, 8, 4, 0] {
622            output.push(HEX[((value >> shift) & 0xf) as usize] as char);
623        }
624    }
625}