pray-cli 1.20.0

Package manager for the language placed before inference — Prayfile CLI (binary: pray)
use pray_core::lockfile::{lockfiles_equivalent, Lockfile};
use pray_core::render::{
    provisioned_destination_statuses, ProvisionedDestinationStatus, RenderedTarget,
};
use pray_core::resolve::ResolvedProject;
use pray_core::verify::{inspect_project, VerificationFinding};
use pray_core::PrayResult;
use std::path::{Path, PathBuf};

#[path = "apply_report_lines.rs"]
mod lines;
pub(crate) use lines::outdated_local_lines;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterializationMode {
    Plan,
    Install,
    Apply,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockfileChange {
    Unchanged,
    Updated,
    Created,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetChange {
    Unchanged,
    Updated,
    Written,
}

#[derive(Debug, Clone)]
pub struct MaterializationPreview {
    pub package_lines: Vec<String>,
    pub lockfile: LockfileChange,
    pub targets: Vec<(PathBuf, TargetChange)>,
    pub provisioned: Vec<(PathBuf, TargetChange)>,
    pub warnings: Vec<String>,
}

pub fn build_materialization_preview(
    project: &ResolvedProject,
    rendered: &[RenderedTarget],
    lockfile: &Lockfile,
    lockfile_path: &Path,
    previous_lockfile: Option<&Lockfile>,
) -> PrayResult<MaterializationPreview> {
    let package_lines = lines::materialization_summary_lines(previous_lockfile, lockfile, project);
    let lockfile_change = lockfile_change_status(lockfile_path, lockfile)?;
    let targets = rendered
        .iter()
        .map(|target| {
            let path = project.project_root.join(&target.path);
            let change = target_change_status(&path, &target.content);
            Ok((target.path.clone(), change))
        })
        .collect::<PrayResult<Vec<_>>>()?;
    let provisioned = provisioned_destination_statuses(project, previous_lockfile)?
        .into_iter()
        .map(|(file, status)| {
            let change = match status {
                ProvisionedDestinationStatus::Missing => TargetChange::Written,
                ProvisionedDestinationStatus::Unchanged => TargetChange::Unchanged,
                ProvisionedDestinationStatus::ManagedUpdate => TargetChange::Updated,
            };
            Ok((file.path, change))
        })
        .collect::<PrayResult<Vec<_>>>()?;
    let warnings = pre_apply_warnings(project, previous_lockfile, rendered, &targets)?;

    Ok(MaterializationPreview {
        package_lines,
        lockfile: lockfile_change,
        targets,
        provisioned,
        warnings,
    })
}

pub fn materialization_preview_to_json(
    preview: &MaterializationPreview,
    mode: MaterializationMode,
) -> serde_json::Value {
    serde_json::json!({
        "mode": mode.heading().to_lowercase(),
        "packages": preview.package_lines,
        "lockfile": lockfile_change_label(preview.lockfile),
        "targets": preview
            .targets
            .iter()
            .map(|(path, change)| {
                serde_json::json!({
                    "path": path,
                    "change": target_change_label(*change),
                })
            })
            .collect::<Vec<_>>(),
        "provisioned": preview
            .provisioned
            .iter()
            .map(|(path, change)| {
                serde_json::json!({
                    "path": path,
                    "change": target_change_label(*change),
                })
            })
            .collect::<Vec<_>>(),
        "warnings": preview.warnings,
    })
}

fn lockfile_change_label(change: LockfileChange) -> &'static str {
    match change {
        LockfileChange::Unchanged => "unchanged",
        LockfileChange::Updated => "updated",
        LockfileChange::Created => "created",
    }
}

fn target_change_label(change: TargetChange) -> &'static str {
    match change {
        TargetChange::Unchanged => "unchanged",
        TargetChange::Updated => "updated",
        TargetChange::Written => "written",
    }
}

pub fn print_materialization_report(preview: &MaterializationPreview, mode: MaterializationMode) {
    println!("{}...", mode.heading());

    for line in &preview.package_lines {
        println!("{line}");
    }

    println!("{}", lockfile_line(preview.lockfile, mode));

    for (path, change) in &preview.targets {
        println!("{} {}", path.display(), target_verb(*change, mode));
    }

    for (path, change) in &preview.provisioned {
        if *change == TargetChange::Unchanged {
            continue;
        }
        println!("{} {}", path.display(), target_verb(*change, mode));
    }

    if !preview.warnings.is_empty() {
        println!();
        println!("Warnings");
        for warning in &preview.warnings {
            println!("  {warning}");
        }
    }

    println!();
    println!("{}", lines::summary_footer(preview, mode));
}

impl MaterializationMode {
    fn heading(self) -> &'static str {
        match self {
            Self::Plan => "Plan",
            Self::Install => "Installing",
            Self::Apply => "Applying",
        }
    }

    fn dry_run(self) -> bool {
        matches!(self, Self::Plan)
    }

    fn completion_label(self) -> &'static str {
        match self {
            Self::Plan => "Plan complete",
            Self::Install => "Install complete",
            Self::Apply => "Apply complete",
        }
    }
}

fn lockfile_change_status(path: &Path, lockfile: &Lockfile) -> PrayResult<LockfileChange> {
    if !path.exists() {
        return Ok(LockfileChange::Created);
    }
    match pray_core::lockfile::read_lockfile(path) {
        Ok(existing) if lockfiles_equivalent(lockfile, &existing) => Ok(LockfileChange::Unchanged),
        Ok(_) | Err(_) => Ok(LockfileChange::Updated),
    }
}

