pipecircuit-cli 0.0.13

Command-line architectural tools for Pipecircuit programs
use std::{
    collections::{BTreeMap, BTreeSet},
    fs,
    path::PathBuf,
};

use super::{
    ArchError,
    model::{Architecture, Finding, Package, Role},
};

fn finding(
    package: &Package,
    code: &'static str,
    explanation: String,
    guidance: &'static str,
) -> Finding {
    Finding {
        code,
        manifest: package.manifest.clone(),
        subject: package.name.clone(),
        explanation,
        guidance,
    }
}

pub(super) fn validate(architecture: &mut Architecture) -> Result<(), ArchError> {
    let template = include_str!("../../template/Cargo.toml.in");
    let (_, lint_source) = template
        .split_once("[workspace.lints.rust]")
        .ok_or(ArchError::Capacity)?;
    let template_lints: toml::Value =
        toml::from_str(&format!("[workspace.lints.rust]{lint_source}"))
            .map_err(|error| ArchError::Manifest(PathBuf::from("embedded starter lints"), error))?;
    let required_lints = template_lints
        .get("workspace")
        .and_then(|workspace| workspace.get("lints"))
        .ok_or(ArchError::Capacity)?;
    let mut findings = Vec::new();
    let mut sources = BTreeMap::new();
    let mut adjacency: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
    for package in architecture.packages.values() {
        let Some(declaration) = &package.declaration else {
            findings.push(finding(package, "arch.role.missing", "Project crate has no architectural role.".to_owned(), "Use a canonical location or declare package.metadata.pipecircuit in its Cargo manifest."));
            continue;
        };
        if declaration.role.pure() {
            if let Some(path) = &package.library {
                if !sources.contains_key(path) {
                    let text = fs::read_to_string(path)
                        .map_err(|error| ArchError::Read(path.clone(), error))?;
                    let syntax = syn::parse_file(&text)
                        .map_err(|error| ArchError::Rust(path.clone(), error))?;
                    let no_std = syntax.attrs.iter().any(|attribute| {
                        attribute.path().is_ident("no_std")
                            && matches!(attribute.style, syn::AttrStyle::Inner(_))
                    });
                    sources.insert(path.clone(), no_std);
                }
                if sources.get(path) != Some(&true) {
                    findings.push(finding(package, "arch.purity.no-std", format!("Pure library {} has no unconditional #![no_std] declaration.", path.display()), "Keep semantic libraries no_std. Compile supported targets separately to verify feature and dependency compatibility."));
                }
            } else {
                findings.push(finding(
                    package,
                    "arch.purity.library",
                    "A pure role requires a Rust library target.".to_owned(),
                    "Expose the semantic code as a no_std library.",
                ));
            }
            let local = package.document.get("lints");
            let inherited = local
                .and_then(|lints| lints.get("workspace"))
                .and_then(toml::Value::as_bool)
                == Some(true);
            let lints = if inherited {
                Some(package.workspace_lints.as_ref())
            } else {
                local
            };
            for (family, rules) in required_lints.as_table().into_iter().flatten() {
                for (rule, expected) in rules.as_table().into_iter().flatten() {
                    let value = lints
                        .and_then(|lints| lints.get(family))
                        .and_then(|rules| rules.get(rule));
                    let expected_level = lint_level(expected);
                    let expected_label = expected_level.ok_or_else(|| {
                        ArchError::Configuration(
                            PathBuf::from("embedded starter lints"),
                            format!("Missing lint level for {family}.{rule}"),
                        )
                    })?;
                    let actual_level = value.and_then(lint_level);
                    let strong_enough = actual_level == expected_level
                        || actual_level == Some("forbid")
                        || (expected_level == Some("warn") && actual_level == Some("deny"));
                    let configuration_matches = expected.get("check-cfg").is_none_or(|required| {
                        value.and_then(|value| value.get("check-cfg")) == Some(required)
                    });
                    if !strong_enough || !configuration_matches {
                        findings.push(finding(package, "arch.purity.lint", format!("{family}.{rule} must retain the canonical {expected_label} level and check-cfg configuration."), "Inherit the canonical workspace lints or declare equivalent strictness."));
                    }
                }
            }
        }
        let edges = adjacency.entry(package.manifest.clone()).or_default();
        for dependency in package
            .dependencies
            .iter()
            .filter(|dependency| dependency.kind != "dev")
        {
            architecture.work.dependency_edges_checked = architecture
                .work
                .dependency_edges_checked
                .checked_add(1)
                .ok_or(ArchError::Capacity)?;
            let target = dependency.path.as_ref().map(|path| path.join("Cargo.toml"));
            if let Some(target) = target
                .as_ref()
                .and_then(|target| architecture.packages.get(target))
            {
                edges.push(target.manifest.clone());
                if target
                    .declaration
                    .as_ref()
                    .is_some_and(|target| !declaration.allows(target))
                {
                    findings.push(finding(package, "arch.dependency.outward", format!("{} → {} crosses the permitted role/application boundary (kind: {}, target: {}, optional: {}).", package.name, target.name, dependency.kind, dependency.target.as_deref().unwrap_or("all"), dependency.optional), "Move shared semantic types inward; keep physical implementations in wiring and routing in the owning application."));
                }
            } else if target
                .as_ref()
                .is_some_and(|target| target.starts_with(&architecture.root))
            {
                findings.push(finding(package, "arch.dependency.unclassified", format!("Local dependency {} is outside the inspected workspaces.", dependency.name), "List its workspace manifest in workspace.metadata.pipecircuit.workspaces; local project dependencies cannot silently escape analysis."));
            } else if matches!(declaration.role, Role::Pipes | Role::Switchboard) {
                let alias = dependency.alias.as_deref().unwrap_or(&dependency.name);
                let forwarded = package
                    .document
                    .get("features")
                    .and_then(toml::Value::as_table)
                    .into_iter()
                    .flat_map(|features| features.values())
                    .filter_map(toml::Value::as_array)
                    .flatten()
                    .filter_map(toml::Value::as_str)
                    .filter_map(|feature| feature.split_once('/'))
                    .filter(|(name, _)| name.trim_end_matches('?') == alias)
                    .map(|(_, feature)| feature);
                let permitted = forwarded.into_iter().all(semantic_feature)
                    && dependency.name == "pipecircuit"
                    && !dependency.default_features
                    && dependency
                        .features
                        .iter()
                        .all(|feature| semantic_feature(feature));
                if !permitted {
                    findings.push(finding(package, "arch.dependency.external", format!("Semantic boundary depends on {} with an unsupported external capability configuration.", dependency.name), "Keep application contracts and routing dependent on core and no_std Pipecircuit capabilities. Put reusable domain dependencies in core and physical capabilities in wiring."));
                }
            }
        }
    }
    for package in architecture.packages.values() {
        let Some(declaration) = &package.declaration else {
            continue;
        };
        for forbidden in &declaration.forbid_dependencies {
            let candidates: Vec<_> = architecture
                .packages
                .values()
                .filter(|target| &target.name == forbidden)
                .collect();
            if candidates.len() != 1 {
                findings.push(finding(package, "arch.isolation.target", format!("Dependency restriction target {forbidden} does not identify exactly one inspected package."), "Name an inspected package; stale or ambiguous isolation rules are errors."));
                continue;
            }
            let mut pending = vec![&package.manifest];
            let mut visited = BTreeSet::new();
            while let Some(path) = pending.pop() {
                if !visited.insert(path) {
                    continue;
                }
                if architecture
                    .packages
                    .get(path)
                    .is_some_and(|target| &target.name == forbidden)
                {
                    findings.push(finding(package, "arch.isolation.reachable", format!("{} can reach forbidden execution dependency {forbidden}.", package.name), "Cross the worker boundary through its message contract instead of linking its execution implementation."));
                    break;
                }
                if let Some(edges) = adjacency.get(path) {
                    pending.extend(edges);
                }
            }
        }
    }
    architecture.work.sources_read = sources.len();
    architecture.findings.extend(findings);
    architecture.findings.sort_by(|left, right| {
        (&left.manifest, left.code, &left.explanation).cmp(&(
            &right.manifest,
            right.code,
            &right.explanation,
        ))
    });
    Ok(())
}

fn lint_level(value: &toml::Value) -> Option<&str> {
    value
        .as_str()
        .or_else(|| value.get("level").and_then(toml::Value::as_str))
}

fn semantic_feature(feature: &str) -> bool {
    matches!(feature, "programs" | "secondary-indexes" | "serde")
}