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(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, ¤t) {
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, ¤t);
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
96pub 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 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
132fn 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 if !long.starts_with(short) {
152 return None;
153 }
154 short
155 }
156 });
157 }
158 min_indent.map(str::to_string)
159}