cargo-rail 0.28.1

Cargo-native Rust workspace engine for affected CI, Rust source-surface analysis, compiler reuse, dependency coherence, exact-SHA releases, and split/sync
Documentation
//! Typed semantic index shared by planner evaluators.
//!
//! This module owns the lossless handoff from captured source/config facts to
//! planning policy. It deliberately contains no renderer or command invocation.

use std::collections::BTreeSet;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::config::RailConfig;
use crate::error::{RailError, RailResult};
use crate::source::{ChangeSet, SourceContentIdentity};
use crate::workspace::WorkspaceContext;

mod evidence;
mod source_features;
mod work;

pub(crate) use work::{WorkPlan, WorkPlanAuthority, build_work_plan, format_work_plan, validate_saved_work_plan};

const CONFIG_CANDIDATES: &[&str] = &["rail.toml", ".rail.toml", ".cargo/rail.toml", ".config/rail.toml"];

/// One exact effective configuration leaf whose value changed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ConfigDelta {
    pub(crate) path: String,
    pub(crate) before: Value,
    pub(crate) after: Value,
}

/// One portable changed-path fact retained from the captured source authority.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct IndexedFileChange {
    pub(crate) path: String,
    pub(crate) kind: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) relation: Option<String>,
    pub(crate) provenance: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) before: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub(crate) after: Option<String>,
}

/// One deterministic semantic view consumed by every planner evaluator.
#[derive(Debug, Clone)]
pub(crate) struct PlanningIndex {
    file_changes: Vec<IndexedFileChange>,
    config: Vec<ConfigDelta>,
}

impl PlanningIndex {
    pub(crate) fn from_worktree(ctx: &WorkspaceContext, changes: ChangeSet, base_ref: &str) -> RailResult<Self> {
        let repository_paths = changes
            .entries()
            .iter()
            .map(|change| change.path.as_path().to_path_buf())
            .collect::<Vec<_>>();
        let before = content_identities_at_ref(ctx, base_ref, &repository_paths)?;
        let capture = ctx
            .planning_source_capture()
            .ok_or_else(|| RailError::message("worktree planning index requires one sparse planning source capture"))?;
        let file_changes = changes
            .entries()
            .iter()
            .filter_map(|change| {
                let path = ctx.to_workspace_path(change.path.as_path())?;
                Some(IndexedFileChange {
                    path: crate::utils::path_to_git_format(&path),
                    kind: source_change_kind(change.kind).to_string(),
                    relation: change.relation.as_ref().map(change_relation),
                    provenance: change_provenance(change.provenance),
                    before: before.get(change.path.as_path()).map(content_identity),
                    after: capture
                        .current_content_identity(&change.path)
                        .as_ref()
                        .map(content_identity),
                })
            })
            .collect::<Vec<_>>();
        let config = effective_config_deltas(
            ctx,
            file_changes.iter().map(|change| change.path.as_str()),
            base_ref,
            None,
        )?;
        let index = Self { file_changes, config };
        index.validate()?;
        Ok(index)
    }

    pub(crate) fn from_objects(
        ctx: &WorkspaceContext,
        changes: ChangeSet,
        base_ref: &str,
        head_ref: &str,
    ) -> RailResult<Self> {
        let repository_paths = changes
            .entries()
            .iter()
            .map(|change| change.path.as_path().to_path_buf())
            .collect::<Vec<_>>();
        let before = content_identities_at_ref(ctx, base_ref, &repository_paths)?;
        let after = content_identities_at_ref(ctx, head_ref, &repository_paths)?;
        let file_changes = changes
            .entries()
            .iter()
            .filter_map(|change| {
                let path = ctx.to_workspace_path(change.path.as_path())?;
                Some(IndexedFileChange {
                    path: crate::utils::path_to_git_format(&path),
                    kind: source_change_kind(change.kind).to_string(),
                    relation: change.relation.as_ref().map(change_relation),
                    provenance: change_provenance(change.provenance),
                    before: before.get(change.path.as_path()).map(content_identity),
                    after: after.get(change.path.as_path()).map(content_identity),
                })
            })
            .collect::<Vec<_>>();
        let config = effective_config_deltas(
            ctx,
            file_changes.iter().map(|change| change.path.as_str()),
            base_ref,
            Some(head_ref),
        )?;
        let index = Self { file_changes, config };
        index.validate()?;
        Ok(index)
    }

