pray-core 1.17.0

Core library for Prayfile, the package manager for the language placed before inference
Documentation
use crate::hashing::checksum_managed_body_line_refs;
use std::collections::BTreeMap;

#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
    Text(String),
    Managed { id: String, body: String },
}

/// Preserve unmarked text from `existing` while replacing managed spans from `fresh`.
///
/// When `existing` has no managed spans that overlap `fresh`, return `fresh` wholesale
/// so corrupted or empty destinations can be repaired by a full rewrite.
/// When `fresh` introduces managed spans before the first shared id, take that
/// leading region from `fresh` so local compose sources can gain markers and update.
pub fn patch_rendered_content(existing: &str, fresh: &str) -> String {
    let existing_segments = split_segments(existing);
    let fresh_segments = split_segments(fresh);
    let fresh_managed: BTreeMap<String, String> = fresh_segments
        .iter()
        .filter_map(|segment| match segment {
            Segment::Managed { id, body } => Some((id.clone(), body.clone())),
            Segment::Text(_) => None,
        })
        .collect();
    let existing_ids: std::collections::BTreeSet<&str> = existing_segments
        .iter()
        .filter_map(|segment| match segment {
            Segment::Managed { id, .. } => Some(id.as_str()),
            Segment::Text(_) => None,
        })
        .collect();
    let existing_overlap = existing_segments.iter().any(|segment| match segment {
        Segment::Managed { id, .. } => fresh_managed.contains_key(id),
        Segment::Text(_) => false,
    });
    if !existing_overlap {
        return fresh.to_string();
    }
    let mut used = std::collections::BTreeSet::new();
    let mut output = String::new();
    let remaining_existing = if fresh_managed
        .keys()
        .any(|id| !existing_ids.contains(id.as_str()))
    {
        let Some(shared_id) = fresh_segments.iter().find_map(|segment| match segment {
            Segment::Managed { id, .. } if existing_ids.contains(id.as_str()) => Some(id.as_str()),
            _ => None,
        }) else {
            return fresh.to_string();
        };
        for segment in &fresh_segments {
            match segment {
                Segment::Managed { id, .. } if id == shared_id => break,
                Segment::Text(text) => output.push_str(text),
                Segment::Managed { id, body } => {
                    used.insert(id.clone());
                    push_managed_span(&mut output, id, body);
                }
            }
        }
        skip_until_managed(&existing_segments, shared_id)
    } else {
        existing_segments.as_slice()
    };
    for segment in remaining_existing {
        match segment {
            Segment::Text(text) => output.push_str(text),
            Segment::Managed { id, body } => {
                let replacement = fresh_managed
                    .get(id)
                    .cloned()
                    .unwrap_or_else(|| body.clone());
                used.insert(id.clone());
                push_managed_span(&mut output, id, &replacement);
            }
        }
    }
    for segment in fresh_segments {
        if let Segment::Managed { id, body } = segment {
            if used.contains(&id) {
                continue;
            }
            push_managed_span(&mut output, &id, &body);
        }
    }
    if !output.ends_with('\n') {
        output.push('\n');
    }
    output
}

fn skip_until_managed<'a>(segments: &'a [Segment], id: &str) -> &'a [Segment] {
    match segments.iter().position(|segment| match segment {
        Segment::Managed { id: found, .. } => found == id,
        Segment::Text(_) => false,
    }) {
        Some(index) => &segments[index..],
        None => segments,
    }
}

fn push_managed_span(output: &mut String, id: &str, body: &str) {
    output.push_str(&format!("<!-- pray:{id} -->\n"));
    if !body.is_empty() {
        output.push_str(body.trim_end_matches('\n'));
        output.push('\n');
    }
    output.push_str(&format!("<!-- pray:{id} -->\n"));
}

fn split_segments(content: &str) -> Vec<Segment> {
    let lines: Vec<&str> = content.lines().collect();
    let mut segments = Vec::new();
    let mut text = String::new();
    let mut index = 0usize;
    while index < lines.len() {
        if let Some(id) = marker_id(lines[index]) {
            if let Some(close) = find_closing_marker(&lines, index + 1, &id) {
                if !text.is_empty() {
                    segments.push(Segment::Text(std::mem::take(&mut text)));
                }
                let body_lines = &lines[index + 1..close];
                let body = if body_lines.is_empty() {
                    String::new()
                } else {
                    let mut body = body_lines.join("\n");
                    body.push('\n');
                    body
                };
                let _ = checksum_managed_body_line_refs(body_lines);
                segments.push(Segment::Managed { id, body });
                index = close + 1;
                continue;
            }
        }
        text.push_str(lines[index]);
        text.push('\n');
        index += 1;
    }
    if !text.is_empty() {
        segments.push(Segment::Text(text));
    }
    segments
}

fn find_closing_marker(lines: &[&str], start: usize, id: &str) -> Option<usize> {
    lines
        .iter()
        .enumerate()
        .skip(start)
        .find_map(|(index, line)| (marker_id(line).as_deref() == Some(id)).then_some(index))
}

fn marker_id(line: &str) -> Option<String> {
    let trimmed = line.trim();
    let remainder = trimmed.strip_prefix("<!-- pray:")?;
    let id = remainder.strip_suffix(" -->")?;
    if id == "0 ignore-comments" {
        return None;
    }
    Some(id.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn preserves_unmarked_text_and_updates_managed_body() {
        let existing = "\
## Shared instructions

User note: keep this line.

<!-- pray:abc123 -->
old body
<!-- pray:abc123 -->
";
        let fresh = "\
## Shared instructions

<!-- pray:abc123 -->
new body
<!-- pray:abc123 -->
";
        let patched = patch_rendered_content(existing, fresh);
        assert!(patched.contains("User note: keep this line."));
        assert!(patched.contains("new body"));
        assert!(!patched.contains("old body"));
    }

    #[test]
    fn rewrites_wholesale_when_existing_has_no_managed_overlap() {
        let existing = "broken rendered output\n";
        let fresh = "\
<!-- pray:abc123 -->
new body
<!-- pray:abc123 -->
";
        assert_eq!(patch_rendered_content(existing, fresh), fresh);
    }

    #[test]
    fn inserts_leading_managed_span_from_fresh_and_drops_stale_unmarked_embed() {
        let existing = "\
header

old local body

<!-- pray:abc123 -->
package body
<!-- pray:abc123 -->
";
        let fresh = "\
header

<!-- pray:local001 -->
new local body
<!-- pray:local001 -->

<!-- pray:abc123 -->
package body
<!-- pray:abc123 -->
";
        let patched = patch_rendered_content(existing, fresh);
        assert!(patched.contains("new local body"));
        assert!(!patched.contains("old local body"));
        assert!(patched.contains("<!-- pray:local001 -->"));
        assert!(patched.contains("package body"));
    }
}