use amont_agent_guard::sha256::{hex, sha256};
pub(crate) const REVIEW_SECTION: &str = "Review panel";
const FULL_REVIEWS: &str = "Full reviews";
#[cfg(test)]
thread_local! {
pub(crate) static PARSER_PANICS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub fn canonical(text: &str) -> String {
let text = text.replace("\r\n", "\n");
let lines: Vec<&str> = text.split('\n').collect();
let comment = lines
.iter()
.rposition(|l| !l.trim().is_empty())
.filter(|&i| is_machine_comment(lines[i]));
let mut start = front_matter_end(&lines);
while start < lines.len() && lines[start].trim().is_empty() {
start += 1;
}
let mut out: Vec<&str> = Vec::new();
let mut fence = Fence::default();
let mut dropping = false;
for (i, line) in lines.iter().enumerate().skip(start) {
if Some(i) == comment {
continue;
}
if let Some(title) = fence.h2(line) {
if title.starts_with(FULL_REVIEWS) {
break;
}
dropping = title == REVIEW_SECTION;
}
if !dropping {
out.push(line.trim_end_matches([' ', '\t']));
}
}
let mut body = out.join("\n");
body.truncate(body.trim_end_matches(['\n', ' ', '\t']).len());
body.push('\n');
body
}
pub(crate) fn outside_body(text: &str, offset: usize) -> bool {
let lines: Vec<&str> = text.split('\n').collect();
let comment = lines
.iter()
.rposition(|l| !l.trim().is_empty())
.filter(|&i| is_machine_comment(lines[i]));
let front = front_matter_end(&lines);
let mut fence = Fence::default();
let (mut dropping, mut reviews) = (false, false);
let mut start = 0;
for (i, line) in lines.iter().enumerate() {
let end = start + line.len() + 1;
if i >= front {
if let Some(title) = fence.h2(line) {
reviews |= title.starts_with(FULL_REVIEWS);
dropping = title == REVIEW_SECTION;
}
}
if offset < end {
return i < front || Some(i) == comment || reviews || dropping;
}
start = end;
}
false
}
fn front_matter_end(lines: &[&str]) -> usize {
if lines.first().map(|l| l.trim_end()) != Some("---") {
return 0;
}
lines
.iter()
.enumerate()
.skip(1)
.find(|(_, l)| l.trim_end() == "---")
.map_or(0, |(i, _)| i + 1)
}
pub fn body_sha(text: &str) -> String {
#[cfg(test)]
if PARSER_PANICS.with(|p| p.get()) {
panic!("injected parser panic");
}
hex(&sha256(
crate::plan_words::words(&canonical(text)).as_bytes(),
))
}
pub fn legacy_sha(text: &str) -> String {
hex(&sha256(canonical(text).as_bytes()))
}
pub struct Shas {
pub sha: String,
pub legacy: String,
}
pub fn shas(text: &str) -> Result<Shas, ShaError> {
guarded(|| body_sha(text)).map(|sha| Shas {
sha,
legacy: legacy_sha(text),
})
}
pub(crate) fn guarded(f: impl FnOnce() -> String) -> Result<String, ShaError> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| ShaError::ParserPanicked)
}
#[derive(Debug, PartialEq)]
pub enum ShaError {
ParserPanicked,
}
impl std::fmt::Display for ShaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShaError::ParserPanicked => {
f.write_str("the plan's sha cannot be computed (the Markdown parser panicked)")
}
}
}
}
pub(crate) fn is_machine_comment(line: &str) -> bool {
let t = line.trim();
t.starts_with("<!-- panel:") && t.ends_with("-->")
}
#[derive(Default)]
pub(crate) struct Fence {
pub(crate) open: Option<(char, usize)>,
}
impl Fence {
pub(crate) fn prose<'a>(&mut self, line: &'a str) -> Option<&'a str> {
let indent = line.len() - line.trim_start_matches(' ').len();
let t = line.trim_start_matches(' ');
if indent <= 3 {
for c in ['`', '~'] {
let run = t.len() - t.trim_start_matches(c).len();
if run >= 3 {
match self.open {
None => self.open = Some((c, run)),
Some((o, n)) if o == c && run >= n && t[run..].trim().is_empty() => {
self.open = None
}
_ => {}
}
return None;
}
}
}
if self.open.is_some() || indent > 3 {
return None;
}
Some(t)
}
pub(crate) fn h2<'a>(&mut self, line: &'a str) -> Option<&'a str> {
let title = self.prose(line)?.strip_prefix("## ")?;
Some(title.trim().trim_end_matches('#').trim_end())
}
pub(crate) fn heading<'a>(&mut self, line: &'a str) -> Option<&'a str> {
let t = self.prose(line)?;
let hashes = t.len() - t.trim_start_matches('#').len();
if hashes == 0 || hashes > 6 {
return None;
}
let rest = t[hashes..].strip_prefix(' ')?;
Some(rest.trim().trim_end_matches('#').trim_end())
}
}
const UI_PACKAGES: &[&str] = &[
"react",
"react-dom",
"react-native",
"react-strict-dom",
"@duro-app/ui",
];
pub fn package_is_ui(text: &str) -> bool {
let Ok(v) = serde_json::from_str::<serde_json::Value>(text) else {
return true;
};
let dev = v
.get("scripts")
.and_then(|s| s.get("dev"))
.and_then(|d| d.as_str())
.is_some_and(|s| !s.trim().is_empty());
if dev {
return true;
}
let has = |section: &str, name: &str| {
v.get(section)
.and_then(|d| d.as_object())
.is_some_and(|d| d.contains_key(name))
};
let optional = |name: &str| {
v.get("peerDependenciesMeta")
.and_then(|m| m.get(name))
.and_then(|m| m.get("optional"))
.and_then(|o| o.as_bool())
.unwrap_or(false)
};
UI_PACKAGES
.iter()
.any(|name| has("dependencies", name) || (has("peerDependencies", name) && !optional(name)))
}