    pub(crate) fn paths(&self) -> impl Iterator<Item = &str> {
        self.file_changes.iter().map(|change| change.path.as_str())
    }

    pub(crate) fn config_deltas(&self) -> &[ConfigDelta] {
        &self.config
    }

    fn validate(&self) -> RailResult<()> {
        if !self.config.windows(2).all(|pair| pair[0].path < pair[1].path) {
            return Err(RailError::message(
                "planning configuration deltas are not strictly sorted",
            ));
        }
        if !self.file_changes.windows(2).all(|pair| pair[0].path < pair[1].path) {
            return Err(RailError::message("planning file changes are not strictly sorted"));
        }
        Ok(())
    }
}

fn source_change_kind(kind: crate::source::SourceChangeKind) -> &'static str {
    match kind {
        crate::source::SourceChangeKind::Added => "added",
        crate::source::SourceChangeKind::Modified => "modified",
        crate::source::SourceChangeKind::TypeChanged => "type_changed",
        crate::source::SourceChangeKind::Deleted => "deleted",
    }
}

fn change_relation(relation: &crate::source::ChangeRelation) -> String {
    match relation {
        crate::source::ChangeRelation::RenamedFrom(path) => format!("renamed_from:{path}"),
        crate::source::ChangeRelation::RenamedTo(path) => format!("renamed_to:{path}"),
        crate::source::ChangeRelation::CopiedFrom(path) => format!("copied_from:{path}"),
    }
}

fn change_provenance(provenance: crate::source::ChangeProvenance) -> Vec<String> {
    [
        (crate::source::ChangeLayer::Committed, "committed"),
        (crate::source::ChangeLayer::Staged, "staged"),
        (crate::source::ChangeLayer::Unstaged, "unstaged"),
        (crate::source::ChangeLayer::Untracked, "untracked"),
    ]
    .into_iter()
    .filter(|(layer, _)| provenance.contains(*layer))
    .map(|(_, name)| name.to_string())
    .collect()
}

fn content_identity(identity: &SourceContentIdentity) -> String {
    match identity {
        SourceContentIdentity::GitObject { object_id, mode } => format!("git:{mode}:{object_id}"),
        SourceContentIdentity::Sha256 { digest, executable } => {
            format!("sha256:{digest}:executable={executable}")
        }
        SourceContentIdentity::Symlink { target } => format!("symlink:{target}"),
    }
}

fn content_identities_at_ref(
    ctx: &WorkspaceContext,
    revision: &str,
    paths: &[PathBuf],
) -> RailResult<std::collections::BTreeMap<PathBuf, SourceContentIdentity>> {
    if paths.is_empty() {
        return Ok(std::collections::BTreeMap::new());
    }
    let entries = ctx.git()?.git().collect_tree_entries_for_paths(revision, paths)?;
    Ok(entries
        .into_iter()
        .map(|entry| {
            (
                entry.path,
                SourceContentIdentity::GitObject {
                    object_id: entry.object_id,
                    mode: entry.mode,
                },
            )
        })
        .collect())
}

fn effective_config_deltas<'a>(
    ctx: &WorkspaceContext,
    changed_files: impl IntoIterator<Item = &'a str>,
    base_ref: &str,
    head_ref: Option<&str>,
) -> RailResult<Vec<ConfigDelta>> {
    let candidates = config_candidates(ctx)?;
    if !changed_files
        .into_iter()
        .any(|path| candidates.iter().any(|candidate| candidate == path))
    {
        return Ok(Vec::new());
    }

    let before = config_at_ref(ctx, base_ref, &candidates)?;
    let after = match head_ref {
        Some(head) => config_at_ref(ctx, head, &candidates)?,
        None => ctx.config().map(|config| config.as_ref().clone()).unwrap_or_default(),
    };
    config_deltas(&before, &after)
}

fn config_candidates(ctx: &WorkspaceContext) -> RailResult<Vec<String>> {
    let standard = CONFIG_CANDIDATES
        .iter()
        .map(|path| (*path).to_string())
        .collect::<Vec<_>>();
    let Some(path) = ctx.config_path() else {
        return Ok(standard);
    };
    let Ok(relative) = path.strip_prefix(ctx.workspace_root()) else {
        return Ok(standard);
    };
    let relative = crate::utils::path_to_git_format(relative);
    if standard.contains(&relative) {
        Ok(standard)
    } else {
        Ok(vec![relative])
    }
}

