Skip to main content

keel_cli/
diff.rs

1//! Applyable policy diffs — "diffs as the lingua franca" (dx-spec §5).
2//!
3//! Every suggestion Keel makes — `keel init --diff` adds/removes, `doctor` fix
4//! suggestions, and (future) `keel flows add` and `keel mcp propose_policy` —
5//! is emitted as an *applyable* diff, because agents apply diffs reliably and
6//! paraphrase prose unreliably. Callers describe a change as [`PolicyOp`]s
7//! against the current `keel.toml` text; [`propose`] returns a [`Proposal`]:
8//!
9//! - `patch` — a unified diff (`a/keel.toml` → `b/keel.toml`, or `/dev/null`
10//!   when the file does not exist yet) that `git apply` or `patch -p1` applies
11//!   cleanly, `\ No newline at end of file` markers included.
12//! - `changes` — structured `{path, before, after}` hunks for machines
13//!   (comments are a text-level concern; they appear only in the patch).
14//! - `new_text` — the full proposed file, for callers that write directly.
15//!
16//! Edits are surgical: they operate on the TOML *document* (`toml_edit`), not
17//! a value round-trip, so user formatting and comments outside the touched
18//! regions survive byte-for-byte. Everything here is a pure function of its
19//! inputs — identical inputs yield byte-identical output (dx-spec §5).
20
21use std::collections::BTreeSet;
22use std::fmt;
23use std::ops::Range;
24
25use serde::Serialize;
26use toml_edit::{DocumentMut, InlineTable, Item, Table, TableLike, Value};
27
28/// The policy file name used in patch headers (`a/keel.toml` → `b/keel.toml`).
29const FILE_NAME: &str = "keel.toml";
30
31/// Context lines around each hunk (git's default).
32const CONTEXT: usize = 3;
33
34/// A path into the policy document, segment by segment — e.g.
35/// `["target", "api.example.com", "retry"]`. [`Display`](fmt::Display) renders
36/// the TOML dotted-key form (`target."api.example.com".retry`), quoting
37/// segments that are not bare keys, so paths stay unambiguous even when a
38/// target name contains dots.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PolicyPath(Vec<String>);
41
42impl PolicyPath {
43    /// Build a path from segments.
44    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    /// The raw segments.
53    #[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(&quote_segment(seg))?;
66        }
67        Ok(())
68    }
69}
70
71/// Quote one key segment TOML-style when it is not a bare key.
72fn 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/// One edit to the policy document. Within a proposal, [`Set`](PolicyOp::Set)
85/// and [`Remove`](PolicyOp::Remove) apply first (in order) as document
86/// surgery; every [`AppendBlock`](PolicyOp::AppendBlock) is then appended to
87/// the rendered text (blank-line separated), so pre-rendered blocks keep their
88/// comments verbatim.
89#[derive(Debug, Clone)]
90pub enum PolicyOp {
91    /// Append a pre-rendered TOML block (evidence comments and all) at the end
92    /// of the file. The block must parse in context; [`propose`] re-validates.
93    AppendBlock {
94        /// The block text, e.g. a whole `[target."…"]` section.
95        text: String,
96    },
97    /// Remove the entry at `path` — a whole `[target."…"]` table or a single
98    /// key. Removing a missing path is a no-op, so proposals stay idempotent.
99    Remove {
100        /// What to remove.
101        path: PolicyPath,
102    },
103    /// Set the value at `path`, creating parent tables as needed (top-level
104    /// groups as implicit tables, the level under them as `[dotted]` tables,
105    /// anything deeper house-style inline: `retry = { … }`).
106    Set {
107        /// Where to write.
108        path: PolicyPath,
109        /// The value to write.
110        value: Value,
111    },
112}
113
114/// Why a proposal could not be built.
115#[derive(Debug)]
116pub enum DiffError {
117    /// The current `keel.toml` text is not valid TOML.
118    CurrentInvalid(String),
119    /// A [`PolicyOp::Set`] path traverses something that is not a table.
120    PathConflict {
121        /// The offending path, TOML-dotted.
122        path: String,
123        /// What went wrong there.
124        detail: String,
125    },
126    /// The proposed result does not parse (e.g. a malformed or colliding
127    /// [`PolicyOp::AppendBlock`]).
128    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/// One structured change: the TOML path plus the JSON value on each side
148/// (`null` = absent). Emitted depth-first over sorted keys — deterministic.
149#[derive(Debug, Clone, Serialize)]
150pub struct ChangeHunk {
151    /// The value after the change (`null` when removed).
152    pub after: Option<serde_json::Value>,
153    /// The value before the change (`null` when added).
154    pub before: Option<serde_json::Value>,
155    /// TOML-dotted path, quoted where needed: `target."api.example.com".retry`.
156    pub path: String,
157}
158
159/// The applyable result of [`propose`]: serialize it (or embed it in a larger
160/// report) and both audiences are served — `patch` for `git apply`, `changes`
161/// for structured consumption.
162#[derive(Debug, Clone, Serialize)]
163pub struct Proposal {
164    /// Structured `{path, before, after}` hunks (see [`ChangeHunk`]).
165    pub changes: Vec<ChangeHunk>,
166    /// The full proposed file text, for callers that write directly. Not
167    /// serialized — the patch is the wire form.
168    #[serde(skip)]
169    pub new_text: String,
170    /// Unified diff `a/keel.toml` → `b/keel.toml` (`--- /dev/null` when the
171    /// file does not exist yet). Empty when the ops change nothing.
172    pub patch: String,
173}
174
175/// Build an applyable proposal: apply `ops` to `current` (the `keel.toml`
176/// text; `None` = the file does not exist, which is distinct from
177/// `Some("")` — it selects the `/dev/null` creation header) and return the
178/// patch + structured changes. Untouched regions of `current` survive
179/// byte-for-byte.
180pub 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    // Safety net: the proposed file must parse. A malformed AppendBlock (or one
200    // colliding with an existing table) surfaces here, never at apply time.
201    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/// Resolve a `serde_path_to_error`-style dotted path (e.g.
225/// `target.api.example.com.retry.attempts`, where the target key itself
226/// contains dots) against the document, greedily matching the longest key at
227/// each level. Array indices (`on[1]`) are stripped. When the path descends
228/// into a non-table value with segments left over, the path *to that value* is
229/// returned — it is the entry to fix. Returns `None` when nothing matches.
230#[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            // Descended into a scalar/array with segments left: the value we
247            // reached is the entry the caller should act on.
248            break;
249        };
250        // Longest join of the remaining segments that names an actual key wins,
251        // so `api.example.com` resolves as one segment.
252        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
264/// Drop a trailing `[N]`… index suffix from one dotted-path segment.
265fn 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
281// ---- document surgery ----
282
283/// Set `value` at `path`, creating parents as needed and preserving the decor
284/// (spacing, trailing comment) of a value being overwritten.
285fn 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        // A new last entry in an inline table: the closing-brace space moves
315        // from the previous last value onto this one (`{ a = 1, b = 2 }`).
316        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
337/// A fresh intermediate container. Top-level groups (`target`, `flow`) become
338/// implicit tables and the level under them explicit `[dotted]` tables — the
339/// house style of generated files; anything deeper (or anything under an
340/// inline table) stays inline (`retry = { … }`).
341fn 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
351/// Remove the entry at `path`; a missing path is a no-op. When a whole
352/// `[table]` block is removed, the part of its leading trivia that belongs to
353/// the surrounding file (everything up to the last blank line — e.g. the
354/// generated-file header comments above the first block) is re-attached to the
355/// next block instead of vanishing with it.
356fn 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
390/// The part of a removed table's leading trivia that belongs to the
391/// surrounding file, not the block: everything up to and including the last
392/// blank line. The trailing comment run (attached to the block) is dropped
393/// with it; a whitespace-only remainder yields nothing, because the successor
394/// keeps its own separator.
395fn 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
404/// Prepend `detached` to the leading trivia of the first `[table]` positioned
405/// after `removed_position`; with no successor it becomes the document's
406/// trailing content.
407fn 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
436/// Depth-first visit of every explicitly positioned `[table]` in the document.
437fn 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
454/// Navigate to the `[table]` at `segments` (standard tables only).
455fn 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
466/// Append a pre-rendered block with exactly one blank line separating it from
467/// existing content, normalizing to a single trailing newline.
468fn 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
482// ---- structured changes ----
483
484/// Parse policy text to a JSON value (comments and formatting drop away — this
485/// is the semantic view the `changes` hunks compare).
486fn 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
491/// Structurally compare two policy documents into `{path, before, after}`
492/// hunks. Tables present on both sides recurse; a subtree appearing or
493/// vanishing above the target-name level (depth < 2) is broken into per-child
494/// hunks so adds/removes stay `[target."…"]`-block granular.
495fn 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// ---- unified diff ----
539
540/// One line-level edit; indices point into the old/new line vectors. An equal
541/// line is rendered from the old side, so it only carries its old index.
542#[derive(Debug, Clone, Copy)]
543enum Edit {
544    /// The same line on both sides (old index).
545    Equal(usize),
546    /// A line only in the old text.
547    Del(usize),
548    /// A line only in the new text.
549    Ins(usize),
550}
551
552/// Render a unified diff between two texts: git-compatible headers, three
553/// context lines, `\ No newline at end of file` markers. Lines are compared
554/// *with* their terminators, so a missing final newline diffs correctly.
555/// Deterministic — a pure function of its inputs.
556#[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
576/// Split text into lines that *keep* their `\n`, so a terminator-less final
577/// line compares as different from its terminated twin.
578fn 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
593/// The line-level edit script: common prefix/suffix trimmed, then an LCS walk
594/// over the middle. Policy files are small; O(n·m) is comfortably fine.
595fn 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    // lcs[i][j] = length of the LCS of a[i..] and b[j..].
613    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
652/// Group changed edits into hunk ranges, each padded with [`CONTEXT`] edits and
653/// merged when their context regions touch.
654fn 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
670/// The (old, new) line counts consumed by `edits[..upto]` — the hunk's cursor.
671fn 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
687/// Render one `@@ -l,c +l,c @@` hunk. A zero-count side reports the line
688/// *before* the change (git's convention, `-0,0` for creation).
689fn 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
718/// Emit one patch body line; a terminator-less line gets the
719/// `\ No newline at end of file` marker git and patch expect.
720fn 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    /// Test-only unified-diff applier: replays `patch` against `old` exactly
739    /// the way `git apply`/`patch -p1` would, so the property "the emitted
740    /// patch applies cleanly and reproduces `new_text`" is checked hermetically.
741    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    /// Parse one side (`-` or `+`) of an `@@ -l,c +l,c @@` header.
808    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    // ---- unified_diff ----
826
827    #[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    // ---- propose: surgical edits ----
871
872    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        // Only the timeout value changed; its trailing comment and every other
891        // byte of the file survive.
892        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        // Existing file: the new key lands inside the existing inline table.
910        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        // Removing the FIRST block re-attaches the header comments to the next.
948        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        // Removing the LAST block leaves no dangling blank line.
964        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\""; // no final newline
1027        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        // A block colliding with an existing table is equally rejected.
1051        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        // Creation hunks stay target-block granular (depth rule).
1084        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        // The property from the task brief, over a mixed op set.
1109        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    // ---- paths ----
1135
1136    #[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        // Array indices are stripped; the path stops at the array's value.
1155        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        // Segments beyond a scalar resolve to the scalar (the entry to fix).
1161        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}