pray-cli 1.18.0

Package manager for the language placed before inference — Prayfile CLI (binary: pray)
use pray_core::lockfile::Lockfile;
use pray_core::manifest::ManifestPackage;
use pray_core::resolve::ResolvedProject;

pub(crate) fn materialization_summary_lines(
    previous: Option<&Lockfile>,
    updated: &Lockfile,
    project: &ResolvedProject,
) -> Vec<String> {
    let mut lines = local_summary_lines(previous, project);
    lines.extend(package_summary_lines(previous, updated, project));
    lines
}

fn local_summary_lines(previous: Option<&Lockfile>, project: &ResolvedProject) -> Vec<String> {
    let previous_checksums = previous_local_checksums(previous);
    let mut lines = Vec::new();
    for local in &project.local_files {
        if local.content.is_empty() && local.optional {
            continue;
        }
        let checksum = local.source_checksum.as_str();
        match previous_checksums.get(local.manifest_path.as_str()) {
            None => lines.push(format!("Installing {} ({checksum})", local.manifest_path)),
            Some(previous) if *previous == checksum => lines.push(format!(
                "Using {} ({checksum} checked)",
                local.manifest_path
            )),
            Some(previous) => lines.push(format!(
                "Updating {} ({checksum} was {previous})",
                local.manifest_path
            )),
        }
    }
    lines
}

fn previous_local_checksums(previous: Option<&Lockfile>) -> std::collections::BTreeMap<&str, &str> {
    previous
        .into_iter()
        .flat_map(|lockfile| lockfile.managed_span.iter())
        .filter(|span| span.package == pray_core::hashing::LOCAL_EMBED_PACKAGE)
        .map(|span| (span.export.as_str(), span.source_checksum.as_str()))
        .collect()
}

fn package_summary_lines(
    previous: Option<&Lockfile>,
    updated: &Lockfile,
    project: &ResolvedProject,
) -> Vec<String> {
    let previous_versions: std::collections::BTreeMap<&str, &str> = previous
        .into_iter()
        .flat_map(|lockfile| lockfile.package.iter())
        .map(|package| (package.name.as_str(), package.version.as_str()))
        .collect();
    let sources: std::collections::BTreeMap<&str, String> = project
        .packages
        .iter()
        .map(|package| {
            (
                package.declaration.name.as_str(),
                package_source_label(&package.declaration),
            )
        })
        .collect();

    let mut lines = Vec::new();
    for package in &updated.package {
        let source = sources
            .get(package.name.as_str())
            .cloned()
            .unwrap_or_else(|| "unknown".to_string());
        match previous_versions.get(package.name.as_str()) {
            None => lines.push(format!(
                "Installing {} {} ({source})",
                package.name, package.version
            )),
            Some(previous_version) if *previous_version == package.version => lines.push(format!(
                "Using {} {} ({source})",
                package.name, package.version
            )),
            Some(previous_version) => lines.push(format!(
                "Using {} {} (was {previous_version}) ({source})",
                package.name, package.version
            )),
        }
    }
    lines
}

fn package_source_label(declaration: &ManifestPackage) -> String {
    if let Some(path) = &declaration.path {
        return format!("path:{path}");
    }
    if let Some(source) = &declaration.source {
        return format!("source:{source}");
    }
    "default".to_string()
}

pub(crate) fn outdated_local_lines(
    previous: Option<&Lockfile>,
    project: &ResolvedProject,
) -> Vec<String> {
    let previous_checksums = previous_local_checksums(previous);
    let mut lines = Vec::new();
    for local in &project.local_files {
        if local.content.is_empty() && local.optional {
            continue;
        }
        let checksum = local.source_checksum.as_str();
        match previous_checksums.get(local.manifest_path.as_str()) {
            Some(previous) if *previous != checksum => {
                lines.push(format!("{} {previous} -> {checksum}", local.manifest_path))
            }
            None if previous.is_some() => {
                lines.push(format!("{} (new) -> {checksum}", local.manifest_path))
            }
            _ => {}
        }
    }
    lines
}

pub(crate) fn summary_footer(
    preview: &super::MaterializationPreview,
    mode: super::MaterializationMode,
) -> String {
    let inventory = inventory_phrase(preview);
    let changed_targets = preview
        .targets
        .iter()
        .filter(|(_, change)| *change != super::TargetChange::Unchanged)
        .count();
    let changed_provisioned = preview
        .provisioned
        .iter()
        .filter(|(_, change)| *change != super::TargetChange::Unchanged)
        .count();
    let lockfile_changed = preview.lockfile != super::LockfileChange::Unchanged;
    let has_warnings = !preview.warnings.is_empty();

    if changed_targets == 0 && changed_provisioned == 0 && !lockfile_changed && !has_warnings {
        return format!(
            "{}. {inventory}, everything up to date.",
            mode.completion_label()
        );
    }

    let mut parts = Vec::new();
    if lockfile_changed {
        parts.push("lockfile changed".to_string());
    }
    if changed_targets > 0 {
        parts.push(format!(
            "{changed_targets} target file{} changed",
            if changed_targets == 1 { "" } else { "s" }
        ));
    }
    if changed_provisioned > 0 {
        parts.push(format!(
            "{changed_provisioned} provisioned file{} changed",
            if changed_provisioned == 1 { "" } else { "s" }
        ));
    }
    if has_warnings {
        parts.push(format!(
            "{} warning{}",
            preview.warnings.len(),
            if preview.warnings.len() == 1 { "" } else { "s" }
        ));
    }

    let detail = parts.join(", ");
    format!("{}. {inventory}, {detail}.", mode.completion_label())
}

fn inventory_phrase(preview: &super::MaterializationPreview) -> String {
    let local_count = preview
        .package_lines
        .iter()
        .filter(|line| line.contains("(sha256:"))
        .count();
    let package_count = preview.package_lines.len().saturating_sub(local_count);
    let mut parts = vec![format!("{package_count} packages")];
    if local_count > 0 {
        parts.push(format!(
            "{local_count} local file{}",
            if local_count == 1 { "" } else { "s" }
        ));
    }
    parts.join(", ")
}

#[cfg(test)]
mod tests {
    use super::super::{LockfileChange, MaterializationMode, MaterializationPreview, TargetChange};
    use super::summary_footer;
    use std::path::PathBuf;

    #[test]
    fn summary_reports_up_to_date_when_nothing_changes() {
        let preview = MaterializationPreview {
            package_lines: vec!["Using sample/base 1.0.0 (path:packages/base)".to_string()],
            lockfile: LockfileChange::Unchanged,
            targets: vec![(PathBuf::from("INSTRUCTIONS.md"), TargetChange::Unchanged)],
            provisioned: Vec::new(),
            warnings: Vec::new(),
        };

        assert!(
            summary_footer(&preview, MaterializationMode::Apply).contains("everything up to date")
        );
    }

    #[test]
    fn summary_counts_local_checksum_lines_apart_from_packages() {
        let preview = MaterializationPreview {
            package_lines: vec![
                "Using .agents/project.md (sha256:abc checked)".to_string(),
                "Using sample/base 1.0.0 (path:packages/base)".to_string(),
            ],
            lockfile: LockfileChange::Unchanged,
            targets: vec![(PathBuf::from("AGENTS.md"), TargetChange::Unchanged)],
            provisioned: Vec::new(),
            warnings: Vec::new(),
        };

        assert_eq!(
            summary_footer(&preview, MaterializationMode::Install),
            "Install complete. 1 packages, 1 local file, everything up to date."
        );
    }
}