Skip to main content

roder_edit_core/
post_edit.rs

1//! Deterministic post-edit actions: bounded indentation normalization for
2//! inserted/replaced code and host-provided formatter/validator hooks.
3//! These actions are tool-scoped; there are no session-level watcher loops.
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum ValidatorPolicy {
7    Off,
8    Warn,
9    Block,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct PostEditDiagnostic {
14    pub kind: String,
15    pub message: String,
16}
17
18/// Host-provided formatter callback: receives `(path, content)` and returns
19/// `Ok(Some(formatted))` to replace the content, `Ok(None)` to leave it
20/// unchanged, or `Err` for a formatter failure (reported as a diagnostic,
21/// never silently swallowed).
22pub type FormatterHook<'a> = &'a dyn Fn(&str, &str) -> anyhow::Result<Option<String>>;
23
24/// Host-provided validator callback: receives `(path, content)` and returns
25/// structured diagnostics. The attached policy decides whether diagnostics
26/// warn or block the edit.
27pub struct PostEditValidator<'a> {
28    pub name: &'a str,
29    pub policy: ValidatorPolicy,
30    pub check: &'a dyn Fn(&str, &str) -> Vec<PostEditDiagnostic>,
31}
32
33#[derive(Default)]
34pub struct PostEditHooks<'a> {
35    pub formatter: Option<FormatterHook<'a>>,
36    pub validators: Vec<PostEditValidator<'a>>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PostEditOutcome {
41    pub content: String,
42    pub formatted: bool,
43    pub diagnostics: Vec<PostEditDiagnostic>,
44    /// True when a `Block` validator produced diagnostics; the caller must
45    /// not persist `content` in that case.
46    pub blocked: bool,
47}
48
49/**
50 * Run the optional formatter then validators over the edited content for one
51 * path. Formatter failures become diagnostics and leave content unchanged;
52 * validator behavior follows each validator's policy.
53 */
54pub fn run_post_edit_hooks(
55    path: &str,
56    content: &str,
57    hooks: &PostEditHooks<'_>,
58) -> PostEditOutcome {
59    let mut diagnostics = Vec::new();
60    let mut formatted = false;
61    let mut current = content.to_string();
62    if let Some(formatter) = hooks.formatter {
63        match formatter(path, &current) {
64            Ok(Some(next)) => {
65                formatted = next != current;
66                current = next;
67            }
68            Ok(None) => {}
69            Err(error) => diagnostics.push(PostEditDiagnostic {
70                kind: "formatter_failed".to_string(),
71                message: format!("formatter failed for {path}: {error}"),
72            }),
73        }
74    }
75    let mut blocked = false;
76    for validator in &hooks.validators {
77        if matches!(validator.policy, ValidatorPolicy::Off) {
78            continue;
79        }
80        let findings = (validator.check)(path, &current);
81        if findings.is_empty() {
82            continue;
83        }
84        if matches!(validator.policy, ValidatorPolicy::Block) {
85            blocked = true;
86        }
87        diagnostics.extend(findings.into_iter().map(|finding| PostEditDiagnostic {
88            kind: finding.kind,
89            message: format!("[{}] {}", validator.name, finding.message),
90        }));
91    }
92    PostEditOutcome {
93        content: current,
94        formatted,
95        diagnostics,
96        blocked,
97    }
98}
99
100/**
101 * Bounded indentation normalization for inserted/replaced multiline code.
102 *
103 * Models frequently emit replacement blocks at column zero even when the
104 * replaced text was indented. When the replaced text has a uniform minimum
105 * indentation and the replacement has none, shift every non-empty
106 * replacement line right by that minimum indent, preserving relative
107 * indentation inside the block. Anything else is left untouched, so the
108 * transform is deterministic and scoped to the inserted text only.
109 */
110pub fn normalize_inserted_indentation(old_string: &str, new_string: &str) -> String {
111    let Some(base_indent) = uniform_min_indent(old_string) else {
112        return new_string.to_string();
113    };
114    if base_indent.is_empty() {
115        return new_string.to_string();
116    }
117    // Only reindent when the replacement clearly omitted indentation: its
118    // minimum indent across non-empty lines must be zero.
119    match uniform_min_indent(new_string) {
120        Some(indent) if indent.is_empty() => {}
121        _ => return new_string.to_string(),
122    }
123    new_string
124        .split('\n')
125        .map(|line| {
126            if line.trim().is_empty() {
127                line.to_string()
128            } else {
129                format!("{base_indent}{line}")
130            }
131        })
132        .collect::<Vec<_>>()
133        .join("\n")
134}
135
136/// Returns the common leading-whitespace prefix length (as a string) across
137/// non-empty lines, or `None` when lines mix tabs and spaces inconsistently.
138fn uniform_min_indent(text: &str) -> Option<String> {
139    let mut min_indent: Option<&str> = None;
140    for line in text.lines().filter(|line| !line.trim().is_empty()) {
141        let indent_len = line.len() - line.trim_start().len();
142        let indent = &line[..indent_len];
143        if indent.chars().any(|ch| ch != ' ' && ch != '\t') {
144            return None;
145        }
146        min_indent = Some(match min_indent {
147            None => indent,
148            Some(current) => {
149                let (short, long) = if indent.len() <= current.len() {
150                    (indent, current)
151                } else {
152                    (current, indent)
153                };
154                // Mixed tab/space prefixes are not comparable; bail out.
155                if !long.starts_with(short) {
156                    return None;
157                }
158                short
159            }
160        });
161    }
162    min_indent.map(str::to_string)
163}