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(path: &str, content: &str, hooks: &PostEditHooks<'_>) -> PostEditOutcome {
55    let mut diagnostics = Vec::new();
56    let mut formatted = false;
57    let mut current = content.to_string();
58    if let Some(formatter) = hooks.formatter {
59        match formatter(path, &current) {
60            Ok(Some(next)) => {
61                formatted = next != current;
62                current = next;
63            }
64            Ok(None) => {}
65            Err(error) => diagnostics.push(PostEditDiagnostic {
66                kind: "formatter_failed".to_string(),
67                message: format!("formatter failed for {path}: {error}"),
68            }),
69        }
70    }
71    let mut blocked = false;
72    for validator in &hooks.validators {
73        if matches!(validator.policy, ValidatorPolicy::Off) {
74            continue;
75        }
76        let findings = (validator.check)(path, &current);
77        if findings.is_empty() {
78            continue;
79        }
80        if matches!(validator.policy, ValidatorPolicy::Block) {
81            blocked = true;
82        }
83        diagnostics.extend(findings.into_iter().map(|finding| PostEditDiagnostic {
84            kind: finding.kind,
85            message: format!("[{}] {}", validator.name, finding.message),
86        }));
87    }
88    PostEditOutcome {
89        content: current,
90        formatted,
91        diagnostics,
92        blocked,
93    }
94}
95
96/**
97 * Bounded indentation normalization for inserted/replaced multiline code.
98 *
99 * Models frequently emit replacement blocks at column zero even when the
100 * replaced text was indented. When the replaced text has a uniform minimum
101 * indentation and the replacement has none, shift every non-empty
102 * replacement line right by that minimum indent, preserving relative
103 * indentation inside the block. Anything else is left untouched, so the
104 * transform is deterministic and scoped to the inserted text only.
105 */
106pub fn normalize_inserted_indentation(old_string: &str, new_string: &str) -> String {
107    let Some(base_indent) = uniform_min_indent(old_string) else {
108        return new_string.to_string();
109    };
110    if base_indent.is_empty() {
111        return new_string.to_string();
112    }
113    // Only reindent when the replacement clearly omitted indentation: its
114    // minimum indent across non-empty lines must be zero.
115    match uniform_min_indent(new_string) {
116        Some(indent) if indent.is_empty() => {}
117        _ => return new_string.to_string(),
118    }
119    new_string
120        .split('\n')
121        .map(|line| {
122            if line.trim().is_empty() {
123                line.to_string()
124            } else {
125                format!("{base_indent}{line}")
126            }
127        })
128        .collect::<Vec<_>>()
129        .join("\n")
130}
131
132/// Returns the common leading-whitespace prefix length (as a string) across
133/// non-empty lines, or `None` when lines mix tabs and spaces inconsistently.
134fn uniform_min_indent(text: &str) -> Option<String> {
135    let mut min_indent: Option<&str> = None;
136    for line in text.lines().filter(|line| !line.trim().is_empty()) {
137        let indent_len = line.len() - line.trim_start().len();
138        let indent = &line[..indent_len];
139        if indent.chars().any(|ch| ch != ' ' && ch != '\t') {
140            return None;
141        }
142        min_indent = Some(match min_indent {
143            None => indent,
144            Some(current) => {
145                let (short, long) = if indent.len() <= current.len() {
146                    (indent, current)
147                } else {
148                    (current, indent)
149                };
150                // Mixed tab/space prefixes are not comparable; bail out.
151                if !long.starts_with(short) {
152                    return None;
153                }
154                short
155            }
156        });
157    }
158    min_indent.map(str::to_string)
159}