Skip to main content

systemprompt_cloud/deploy/provider/
values_file.rs

1//! In-place scalar update of a YAML values file.
2//!
3//! Ops repositories are reviewed as text, so the digest rewrite edits one
4//! line and leaves every other byte — comments, ordering, quoting style —
5//! as authored. It understands block mappings only: a path through a
6//! sequence or a flow mapping is refused rather than guessed at. A missing
7//! key is created at the end of its parent mapping.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12#[derive(Debug, thiserror::Error, PartialEq, Eq)]
13pub enum ValuesFileError {
14    #[error("the key path is empty or has an empty segment")]
15    EmptyPath,
16    #[error("'{path}' holds a sequence, not a mapping")]
17    Sequence { path: String },
18    #[error("'{path}' is a mapping, not a scalar")]
19    Mapping { path: String },
20    #[error("'{path}' is a scalar, not a mapping")]
21    Scalar { path: String },
22}
23
24struct KeyLine<'a> {
25    raw_key: &'a str,
26    key: &'a str,
27    rest: &'a str,
28}
29
30fn indent_of(line: &str) -> usize {
31    line.len() - line.trim_start_matches(' ').len()
32}
33
34fn is_content(line: &str) -> bool {
35    let trimmed = line.trim();
36    !trimmed.is_empty() && !trimmed.starts_with('#')
37}
38
39fn key_line(line: &str) -> Option<KeyLine<'_>> {
40    let body = line.trim_start_matches(' ');
41    if body.starts_with('-') || body.starts_with('{') || body.starts_with('[') {
42        return None;
43    }
44    let colon = body
45        .char_indices()
46        .find(|&(i, c)| c == ':' && body[i + 1..].chars().next().is_none_or(|n| n == ' '))
47        .map(|(i, _)| i)?;
48    let raw_key = &body[..colon];
49    let key = raw_key
50        .strip_prefix('"')
51        .and_then(|k| k.strip_suffix('"'))
52        .or_else(|| {
53            raw_key
54                .strip_prefix('\'')
55                .and_then(|k| k.strip_suffix('\''))
56        })
57        .unwrap_or(raw_key);
58    Some(KeyLine {
59        raw_key,
60        key,
61        rest: body[colon + 1..].trim(),
62    })
63}
64
65fn trailing_comment(rest: &str) -> &str {
66    if rest.starts_with('#') {
67        return rest;
68    }
69    rest.find(" #").map_or("", |pos| &rest[pos..])
70}
71
72fn holds_value(rest: &str) -> bool {
73    !rest.is_empty() && !rest.starts_with('#')
74}
75
76fn quote(value: &str) -> String {
77    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
78}
79
80fn block_end(lines: &[&str], from: usize, owner_indent: usize) -> usize {
81    (from..lines.len())
82        .find(|&i| is_content(lines[i]) && indent_of(lines[i]) <= owner_indent)
83        .unwrap_or(lines.len())
84}
85
86struct Block {
87    start: usize,
88    end: usize,
89    owner_indent: Option<usize>,
90}
91
92pub fn set_yaml_scalar(
93    content: &str,
94    key_path: &[&str],
95    value: &str,
96) -> Result<String, ValuesFileError> {
97    if key_path.is_empty() || key_path.iter().any(|segment| segment.is_empty()) {
98        return Err(ValuesFileError::EmptyPath);
99    }
100    let view: Vec<&str> = content.lines().collect();
101    let mut lines: Vec<String> = view.iter().map(|line| (*line).to_owned()).collect();
102    let mut block = Block {
103        start: 0,
104        end: view.len(),
105        owner_indent: None,
106    };
107
108    for (depth, segment) in key_path.iter().enumerate() {
109        let child_indent = first_child_indent(&view, &block, &key_path[..depth])?;
110        let Some(index) = find_key(&view, &block, child_indent, segment) else {
111            let base =
112                child_indent.unwrap_or_else(|| block.owner_indent.map_or(0, |owner| owner + 2));
113            let insert_at = insert_point(&view, &block);
114            insert_path(&mut lines, insert_at, base, &key_path[depth..], value);
115            return Ok(render(&lines, content));
116        };
117
118        let line_indent = indent_of(view[index]);
119        let rest = key_line(view[index]).map_or("", |parsed| parsed.rest);
120        let child_end = block_end(&view, index + 1, line_indent);
121
122        if depth + 1 == key_path.len() {
123            let has_children = view[index + 1..child_end].iter().any(|l| is_content(l));
124            if has_children && !holds_value(rest) {
125                return Err(ValuesFileError::Mapping {
126                    path: key_path.join("."),
127                });
128            }
129            lines[index] = replaced_line(view[index], value);
130            return Ok(render(&lines, content));
131        }
132
133        if holds_value(rest) {
134            return Err(ValuesFileError::Scalar {
135                path: key_path[..=depth].join("."),
136            });
137        }
138        block = Block {
139            start: index + 1,
140            end: child_end,
141            owner_indent: Some(line_indent),
142        };
143    }
144    Err(ValuesFileError::EmptyPath)
145}
146
147fn first_child_indent(
148    view: &[&str],
149    block: &Block,
150    parent_path: &[&str],
151) -> Result<Option<usize>, ValuesFileError> {
152    let Some(first) = view[block.start..block.end]
153        .iter()
154        .find(|line| is_content(line))
155    else {
156        return Ok(None);
157    };
158    if first.trim_start().starts_with('-') {
159        return Err(ValuesFileError::Sequence {
160            path: parent_path.join("."),
161        });
162    }
163    Ok(Some(indent_of(first)))
164}
165
166fn find_key(
167    view: &[&str],
168    block: &Block,
169    child_indent: Option<usize>,
170    segment: &str,
171) -> Option<usize> {
172    (block.start..block.end).find(|&i| {
173        is_content(view[i])
174            && Some(indent_of(view[i])) == child_indent
175            && key_line(view[i]).is_some_and(|k| k.key == segment)
176    })
177}
178
179fn insert_point(view: &[&str], block: &Block) -> usize {
180    (block.start..block.end)
181        .rev()
182        .find(|&i| is_content(view[i]))
183        .map_or(block.start, |i| i + 1)
184}
185
186fn insert_path(lines: &mut Vec<String>, insert_at: usize, base: usize, keys: &[&str], value: &str) {
187    for (offset, key) in keys.iter().enumerate() {
188        let pad = " ".repeat(base + offset * 2);
189        let line = if offset + 1 == keys.len() {
190            format!("{pad}{key}: {}", quote(value))
191        } else {
192            format!("{pad}{key}:")
193        };
194        lines.insert(insert_at + offset, line);
195    }
196}
197
198fn replaced_line(line: &str, value: &str) -> String {
199    let indent = indent_of(line);
200    let Some(parsed) = key_line(line) else {
201        return line.to_owned();
202    };
203    let comment = trailing_comment(parsed.rest);
204    let separator = if comment.is_empty() || comment.starts_with(' ') {
205        ""
206    } else {
207        " "
208    };
209    format!(
210        "{}{}: {}{separator}{comment}",
211        " ".repeat(indent),
212        parsed.raw_key,
213        quote(value)
214    )
215}
216
217fn render(lines: &[String], original: &str) -> String {
218    let mut out = lines.join("\n");
219    if original.ends_with('\n') || original.is_empty() {
220        out.push('\n');
221    }
222    out
223}