fn target_change_status(path: &Path, content: &str) -> TargetChange {
    match pray_core::render::read_destination_text(path) {
        Ok(existing) if existing == content => TargetChange::Unchanged,
        Ok(_) => TargetChange::Updated,
        Err(_) => TargetChange::Written,
    }
}

fn pre_apply_warnings(
    project: &ResolvedProject,
    previous_lockfile: Option<&Lockfile>,
    rendered: &[RenderedTarget],
    targets: &[(PathBuf, TargetChange)],
) -> PrayResult<Vec<String>> {
    let mut warnings = Vec::new();
    let Some(previous_lockfile) = previous_lockfile else {
        return Ok(warnings);
    };

    let report = inspect_project(project, previous_lockfile)?;
    for finding in report.findings {
        if warning_applies_to_materialization(&finding, rendered, targets) {
            warnings.push(warning_message(&finding));
        }
    }

    for target in rendered {
        let path = project.project_root.join(&target.path);
        let change = target_change_status(&path, &target.content);
        if change == TargetChange::Unchanged {
            continue;
        }
        if let Ok(existing) = pray_core::render::read_destination_text(&path) {
            if existing != target.content
                && !warnings
                    .iter()
                    .any(|warning| warning.contains(target.path.to_string_lossy().as_ref()))
            {
                warnings.push(format!(
                    "{} has local edits outside tracked managed spans and will be overwritten",
                    target.path.display()
                ));
            }
        }
    }

    Ok(warnings)
}

fn warning_applies_to_materialization(
    finding: &VerificationFinding,
    rendered: &[RenderedTarget],
    targets: &[(PathBuf, TargetChange)],
) -> bool {
    let changes_target = |target_path: &str| {
        targets.iter().any(|(path, change)| {
            *change != TargetChange::Unchanged && path.to_string_lossy() == target_path
        })
    };
    let will_render = |target_path: &str| {
        rendered
            .iter()
            .any(|target| target.path.to_string_lossy() == target_path)
    };

    match finding.kind.as_str() {
        "custom_implementation" | "removed_prayer" | "position_drift" | "orphan_marker" => finding
            .message
            .split('`')
            .nth(1)
            .is_some_and(|target_path| changes_target(target_path) || will_render(target_path)),
        "renderer_drift" => finding
            .message
            .split_whitespace()
            .next()
            .is_some_and(changes_target),
        _ => false,
    }
}

fn warning_message(finding: &VerificationFinding) -> String {
    match finding.kind.as_str() {
        "custom_implementation" => format!("Conflict: {}", finding.message),
        "removed_prayer" => format!("Conflict: {}", finding.message),
        "position_drift" => format!("Conflict: {}", finding.message),
        "orphan_marker" => format!("Warning: {}", finding.message),
        "renderer_drift" => format!("Warning: {}", finding.message),
        _ => finding.message.clone(),
    }
}

fn lockfile_line(change: LockfileChange, mode: MaterializationMode) -> String {
    let dry_run = mode.dry_run();
    match (change, dry_run) {
        (LockfileChange::Unchanged, _) => "Prayfile.lock unchanged".to_string(),
        (LockfileChange::Created, true) => "Prayfile.lock would be created".to_string(),
        (LockfileChange::Created, false) => "Prayfile.lock created".to_string(),
        (LockfileChange::Updated, true) => "Prayfile.lock would be updated".to_string(),
        (LockfileChange::Updated, false) => "Prayfile.lock updated".to_string(),
    }
}

fn target_verb(change: TargetChange, mode: MaterializationMode) -> &'static str {
    match (change, mode.dry_run()) {
        (TargetChange::Unchanged, _) => "unchanged",
        (TargetChange::Updated, true) => "would be updated",
        (TargetChange::Updated, false) => "updated",
        (TargetChange::Written, true) => "would be written",
        (TargetChange::Written, false) => "written",
    }
}

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

    #[test]
    fn target_verb_uses_past_tense_for_apply() {
        assert_eq!(
            target_verb(TargetChange::Updated, MaterializationMode::Apply),
            "updated"
        );
        assert_eq!(
            target_verb(TargetChange::Written, MaterializationMode::Apply),
            "written"
        );
    }

    #[test]
    fn target_verb_uses_plan_tense_for_dry_run() {
        assert_eq!(
            target_verb(TargetChange::Updated, MaterializationMode::Plan),
            "would be updated"
        );
        assert_eq!(
            target_verb(TargetChange::Written, MaterializationMode::Plan),
            "would be written"
        );
    }

    #[test]
    fn plan_lists_each_provisioned_path() {
        let preview = MaterializationPreview {
            package_lines: Vec::new(),
            lockfile: LockfileChange::Unchanged,
            targets: Vec::new(),
            provisioned: vec![
                (
                    PathBuf::from(".agents/skills/audit/SKILL.md"),
                    TargetChange::Written,
                ),
                (
                    PathBuf::from(".agents/skills/audit/details.md"),
                    TargetChange::Written,
                ),
            ],
            warnings: Vec::new(),
        };
        assert_eq!(
            preview
                .provisioned
                .iter()
                .filter(|(_, change)| *change != TargetChange::Unchanged)
                .count(),
            2
        );
        assert_eq!(
            target_verb(TargetChange::Written, MaterializationMode::Plan),
            "would be written"
        );
    }
}