1use std::collections::BTreeSet;
22use std::fmt;
23use std::ops::Range;
24
25use serde::Serialize;
26use toml_edit::{DocumentMut, InlineTable, Item, Table, TableLike, Value};
27
28const FILE_NAME: &str = "keel.toml";
30
31const CONTEXT: usize = 3;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PolicyPath(Vec<String>);
41
42impl PolicyPath {
43 pub fn new<I, S>(segments: I) -> Self
45 where
46 I: IntoIterator<Item = S>,
47 S: Into<String>,
48 {
49 Self(segments.into_iter().map(Into::into).collect())
50 }
51
52 #[must_use]
54 pub fn segments(&self) -> &[String] {
55 &self.0
56 }
57}
58
59impl fmt::Display for PolicyPath {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 for (i, seg) in self.0.iter().enumerate() {
62 if i > 0 {
63 f.write_str(".")?;
64 }
65 f.write_str("e_segment(seg))?;
66 }
67 Ok(())
68 }
69}
70
71fn quote_segment(seg: &str) -> String {
73 let bare = !seg.is_empty()
74 && seg
75 .chars()
76 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
77 if bare {
78 seg.to_owned()
79 } else {
80 format!("\"{}\"", seg.replace('\\', "\\\\").replace('"', "\\\""))
81 }
82}
83
84#[derive(Debug, Clone)]
90pub enum PolicyOp {
91 AppendBlock {
94 text: String,
96 },
97 Remove {
100 path: PolicyPath,
102 },
103 Set {
107 path: PolicyPath,
109 value: Value,
111 },
112}
113
114#[derive(Debug)]
116pub enum DiffError {
117 CurrentInvalid(String),
119 PathConflict {
121 path: String,
123 detail: String,
125 },
126 ResultInvalid(String),
129}
130
131impl fmt::Display for DiffError {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 Self::CurrentInvalid(e) => write!(f, "keel.toml is not valid TOML: {e}"),
135 Self::PathConflict { path, detail } => {
136 write!(f, "cannot edit `{path}`: {detail}")
137 }
138 Self::ResultInvalid(e) => {
139 write!(f, "the proposed keel.toml would not parse: {e}")
140 }
141 }
142 }
143}
144
145impl std::error::Error for DiffError {}
146
147#[derive(Debug, Clone, Serialize)]
150pub struct ChangeHunk {
151 pub after: Option<serde_json::Value>,
153 pub before: Option<serde_json::Value>,
155 pub path: String,
157}
158
159#[derive(Debug, Clone, Serialize)]
163pub struct Proposal {
164 pub changes: Vec<ChangeHunk>,
166 #[serde(skip)]
169 pub new_text: String,
170 pub patch: String,
173}
174
175pub fn propose(current: Option<&str>, ops: &[PolicyOp]) -> Result<Proposal, DiffError> {
181 let old_text = current.unwrap_or("");
182 let mut doc: DocumentMut = old_text
183 .parse()
184 .map_err(|e: toml_edit::TomlError| DiffError::CurrentInvalid(e.to_string()))?;
185
186 for op in ops {
187 match op {
188 PolicyOp::Set { path, value } => set_at(&mut doc, path, value.clone())?,
189 PolicyOp::Remove { path } => remove_at(&mut doc, path),
190 PolicyOp::AppendBlock { .. } => {}
191 }
192 }
193 let mut new_text = doc.to_string();
194 for op in ops {
195 if let PolicyOp::AppendBlock { text } = op {
196 append_block(&mut new_text, text);
197 }
198 }
199 if let Err(e) = new_text.parse::<DocumentMut>() {
202 return Err(DiffError::ResultInvalid(e.to_string()));
203 }
204
205 let patch = if new_text == old_text {
206 String::new()
207 } else {
208 let old_label = if current.is_some() {
209 format!("a/{FILE_NAME}")
210 } else {
211 "/dev/null".to_owned()
212 };
213 unified_diff(&old_label, &format!("b/{FILE_NAME}"), old_text, &new_text)
214 };
215 let before = policy_json(old_text).map_err(DiffError::CurrentInvalid)?;
216 let after = policy_json(&new_text).map_err(DiffError::ResultInvalid)?;
217 Ok(Proposal {
218 changes: structural_changes(&before, &after),
219 new_text,
220 patch,
221 })
222}
223
224#[must_use]
231pub fn resolve_dotted_path(text: &str, dotted: &str) -> Option<PolicyPath> {
232 let value: toml::Value = text.parse().ok()?;
233 let segments: Vec<String> = dotted
234 .split('.')
235 .map(strip_index_suffix)
236 .filter(|s| !s.is_empty())
237 .collect();
238 if segments.is_empty() {
239 return None;
240 }
241 let mut resolved: Vec<String> = Vec::new();
242 let mut current = &value;
243 let mut rest = segments.as_slice();
244 while !rest.is_empty() {
245 let Some(table) = current.as_table() else {
246 break;
249 };
250 let matched = (1..=rest.len())
253 .rev()
254 .map(|n| rest[..n].join("."))
255 .find(|candidate| table.contains_key(candidate))?;
256 let consumed = matched.matches('.').count() + 1;
257 current = &table[&matched];
258 resolved.push(matched);
259 rest = &rest[consumed..];
260 }
261 Some(PolicyPath(resolved))
262}
263
264fn strip_index_suffix(seg: &str) -> String {
266 let mut out = seg;
267 while let Some(open) = out.rfind('[') {
268 if out.ends_with(']')
269 && out[open + 1..out.len() - 1]
270 .chars()
271 .all(|c| c.is_ascii_digit())
272 {
273 out = &out[..open];
274 } else {
275 break;
276 }
277 }
278 out.to_owned()
279}
280
281fn set_at(doc: &mut DocumentMut, path: &PolicyPath, value: Value) -> Result<(), DiffError> {
286 let Some((leaf, parents)) = path.segments().split_last() else {
287 return Err(DiffError::PathConflict {
288 path: String::new(),
289 detail: "an empty path names nothing".to_owned(),
290 });
291 };
292 let mut current: &mut dyn TableLike = doc.as_table_mut();
293 let mut parent_is_standard = true;
294 for (depth, seg) in parents.iter().enumerate() {
295 if current.get(seg).is_none() {
296 current.insert(seg, new_container(depth, parent_is_standard));
297 }
298 let item = current.get_mut(seg).expect("inserted above when missing");
299 parent_is_standard = item.is_table();
300 current = item
301 .as_table_like_mut()
302 .ok_or_else(|| DiffError::PathConflict {
303 path: path.to_string(),
304 detail: format!("`{seg}` already holds a plain value, not a table"),
305 })?;
306 }
307 let existing_decor = current
308 .get(leaf)
309 .and_then(Item::as_value)
310 .map(|v| v.decor().clone());
311 let mut value = if parent_is_standard || existing_decor.is_some() {
312 value.decorated(" ", "")
313 } else {
314 let last_key = current.iter().last().map(|(k, _)| k.to_owned());
317 if let Some(prev) = last_key
318 .and_then(|k| current.get_mut(&k))
319 .and_then(Item::as_value_mut)
320 && prev
321 .decor()
322 .suffix()
323 .and_then(toml_edit::RawString::as_str)
324 .is_some_and(|s| !s.is_empty() && s.chars().all(char::is_whitespace))
325 {
326 prev.decor_mut().set_suffix("");
327 }
328 value.decorated(" ", " ")
329 };
330 if let Some(decor) = existing_decor {
331 *value.decor_mut() = decor;
332 }
333 current.insert(leaf, Item::Value(value));
334 Ok(())
335}
336
337fn new_container(depth: usize, parent_is_standard: bool) -> Item {
342 if parent_is_standard && depth <= 1 {
343 let mut table = Table::new();
344 table.set_implicit(depth == 0);
345 Item::Table(table)
346 } else {
347 Item::Value(Value::InlineTable(InlineTable::new()).decorated(" ", ""))
348 }
349}
350
351fn remove_at(doc: &mut DocumentMut, path: &PolicyPath) {
357 let Some((leaf, parents)) = path.segments().split_last() else {
358 return;
359 };
360 let mut current: &mut dyn TableLike = doc.as_table_mut();
361 for seg in parents {
362 let Some(item) = current.get_mut(seg) else {
363 return;
364 };
365 let Some(table) = item.as_table_like_mut() else {
366 return;
367 };
368 current = table;
369 }
370 let salvage = match current.get(leaf) {
371 None => return,
372 Some(Item::Table(t)) => {
373 let prefix = t
374 .decor()
375 .prefix()
376 .and_then(toml_edit::RawString::as_str)
377 .unwrap_or("");
378 Some((detached_prefix(prefix), t.position()))
379 }
380 Some(_) => None,
381 };
382 current.remove(leaf);
383 if let Some((detached, position)) = salvage
384 && !detached.is_empty()
385 {
386 reattach_prefix(doc, position, &detached);
387 }
388}
389
390fn detached_prefix(prefix: &str) -> String {
396 let detached = prefix.rfind("\n\n").map_or("", |i| &prefix[..i + 2]);
397 if detached.trim().is_empty() {
398 String::new()
399 } else {
400 detached.to_owned()
401 }
402}
403
404fn reattach_prefix(doc: &mut DocumentMut, removed_position: Option<usize>, detached: &str) {
408 let successor = removed_position.and_then(|removed| {
409 let mut best: Option<(usize, Vec<String>)> = None;
410 collect_positioned_tables(doc.as_table(), &mut Vec::new(), &mut |pos, path| {
411 if pos > removed && best.as_ref().is_none_or(|(b, _)| pos < *b) {
412 best = Some((pos, path.to_vec()));
413 }
414 });
415 best
416 });
417 if let Some((_, segments)) = successor {
418 if let Some(table) = table_at_mut(doc, &segments) {
419 let existing = table
420 .decor()
421 .prefix()
422 .and_then(toml_edit::RawString::as_str)
423 .unwrap_or("")
424 .trim_start_matches('\n')
425 .to_owned();
426 table
427 .decor_mut()
428 .set_prefix(format!("{detached}{existing}"));
429 }
430 } else {
431 let trailing = doc.trailing().as_str().unwrap_or("").to_owned();
432 doc.set_trailing(format!("{trailing}{detached}"));
433 }
434}
435
436fn collect_positioned_tables(
438 table: &Table,
439 path: &mut Vec<String>,
440 visit: &mut dyn FnMut(usize, &[String]),
441) {
442 for (key, item) in table {
443 if let Item::Table(t) = item {
444 path.push(key.to_owned());
445 if let Some(position) = t.position() {
446 visit(position, path);
447 }
448 collect_positioned_tables(t, path, visit);
449 path.pop();
450 }
451 }
452}
453
454fn table_at_mut<'a>(doc: &'a mut DocumentMut, segments: &[String]) -> Option<&'a mut Table> {
456 let mut table: &mut Table = doc.as_table_mut();
457 for seg in segments {
458 match table.get_mut(seg) {
459 Some(Item::Table(t)) => table = t,
460 _ => return None,
461 }
462 }
463 Some(table)
464}
465
466fn append_block(out: &mut String, block: &str) {
469 let block = block.trim_end_matches('\n');
470 if !out.is_empty() {
471 if !out.ends_with('\n') {
472 out.push('\n');
473 }
474 if !out.ends_with("\n\n") {
475 out.push('\n');
476 }
477 }
478 out.push_str(block);
479 out.push('\n');
480}
481
482fn policy_json(text: &str) -> Result<serde_json::Value, String> {
487 let value: toml::Value = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
488 serde_json::to_value(value).map_err(|e| e.to_string())
489}
490
491fn structural_changes(before: &serde_json::Value, after: &serde_json::Value) -> Vec<ChangeHunk> {
496 let mut out = Vec::new();
497 walk_changes(&mut Vec::new(), Some(before), Some(after), &mut out);
498 out
499}
500
501fn walk_changes(
502 path: &mut Vec<String>,
503 before: Option<&serde_json::Value>,
504 after: Option<&serde_json::Value>,
505 out: &mut Vec<ChangeHunk>,
506) {
507 if before == after {
508 return;
509 }
510 let recurse = match (before, after) {
511 (Some(b), Some(a)) => b.is_object() && a.is_object(),
512 (Some(v), None) | (None, Some(v)) => v.is_object() && path.len() < 2,
513 (None, None) => return,
514 };
515 if recurse {
516 let empty = serde_json::Map::new();
517 let b = before
518 .and_then(serde_json::Value::as_object)
519 .unwrap_or(&empty);
520 let a = after
521 .and_then(serde_json::Value::as_object)
522 .unwrap_or(&empty);
523 let keys: BTreeSet<&String> = b.keys().chain(a.keys()).collect();
524 for key in keys {
525 path.push(key.clone());
526 walk_changes(path, b.get(key), a.get(key), out);
527 path.pop();
528 }
529 return;
530 }
531 out.push(ChangeHunk {
532 after: after.cloned(),
533 before: before.cloned(),
534 path: PolicyPath::new(path.iter().cloned()).to_string(),
535 });
536}
537
538#[derive(Debug, Clone, Copy)]
543enum Edit {
544 Equal(usize),
546 Del(usize),
548 Ins(usize),
550}
551
552#[must_use]
557pub fn unified_diff(old_label: &str, new_label: &str, old: &str, new: &str) -> String {
558 let old_lines = split_keep_newline(old);
559 let new_lines = split_keep_newline(new);
560 let edits = line_edits(&old_lines, &new_lines);
561 let mut out = format!("--- {old_label}\n+++ {new_label}\n");
562 for range in hunk_ranges(&edits) {
563 let (old_pos, new_pos) = cursor_at(&edits, range.start);
564 render_hunk(
565 &mut out,
566 &edits[range],
567 old_pos,
568 new_pos,
569 &old_lines,
570 &new_lines,
571 );
572 }
573 out
574}
575
576fn split_keep_newline(text: &str) -> Vec<&str> {
579 let mut lines = Vec::new();
580 let mut rest = text;
581 while !rest.is_empty() {
582 if let Some(i) = rest.find('\n') {
583 lines.push(&rest[..=i]);
584 rest = &rest[i + 1..];
585 } else {
586 lines.push(rest);
587 rest = "";
588 }
589 }
590 lines
591}
592
593fn line_edits(old: &[&str], new: &[&str]) -> Vec<Edit> {
596 let prefix = old
597 .iter()
598 .zip(new.iter())
599 .take_while(|(a, b)| a == b)
600 .count();
601 let rest_old = &old[prefix..];
602 let rest_new = &new[prefix..];
603 let suffix = rest_old
604 .iter()
605 .rev()
606 .zip(rest_new.iter().rev())
607 .take_while(|(a, b)| a == b)
608 .count();
609 let a = &rest_old[..rest_old.len() - suffix];
610 let b = &rest_new[..rest_new.len() - suffix];
611
612 let mut lcs = vec![vec![0_usize; b.len() + 1]; a.len() + 1];
614 for i in (0..a.len()).rev() {
615 for j in (0..b.len()).rev() {
616 lcs[i][j] = if a[i] == b[j] {
617 lcs[i + 1][j + 1] + 1
618 } else {
619 lcs[i + 1][j].max(lcs[i][j + 1])
620 };
621 }
622 }
623 let mut edits: Vec<Edit> = (0..prefix).map(Edit::Equal).collect();
624 let (mut i, mut j) = (0, 0);
625 while i < a.len() && j < b.len() {
626 if a[i] == b[j] {
627 edits.push(Edit::Equal(prefix + i));
628 i += 1;
629 j += 1;
630 } else if lcs[i + 1][j] >= lcs[i][j + 1] {
631 edits.push(Edit::Del(prefix + i));
632 i += 1;
633 } else {
634 edits.push(Edit::Ins(prefix + j));
635 j += 1;
636 }
637 }
638 while i < a.len() {
639 edits.push(Edit::Del(prefix + i));
640 i += 1;
641 }
642 while j < b.len() {
643 edits.push(Edit::Ins(prefix + j));
644 j += 1;
645 }
646 for k in 0..suffix {
647 edits.push(Edit::Equal(old.len() - suffix + k));
648 }
649 edits
650}
651
652fn hunk_ranges(edits: &[Edit]) -> Vec<Range<usize>> {
655 let mut ranges: Vec<Range<usize>> = Vec::new();
656 for (index, edit) in edits.iter().enumerate() {
657 if matches!(edit, Edit::Equal(..)) {
658 continue;
659 }
660 let start = index.saturating_sub(CONTEXT);
661 let end = (index + CONTEXT + 1).min(edits.len());
662 match ranges.last_mut() {
663 Some(last) if start <= last.end => last.end = end,
664 _ => ranges.push(start..end),
665 }
666 }
667 ranges
668}
669
670fn cursor_at(edits: &[Edit], upto: usize) -> (usize, usize) {
672 let mut old = 0;
673 let mut new = 0;
674 for edit in &edits[..upto] {
675 match edit {
676 Edit::Equal(..) => {
677 old += 1;
678 new += 1;
679 }
680 Edit::Del(_) => old += 1,
681 Edit::Ins(_) => new += 1,
682 }
683 }
684 (old, new)
685}
686
687fn render_hunk(
690 out: &mut String,
691 edits: &[Edit],
692 old_pos: usize,
693 new_pos: usize,
694 old: &[&str],
695 new: &[&str],
696) {
697 let old_count = edits
698 .iter()
699 .filter(|e| matches!(e, Edit::Equal(..) | Edit::Del(_)))
700 .count();
701 let new_count = edits
702 .iter()
703 .filter(|e| matches!(e, Edit::Equal(..) | Edit::Ins(_)))
704 .count();
705 let old_display = if old_count == 0 { old_pos } else { old_pos + 1 };
706 let new_display = if new_count == 0 { new_pos } else { new_pos + 1 };
707 let header = format!("@@ -{old_display},{old_count} +{new_display},{new_count} @@\n");
708 out.push_str(&header);
709 for edit in edits {
710 match *edit {
711 Edit::Equal(i) => push_line(out, ' ', old[i]),
712 Edit::Del(i) => push_line(out, '-', old[i]),
713 Edit::Ins(j) => push_line(out, '+', new[j]),
714 }
715 }
716}
717
718fn push_line(out: &mut String, tag: char, line: &str) {
721 out.push(tag);
722 if let Some(body) = line.strip_suffix('\n') {
723 out.push_str(body);
724 out.push('\n');
725 } else {
726 out.push_str(line);
727 out.push_str("\n\\ No newline at end of file\n");
728 }
729}
730
731#[cfg(test)]
732pub(crate) use tests::apply_unified;
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 pub(crate) fn apply_unified(old: &str, patch: &str) -> Result<String, String> {
742 let old_lines = split_keep_newline(old);
743 let mut out = String::new();
744 let mut cursor = 0_usize;
745 let mut lines = patch.lines().peekable();
746 while lines
747 .peek()
748 .is_some_and(|l| l.starts_with("--- ") || l.starts_with("+++ "))
749 {
750 lines.next();
751 }
752 while let Some(header) = lines.next() {
753 let (old_start, old_count) = parse_hunk_side(header, '-')?;
754 let (_, new_count) = parse_hunk_side(header, '+')?;
755 let hunk_old_index = if old_count == 0 {
756 old_start
757 } else {
758 old_start - 1
759 };
760 while cursor < hunk_old_index {
761 out.push_str(old_lines[cursor]);
762 cursor += 1;
763 }
764 let (mut consumed, mut produced) = (0, 0);
765 while consumed < old_count || produced < new_count {
766 let line = lines.next().ok_or("truncated hunk")?;
767 let body = &line[1..];
768 let no_newline_next = lines.peek() == Some(&"\\ No newline at end of file");
769 let text = if no_newline_next {
770 lines.next();
771 body.to_owned()
772 } else {
773 format!("{body}\n")
774 };
775 match line.as_bytes().first() {
776 Some(b' ') => {
777 if old_lines[cursor] != text {
778 return Err(format!("context mismatch at old line {cursor}"));
779 }
780 out.push_str(&text);
781 cursor += 1;
782 consumed += 1;
783 produced += 1;
784 }
785 Some(b'-') => {
786 if old_lines[cursor] != text {
787 return Err(format!("removal mismatch at old line {cursor}"));
788 }
789 cursor += 1;
790 consumed += 1;
791 }
792 Some(b'+') => {
793 out.push_str(&text);
794 produced += 1;
795 }
796 _ => return Err(format!("unexpected patch line: {line}")),
797 }
798 }
799 }
800 while cursor < old_lines.len() {
801 out.push_str(old_lines[cursor]);
802 cursor += 1;
803 }
804 Ok(out)
805 }
806
807 fn parse_hunk_side(header: &str, sign: char) -> Result<(usize, usize), String> {
809 let start = header
810 .find(sign)
811 .ok_or_else(|| format!("bad hunk header: {header}"))?;
812 let rest = &header[start + 1..];
813 let end = rest
814 .find(' ')
815 .ok_or_else(|| format!("bad hunk header: {header}"))?;
816 let (line, count) = rest[..end]
817 .split_once(',')
818 .ok_or_else(|| format!("bad hunk header: {header}"))?;
819 Ok((
820 line.parse().map_err(|e| format!("bad line number: {e}"))?,
821 count.parse().map_err(|e| format!("bad count: {e}"))?,
822 ))
823 }
824
825 #[test]
828 fn modify_one_line_yields_one_hunk_with_three_context_lines() {
829 let old = "a\nb\nc\nd\ne\nf\ng\n";
830 let new = "a\nb\nc\nD\ne\nf\ng\n";
831 let patch = unified_diff("a/keel.toml", "b/keel.toml", old, new);
832 assert_eq!(
833 patch,
834 "--- a/keel.toml\n+++ b/keel.toml\n@@ -1,7 +1,7 @@\n a\n b\n c\n-d\n+D\n e\n f\n g\n"
835 );
836 assert_eq!(apply_unified(old, &patch).unwrap(), new);
837 }
838
839 #[test]
840 fn creation_from_absent_file_counts_from_zero() {
841 let patch = unified_diff("/dev/null", "b/keel.toml", "", "x = 1\ny = 2\n");
842 assert_eq!(
843 patch,
844 "--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,2 @@\n+x = 1\n+y = 2\n"
845 );
846 assert_eq!(apply_unified("", &patch).unwrap(), "x = 1\ny = 2\n");
847 }
848
849 #[test]
850 fn missing_final_newline_gets_the_marker_on_the_right_side() {
851 let old = "a\nb";
852 let new = "a\nb\nc\n";
853 let patch = unified_diff("a/keel.toml", "b/keel.toml", old, new);
854 assert_eq!(
855 patch,
856 "--- a/keel.toml\n+++ b/keel.toml\n@@ -1,2 +1,3 @@\n a\n-b\n\\ No newline at end of file\n+b\n+c\n"
857 );
858 assert_eq!(apply_unified(old, &patch).unwrap(), new);
859 }
860
861 #[test]
862 fn distant_changes_land_in_separate_hunks() {
863 let old = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n";
864 let new = "one\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\nfifteen\n";
865 let patch = unified_diff("a/keel.toml", "b/keel.toml", old, new);
866 assert_eq!(patch.matches("@@").count(), 4, "two hunks: {patch}");
867 assert_eq!(apply_unified(old, &patch).unwrap(), new);
868 }
869
870 const TUNED: &str = "\
873# hand-tuned; keep me
874
875[target.\"api.example.com\"] # seen in: app.py:4
876timeout = \"30s\" # tuned by us
877retry = { attempts = 3 }
878
879[target.\"api.other.example\"]
880timeout = \"10s\"
881";
882
883 #[test]
884 fn set_overwrites_in_place_preserving_comments_and_untouched_lines() {
885 let ops = [PolicyOp::Set {
886 path: PolicyPath::new(["target", "api.example.com", "timeout"]),
887 value: Value::from("2m"),
888 }];
889 let p = propose(Some(TUNED), &ops).unwrap();
890 assert_eq!(p.new_text, TUNED.replace("\"30s\"", "\"2m\""));
893 assert_eq!(apply_unified(TUNED, &p.patch).unwrap(), p.new_text);
894 assert_eq!(p.changes.len(), 1);
895 assert_eq!(p.changes[0].path, "target.\"api.example.com\".timeout");
896 assert_eq!(p.changes[0].before, Some(serde_json::json!("30s")));
897 assert_eq!(p.changes[0].after, Some(serde_json::json!("2m")));
898 }
899
900 #[test]
901 fn set_creates_missing_tables_in_house_style() {
902 let ops = [PolicyOp::Set {
903 path: PolicyPath::new(["target", "api.x", "retry", "attempts"]),
904 value: Value::from(5_i64),
905 }];
906 let p = propose(Some(""), &ops).unwrap();
907 assert_eq!(p.new_text, "[target.\"api.x\"]\nretry = { attempts = 5 }\n");
908 assert_eq!(apply_unified("", &p.patch).unwrap(), p.new_text);
909 let ops = [PolicyOp::Set {
911 path: PolicyPath::new(["target", "api.example.com", "retry", "schedule"]),
912 value: Value::from("exp(1s, x2, max 30s)"),
913 }];
914 let p = propose(Some(TUNED), &ops).unwrap();
915 assert!(
916 p.new_text
917 .contains("retry = { attempts = 3, schedule = \"exp(1s, x2, max 30s)\" }"),
918 "got: {}",
919 p.new_text
920 );
921 assert_eq!(apply_unified(TUNED, &p.patch).unwrap(), p.new_text);
922 }
923
924 #[test]
925 fn set_through_a_scalar_is_a_path_conflict() {
926 let ops = [PolicyOp::Set {
927 path: PolicyPath::new(["target", "api.example.com", "timeout", "nested"]),
928 value: Value::from(1_i64),
929 }];
930 let err = propose(Some(TUNED), &ops).unwrap_err();
931 assert!(matches!(err, DiffError::PathConflict { .. }), "{err}");
932 assert!(err.to_string().contains("timeout"));
933 }
934
935 #[test]
936 fn remove_drops_a_whole_block_but_keeps_file_header_comments() {
937 let generated = "\
938# Generated by keel init from 2 static scans + 0 observed runs
939# Every entry below was found in YOUR code. Delete anything; defaults still apply.
940
941[target.\"a.example\"] # seen in: app.py:4
942timeout = \"30s\"
943
944[target.\"b.example\"]
945timeout = \"10s\"
946";
947 let ops = [PolicyOp::Remove {
949 path: PolicyPath::new(["target", "a.example"]),
950 }];
951 let p = propose(Some(generated), &ops).unwrap();
952 assert_eq!(
953 p.new_text,
954 "\
955# Generated by keel init from 2 static scans + 0 observed runs
956# Every entry below was found in YOUR code. Delete anything; defaults still apply.
957
958[target.\"b.example\"]
959timeout = \"10s\"
960"
961 );
962 assert_eq!(apply_unified(generated, &p.patch).unwrap(), p.new_text);
963 let ops = [PolicyOp::Remove {
965 path: PolicyPath::new(["target", "b.example"]),
966 }];
967 let p = propose(Some(generated), &ops).unwrap();
968 assert!(
969 p.new_text.ends_with("timeout = \"30s\"\n"),
970 "{}",
971 p.new_text
972 );
973 assert!(!p.new_text.contains("b.example"));
974 assert_eq!(apply_unified(generated, &p.patch).unwrap(), p.new_text);
975 assert_eq!(p.changes.len(), 1);
976 assert_eq!(p.changes[0].path, "target.\"b.example\"");
977 assert!(p.changes[0].after.is_none());
978 }
979
980 #[test]
981 fn remove_of_a_missing_path_is_a_noop() {
982 let ops = [PolicyOp::Remove {
983 path: PolicyPath::new(["target", "not.there"]),
984 }];
985 let p = propose(Some(TUNED), &ops).unwrap();
986 assert_eq!(p.new_text, TUNED);
987 assert!(p.patch.is_empty());
988 assert!(p.changes.is_empty());
989 }
990
991 #[test]
992 fn remove_inside_an_inline_table_drops_just_that_key() {
993 let ops = [PolicyOp::Remove {
994 path: PolicyPath::new(["target", "api.example.com", "retry", "attempts"]),
995 }];
996 let p = propose(Some(TUNED), &ops).unwrap();
997 assert!(p.new_text.contains("retry = { }") || p.new_text.contains("retry = {}"));
998 assert_eq!(apply_unified(TUNED, &p.patch).unwrap(), p.new_text);
999 }
1000
1001 #[test]
1002 fn append_block_separates_with_exactly_one_blank_line() {
1003 let block = "[target.\"api.new.example\"]\ntimeout = \"30s\"\n";
1004 let p = propose(
1005 Some(TUNED),
1006 &[PolicyOp::AppendBlock {
1007 text: block.to_owned(),
1008 }],
1009 )
1010 .unwrap();
1011 assert!(
1012 p.new_text.ends_with(
1013 "timeout = \"10s\"\n\n[target.\"api.new.example\"]\ntimeout = \"30s\"\n"
1014 ),
1015 "{}",
1016 p.new_text
1017 );
1018 assert_eq!(apply_unified(TUNED, &p.patch).unwrap(), p.new_text);
1019 assert_eq!(p.changes.len(), 1);
1020 assert_eq!(p.changes[0].path, "target.\"api.new.example\"");
1021 assert!(p.changes[0].before.is_none());
1022 }
1023
1024 #[test]
1025 fn append_to_a_file_without_trailing_newline_still_applies() {
1026 let old = "[target.\"api.example.com\"]\ntimeout = \"30s\""; let block = "[target.\"api.new.example\"]\ntimeout = \"5s\"\n";
1028 let p = propose(
1029 Some(old),
1030 &[PolicyOp::AppendBlock {
1031 text: block.to_owned(),
1032 }],
1033 )
1034 .unwrap();
1035 assert!(p.patch.contains("\\ No newline at end of file"));
1036 assert_eq!(apply_unified(old, &p.patch).unwrap(), p.new_text);
1037 assert!(p.new_text.parse::<DocumentMut>().is_ok());
1038 }
1039
1040 #[test]
1041 fn garbage_append_block_is_rejected_before_it_can_ship() {
1042 let err = propose(
1043 Some(TUNED),
1044 &[PolicyOp::AppendBlock {
1045 text: "not [valid toml".to_owned(),
1046 }],
1047 )
1048 .unwrap_err();
1049 assert!(matches!(err, DiffError::ResultInvalid(_)), "{err}");
1050 let err = propose(
1052 Some(TUNED),
1053 &[PolicyOp::AppendBlock {
1054 text: "[target.\"api.example.com\"]\ntimeout = \"1s\"\n".to_owned(),
1055 }],
1056 )
1057 .unwrap_err();
1058 assert!(matches!(err, DiffError::ResultInvalid(_)), "{err}");
1059 }
1060
1061 #[test]
1062 fn invalid_current_text_is_reported_as_such() {
1063 let err = propose(Some("not [valid"), &[]).unwrap_err();
1064 assert!(matches!(err, DiffError::CurrentInvalid(_)), "{err}");
1065 }
1066
1067 #[test]
1068 fn absent_file_proposes_a_dev_null_creation_patch() {
1069 let content = "# header\n\n[target.\"a.example\"]\ntimeout = \"30s\"\n";
1070 let p = propose(
1071 None,
1072 &[PolicyOp::AppendBlock {
1073 text: content.to_owned(),
1074 }],
1075 )
1076 .unwrap();
1077 assert_eq!(p.new_text, content);
1078 assert!(
1079 p.patch
1080 .starts_with("--- /dev/null\n+++ b/keel.toml\n@@ -0,0 +1,4 @@\n")
1081 );
1082 assert_eq!(apply_unified("", &p.patch).unwrap(), content);
1083 assert_eq!(p.changes.len(), 1);
1085 assert_eq!(p.changes[0].path, "target.\"a.example\"");
1086 }
1087
1088 #[test]
1089 fn proposal_json_serializes_changes_and_patch_only() {
1090 let p = propose(
1091 Some(TUNED),
1092 &[PolicyOp::Remove {
1093 path: PolicyPath::new(["target", "api.other.example"]),
1094 }],
1095 )
1096 .unwrap();
1097 let json = crate::render::to_json(&p);
1098 assert!(json.get("changes").is_some());
1099 assert!(json.get("patch").is_some());
1100 assert!(
1101 json.get("new_text").is_none(),
1102 "new_text is not wire format"
1103 );
1104 }
1105
1106 #[test]
1107 fn applying_the_patch_yields_a_file_that_parses_to_the_proposed_policy() {
1108 let ops = [
1110 PolicyOp::Remove {
1111 path: PolicyPath::new(["target", "api.other.example"]),
1112 },
1113 PolicyOp::Set {
1114 path: PolicyPath::new(["target", "api.example.com", "timeout"]),
1115 value: Value::from("45s"),
1116 },
1117 PolicyOp::AppendBlock {
1118 text: "[target.\"api.new.example\"]\ntimeout = \"5s\"\n".to_owned(),
1119 },
1120 ];
1121 let p = propose(Some(TUNED), &ops).unwrap();
1122 let applied = apply_unified(TUNED, &p.patch).unwrap();
1123 assert_eq!(applied, p.new_text);
1124 let applied_policy: toml::Value = applied.parse().unwrap();
1125 let proposed_policy: toml::Value = p.new_text.parse().unwrap();
1126 assert_eq!(applied_policy, proposed_policy);
1127 assert!(applied_policy["target"].get("api.other.example").is_none());
1128 assert_eq!(
1129 applied_policy["target"]["api.example.com"]["timeout"].as_str(),
1130 Some("45s")
1131 );
1132 }
1133
1134 #[test]
1137 fn policy_path_display_quotes_only_non_bare_segments() {
1138 let path = PolicyPath::new(["target", "api.example.com", "retry"]);
1139 assert_eq!(path.to_string(), "target.\"api.example.com\".retry");
1140 assert_eq!(
1141 PolicyPath::new(["flow", "nightly-etl"]).to_string(),
1142 "flow.nightly-etl"
1143 );
1144 }
1145
1146 #[test]
1147 fn resolve_dotted_path_matches_the_longest_key_greedily() {
1148 let text = "[target.\"api.example.com\"]\nretry = { attempts = 0, on = [\"5xx\"] }\n";
1149 let resolved = resolve_dotted_path(text, "target.api.example.com.retry.attempts").unwrap();
1150 assert_eq!(
1151 resolved.segments(),
1152 ["target", "api.example.com", "retry", "attempts"]
1153 );
1154 let resolved = resolve_dotted_path(text, "target.api.example.com.retry.on[1]").unwrap();
1156 assert_eq!(
1157 resolved.segments(),
1158 ["target", "api.example.com", "retry", "on"]
1159 );
1160 let resolved = resolve_dotted_path(text, "target.api.example.com.retry.attempts.deep");
1162 assert_eq!(
1163 resolved.unwrap().segments(),
1164 ["target", "api.example.com", "retry", "attempts"]
1165 );
1166 assert!(resolve_dotted_path(text, "target.nope.retry").is_none());
1167 }
1168}