roder_edit_core/
post_edit.rs1#[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
18pub type FormatterHook<'a> = &'a dyn Fn(&str, &str) -> anyhow::Result<Option<String>>;
23
24pub 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 pub blocked: bool,
47}
48
49pub 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, ¤t) {
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, ¤t);
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
100pub 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 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
136fn 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 if !long.starts_with(short) {
156 return None;
157 }
158 short
159 }
160 });
161 }
162 min_indent.map(str::to_string)
163}