amont_agent_process/
plan_text.rs1use amont_agent_guard::sha256::{hex, sha256};
7
8pub(crate) const REVIEW_SECTION: &str = "Review panel";
9const FULL_REVIEWS: &str = "Full reviews";
10
11#[cfg(test)]
12thread_local! {
13 pub(crate) static PARSER_PANICS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
15}
16
17pub fn canonical(text: &str) -> String {
29 let text = text.replace("\r\n", "\n");
30 let lines: Vec<&str> = text.split('\n').collect();
31 let comment = lines
32 .iter()
33 .rposition(|l| !l.trim().is_empty())
34 .filter(|&i| is_machine_comment(lines[i]));
35 let mut start = front_matter_end(&lines);
36 while start < lines.len() && lines[start].trim().is_empty() {
37 start += 1;
38 }
39 let mut out: Vec<&str> = Vec::new();
40 let mut fence = Fence::default();
41 let mut dropping = false;
42 for (i, line) in lines.iter().enumerate().skip(start) {
43 if Some(i) == comment {
44 continue;
45 }
46 if let Some(title) = fence.h2(line) {
47 if title.starts_with(FULL_REVIEWS) {
48 break;
49 }
50 dropping = title == REVIEW_SECTION;
51 }
52 if !dropping {
53 out.push(line.trim_end_matches([' ', '\t']));
54 }
55 }
56 let mut body = out.join("\n");
57 body.truncate(body.trim_end_matches(['\n', ' ', '\t']).len());
58 body.push('\n');
59 body
60}
61
62pub(crate) fn outside_body(text: &str, offset: usize) -> bool {
66 let lines: Vec<&str> = text.split('\n').collect();
67 let comment = lines
68 .iter()
69 .rposition(|l| !l.trim().is_empty())
70 .filter(|&i| is_machine_comment(lines[i]));
71 let front = front_matter_end(&lines);
72 let mut fence = Fence::default();
73 let (mut dropping, mut reviews) = (false, false);
74 let mut start = 0;
75 for (i, line) in lines.iter().enumerate() {
76 let end = start + line.len() + 1;
77 if i >= front {
78 if let Some(title) = fence.h2(line) {
79 reviews |= title.starts_with(FULL_REVIEWS);
80 dropping = title == REVIEW_SECTION;
81 }
82 }
83 if offset < end {
84 return i < front || Some(i) == comment || reviews || dropping;
85 }
86 start = end;
87 }
88 false
89}
90
91fn front_matter_end(lines: &[&str]) -> usize {
94 if lines.first().map(|l| l.trim_end()) != Some("---") {
95 return 0;
96 }
97 lines
98 .iter()
99 .enumerate()
100 .skip(1)
101 .find(|(_, l)| l.trim_end() == "---")
102 .map_or(0, |(i, _)| i + 1)
103}
104
105pub fn body_sha(text: &str) -> String {
109 #[cfg(test)]
110 if PARSER_PANICS.with(|p| p.get()) {
111 panic!("injected parser panic");
112 }
113 hex(&sha256(
114 crate::plan_words::words(&canonical(text)).as_bytes(),
115 ))
116}
117
118pub fn legacy_sha(text: &str) -> String {
122 hex(&sha256(canonical(text).as_bytes()))
123}
124
125pub struct Shas {
127 pub sha: String,
128 pub legacy: String,
129}
130
131pub fn shas(text: &str) -> Result<Shas, ShaError> {
135 guarded(|| body_sha(text)).map(|sha| Shas {
136 sha,
137 legacy: legacy_sha(text),
138 })
139}
140
141pub(crate) fn guarded(f: impl FnOnce() -> String) -> Result<String, ShaError> {
142 std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| ShaError::ParserPanicked)
143}
144
145#[derive(Debug, PartialEq)]
147pub enum ShaError {
148 ParserPanicked,
149}
150
151impl std::fmt::Display for ShaError {
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 match self {
154 ShaError::ParserPanicked => {
155 f.write_str("the plan's sha cannot be computed (the Markdown parser panicked)")
156 }
157 }
158 }
159}
160
161pub(crate) fn is_machine_comment(line: &str) -> bool {
162 let t = line.trim();
163 t.starts_with("<!-- panel:") && t.ends_with("-->")
164}
165
166#[derive(Default)]
168pub(crate) struct Fence {
169 pub(crate) open: Option<(char, usize)>,
170}
171
172impl Fence {
173 pub(crate) fn prose<'a>(&mut self, line: &'a str) -> Option<&'a str> {
177 let indent = line.len() - line.trim_start_matches(' ').len();
178 let t = line.trim_start_matches(' ');
179 if indent <= 3 {
180 for c in ['`', '~'] {
181 let run = t.len() - t.trim_start_matches(c).len();
182 if run >= 3 {
183 match self.open {
184 None => self.open = Some((c, run)),
185 Some((o, n)) if o == c && run >= n && t[run..].trim().is_empty() => {
186 self.open = None
187 }
188 _ => {}
189 }
190 return None;
191 }
192 }
193 }
194 if self.open.is_some() || indent > 3 {
195 return None;
196 }
197 Some(t)
198 }
199
200 pub(crate) fn h2<'a>(&mut self, line: &'a str) -> Option<&'a str> {
202 let title = self.prose(line)?.strip_prefix("## ")?;
203 Some(title.trim().trim_end_matches('#').trim_end())
204 }
205
206 pub(crate) fn heading<'a>(&mut self, line: &'a str) -> Option<&'a str> {
209 let t = self.prose(line)?;
210 let hashes = t.len() - t.trim_start_matches('#').len();
211 if hashes == 0 || hashes > 6 {
212 return None;
213 }
214 let rest = t[hashes..].strip_prefix(' ')?;
215 Some(rest.trim().trim_end_matches('#').trim_end())
216 }
217}
218
219const UI_PACKAGES: &[&str] = &[
221 "react",
222 "react-dom",
223 "react-native",
224 "react-strict-dom",
225 "@duro-app/ui",
226];
227
228pub fn package_is_ui(text: &str) -> bool {
235 let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else {
236 return true;
237 };
238 let dev = v
239 .get("scripts")
240 .and_then(|s| s.get("dev"))
241 .and_then(|d| d.as_str())
242 .is_some_and(|s| !s.trim().is_empty());
243 if dev {
244 return true;
245 }
246 let has = |section: &str, name: &str| {
247 v.get(section)
248 .and_then(|d| d.as_object())
249 .is_some_and(|d| d.contains_key(name))
250 };
251 let optional = |name: &str| {
252 v.get("peerDependenciesMeta")
253 .and_then(|m| m.get(name))
254 .and_then(|m| m.get("optional"))
255 .and_then(|o| o.as_bool())
256 .unwrap_or(false)
257 };
258 UI_PACKAGES
259 .iter()
260 .any(|name| has("dependencies", name) || (has("peerDependencies", name) && !optional(name)))
261}