fn config_at_ref(ctx: &WorkspaceContext, revision: &str, candidates: &[String]) -> RailResult<RailConfig> {
    let repository_paths = candidates
        .iter()
        .map(|candidate| ctx.repository_path_from_workspace(PathBuf::from(candidate).as_path()))
        .collect::<RailResult<Vec<_>>>()?;
    let entries = ctx
        .git()?
        .git()
        .collect_tree_entries_for_paths(revision, &repository_paths)?;
    let selected = candidates.iter().find_map(|candidate| {
        entries.iter().find_map(|entry| {
            ctx.to_workspace_path(&entry.path)
                .is_some_and(|path| crate::utils::path_to_git_format(&path) == candidate.as_str())
                .then_some((candidate, entry))
        })
    });
    let Some((selected, entry)) = selected else {
        return Ok(RailConfig::default());
    };
    if !matches!(entry.mode.as_str(), "100644" | "100755") {
        return Err(RailError::message(format!(
            "historical configuration '{selected}' at {revision} must be a regular Git file"
        )));
    }
    let bytes = ctx.git()?.git().read_blobs_bulk(&[&entry.object_id])?;
    let read_manifest = |package_root: &std::path::Path| -> RailResult<Vec<u8>> {
        let manifest = package_root.join("Cargo.toml");
        let manifest = ctx.repository_path_from_workspace(&manifest)?;
        let entry = ctx
            .git()?
            .git()
            .tree_entry(revision, &manifest)?
            .ok_or_else(|| RailError::message(format!("historical configuration has no {}", manifest.display())))?;
        if !matches!(entry.mode.as_str(), "100644" | "100755") {
            return Err(RailError::message(format!(
                "historical manifest '{}' must be a regular Git file",
                manifest.display()
            )));
        }
        let mut contents = ctx.git()?.git().read_blobs_bulk(&[&entry.object_id])?;
        contents
            .pop()
            .ok_or_else(|| RailError::message(format!("historical configuration has no {}", manifest.display())))
    };
    let decode = || -> RailResult<RailConfig> {
        let decoded = crate::config::decode(&bytes[0])?;
        decoded.config.validate_policy()?;
        if let Some(host) = decoded.config.unify.transitive_host_path() {
            read_manifest(std::path::Path::new(host))?;
        }
        Ok(decoded.config)
    };
    decode().map_err(|error| error.context(format!("historical configuration '{selected}' at {revision}")))
}

fn config_deltas(before: &RailConfig, after: &RailConfig) -> RailResult<Vec<ConfigDelta>> {
    let before = serde_json::to_value(before)
        .map_err(|error| RailError::message(format!("failed to serialize base configuration: {error}")))?;
    let after = serde_json::to_value(after)
        .map_err(|error| RailError::message(format!("failed to serialize current configuration: {error}")))?;
    let mut deltas = Vec::new();
    collect_config_deltas("", &before, &after, &mut deltas);
    Ok(deltas)
}

fn collect_config_deltas(path: &str, before: &Value, after: &Value, output: &mut Vec<ConfigDelta>) {
    if before == after {
        return;
    }
    if let (Some(before), Some(after)) = (before.as_object(), after.as_object()) {
        let keys = before.keys().chain(after.keys()).collect::<BTreeSet<_>>();
        for key in keys {
            let child = if path.is_empty() {
                key.clone()
            } else {
                format!("{path}.{key}")
            };
            collect_config_deltas(
                &child,
                before.get(key).unwrap_or(&Value::Null),
                after.get(key).unwrap_or(&Value::Null),
                output,
            );
        }
        return;
    }
    output.push(ConfigDelta {
        path: path.to_string(),
        before: before.clone(),
        after: after.clone(),
    });
}

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

    #[test]
    fn effective_config_delta_names_only_changed_schema_leaves() {
        let before = crate::config::decode(b"[release]\nsemver_check = \"warn\"\n\n[surface]\nenabled = true\n")
            .unwrap()
            .config;
        let after = crate::config::decode(b"[release]\nsemver_check = \"off\"\n\n[surface]\nenabled = true\n")
            .unwrap()
            .config;
        let deltas = config_deltas(&before, &after).unwrap();
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].path, "release.semver_check");
        assert_eq!(deltas[0].before, "warn");
        assert_eq!(deltas[0].after, "off");
    }
}