pipecircuit-cli 0.0.14

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

use serde::Deserialize;

use super::{
    ArchError,
    model::{Architecture, Declaration, Dependency, Package, Role, Scope, WorkCounts},
};

#[derive(Deserialize)]
struct Metadata {
    workspace_root: PathBuf,
    workspace_members: Vec<String>,
    packages: Vec<CargoPackage>,
}

#[derive(Deserialize)]
struct CargoPackage {
    id: String,
    name: String,
    manifest_path: PathBuf,
    targets: Vec<Target>,
    dependencies: Vec<CargoDependency>,
}

#[derive(Deserialize)]
struct Target {
    kind: Vec<String>,
    src_path: PathBuf,
}

#[derive(Deserialize)]
struct CargoDependency {
    name: String,
    rename: Option<String>,
    path: Option<PathBuf>,
    kind: Option<String>,
    target: Option<String>,
    optional: bool,
    uses_default_features: bool,
    features: Vec<String>,
}

#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Project {
    root: Option<PathBuf>,
    workspaces: Vec<PathBuf>,
}

fn document(
    path: &Path,
    cache: &mut BTreeMap<PathBuf, Rc<toml::Value>>,
) -> Result<Rc<toml::Value>, ArchError> {
    if let Some(value) = cache.get(path) {
        return Ok(value.clone());
    }
    let text = fs::read_to_string(path).map_err(|error| ArchError::Read(path.to_owned(), error))?;
    let value: toml::Value =
        toml::from_str(&text).map_err(|error| ArchError::Manifest(path.to_owned(), error))?;
    let value = Rc::new(value);
    cache.insert(path.to_owned(), Rc::clone(&value));
    Ok(value)
}

fn config<'document>(value: &'document toml::Value, table: &str) -> Option<&'document toml::Value> {
    value.get(table)?.get("metadata")?.get("pipecircuit")
}

pub(super) fn infer(root: &Path, directory: &Path) -> Option<Declaration> {
    let mut parts = directory.strip_prefix(root).ok()?.components();
    let first = parts.next()?.as_os_str();
    let (role, app, scope) = if first == "core" {
        (Role::Core, None, None)
    } else if first == "apps" {
        let app = parts.next()?.as_os_str().to_str()?.to_owned();
        let part = parts.next().map(|part| part.as_os_str());
        if app == "shared" {
            if part? != "pipes" {
                return None;
            }
            (Role::Pipes, None, Some(Scope::Shared))
        } else {
            let role = match part.and_then(|part| part.to_str()) {
                Some("pipes") => Role::Pipes,
                Some("switchboard") => Role::Switchboard,
                Some("wiring") => Role::Wiring,
                None => Role::Application,
                Some(_) => return None,
            };
            (role, Some(app), None)
        }
    } else {
        return None;
    };
    Some(Declaration {
        role,
        app,
        scope,
        forbid_dependencies: Vec::new(),
    })
}

pub(super) fn find_entry(directory: &Path) -> Result<PathBuf, ArchError> {
    let directory = fs::canonicalize(directory)
        .map_err(|error| ArchError::Read(directory.to_owned(), error))?;
    let mut cache = BTreeMap::new();
    for parent in directory.ancestors() {
        let direct = parent.join("Cargo.toml");
        if direct.is_file() {
            return Ok(direct);
        }
        let mut candidates = Vec::new();
        for anchor in ["apps", "core"] {
            let manifest = parent.join(anchor).join("Cargo.toml");
            if !manifest.is_file() {
                continue;
            }
            let value = document(&manifest, &mut cache)?;
            let Some(value) = config(&value, "workspace") else {
                continue;
            };
            let project: Project = value
                .clone()
                .try_into()
                .map_err(|error| ArchError::Manifest(manifest.clone(), error))?;
            if let Some(root) = &project.root {
                let root = parent.join(anchor).join(root);
                let root = fs::canonicalize(&root).map_err(|error| ArchError::Read(root, error))?;
                if root == parent {
                    candidates.push((manifest, project));
                }
            }
        }
        if let Some((entry, project)) = candidates.first() {
            let base = entry.parent().ok_or_else(|| {
                ArchError::Configuration(entry.clone(), "Manifest needs a parent directory.".into())
            })?;
            let mut included = BTreeSet::from([entry.clone()]);
            for workspace in &project.workspaces {
                let path = base.join(workspace);
                let path = fs::canonicalize(&path).map_err(|error| ArchError::Read(path, error))?;
                included.insert(path);
            }
            if candidates
                .iter()
                .any(|(manifest, _)| !included.contains(manifest))
            {
                return Err(ArchError::Configuration(parent.to_owned(),
                    "apps/ and core/ declare separate workspace sets. Link them with workspace.metadata.pipecircuit.workspaces, or select one explicitly with --manifest-path.".into()));
            }
            return Ok(entry.clone());
        }
    }
    Err(ArchError::Configuration(directory,
        "No Cargo workspace or declared Pipecircuit project found. Run inside a Cargo workspace, use --manifest-path <Cargo.toml>, or declare workspace.metadata.pipecircuit.root in apps/Cargo.toml or core/Cargo.toml.".into()))
}

pub(super) fn load(manifest: &Path) -> Result<Architecture, ArchError> {
    let mut pending = VecDeque::from([manifest.to_owned()]);
    let mut visited = BTreeSet::new();
    let mut cache = BTreeMap::new();
    let mut resolved_paths = BTreeMap::new();
    let mut architecture = Architecture {
        schema: "pipecircuit.arch/1",
        root: PathBuf::new(),
        packages: BTreeMap::new(),
        findings: Vec::new(),
        limitations: vec![
            "Manifest architecture analysis; behavior, coverage, proofs, and performance are not checked.",
            "no_std declarations are checked; target compilation and external dependency feature compatibility require separate build evidence.",
            "Dependency arrows are not message routes. Runtime placement is not inferred from crate roles.",
            "Explicit third-party packages remain in the graph; their internals and portability are not audited as first-party core.",
        ],
        work: WorkCounts::default(),
    };
    while let Some(manifest) = pending.pop_front() {
        let manifest = canonical(&manifest, &mut resolved_paths)?;
        if visited.contains(&manifest) {
            continue;
        }
        let directory = manifest.parent().ok_or_else(|| {
            ArchError::Configuration(
                manifest.clone(),
                "Manifest has no parent directory.".to_owned(),
            )
        })?;
        let output = Command::new("cargo")
            .current_dir(directory)
            .args([
                "metadata",
                "--no-deps",
                "--offline",
                "--locked",
                "--format-version",
                "1",
                "--manifest-path",
            ])
            .arg(&manifest)
            .output()
            .map_err(ArchError::CargoStart)?;
        architecture.work.metadata_invocations = architecture
            .work
            .metadata_invocations
            .checked_add(1)
            .ok_or(ArchError::Capacity)?;
        if !output.status.success() {
            return Err(ArchError::Cargo(
                String::from_utf8_lossy(&output.stderr).into_owned(),
            ));
        }
        let metadata: Metadata = serde_json::from_slice(&output.stdout).map_err(ArchError::Json)?;
        let workspace_manifest = canonical(
            &metadata.workspace_root.join("Cargo.toml"),
            &mut resolved_paths,
        )?;
        if visited.contains(&workspace_manifest) {
            visited.insert(manifest);
            continue;
        }
        visited.insert(workspace_manifest.clone());
        visited.insert(manifest);
        let workspace = document(&workspace_manifest, &mut cache)?;
        let project: Project = config(&workspace, "workspace")
            .cloned()
            .map(toml::Value::try_into)
            .transpose()
            .map_err(|error| ArchError::Manifest(workspace_manifest.clone(), error))?
            .unwrap_or_default();
        let declared_root = project
            .root
            .as_ref()
            .map(|root| metadata.workspace_root.join(root));
        if architecture.root.as_os_str().is_empty() {
            let root = declared_root.unwrap_or_else(|| metadata.workspace_root.clone());
            architecture.root = canonical(&root, &mut resolved_paths)?;
        } else if let Some(root) = declared_root {
            let root = canonical(&root, &mut resolved_paths)?;
            if root != architecture.root {
                return Err(ArchError::Configuration(
                    workspace_manifest.clone(),
                    "Listed workspaces must agree on one project root.".to_owned(),
                ));
            }
        }
        for path in project.workspaces {
            pending.push_back(metadata.workspace_root.join(path));
        }
        let lints = Rc::new(
            workspace
                .get("workspace")
                .and_then(|workspace| workspace.get("lints"))
                .cloned()
                .unwrap_or_else(|| toml::Value::Table(Default::default())),
        );
        let members: BTreeSet<_> = metadata.workspace_members.into_iter().collect();
        for package in metadata
            .packages
            .into_iter()
            .filter(|package| members.contains(&package.id))
        {
            let manifest = canonical(&package.manifest_path, &mut resolved_paths)?;
            visited.insert(manifest.clone());
            if architecture.packages.contains_key(&manifest) {
                continue;
            }
            let doc = document(&manifest, &mut cache)?;
            let inferred = manifest
                .parent()
                .and_then(|parent| infer(&architecture.root, parent));
            let declared: Option<Declaration> = config(&doc, "package")
                .cloned()
                .map(toml::Value::try_into)
                .transpose()
                .map_err(|error| ArchError::Manifest(manifest.clone(), error))?;
            let provenance = if declared.is_some() {
                "declared"
            } else if inferred.is_some() {
                "canonical-path"
            } else {
                "unclassified"
            };
            if let Some(declaration) = &declared {
                let conflict = inferred.as_ref().is_some_and(|inferred| {
                    inferred.role != declaration.role
                        || inferred.app != declaration.app
                        || inferred.scope != declaration.scope
                });
                if !declaration.valid() || conflict {
                    architecture.report(&manifest, "arch.role.conflict", package.name.clone(), "The declared role is invalid or contradicts its canonical location.".to_owned(), "Use the role and application established by the canonical path, or move the crate to a custom location.");
                }
            }
            let declaration = inferred
                .map(|mut inferred| {
                    if let Some(declared) = &declared {
                        inferred
                            .forbid_dependencies
                            .clone_from(&declared.forbid_dependencies);
                    }
                    inferred
                })
                .or(declared);
            let proc_macro = package
                .targets
                .iter()
                .any(|target| target.kind.iter().any(|kind| kind == "proc-macro"));
            let library = package
                .targets
                .into_iter()
                .find(|target| {
                    target
                        .kind
                        .iter()
                        .any(|kind| kind == "lib" || kind == "rlib" || kind == "cdylib")
                })
                .map(|target| target.src_path);
            let dependencies = package
                .dependencies
                .into_iter()
                .map(|dependency| {
                    let path = dependency
                        .path
                        .map(|path| {
                            let manifest =
                                canonical(&path.join("Cargo.toml"), &mut resolved_paths)?;
                            manifest.parent().map(Path::to_owned).ok_or_else(|| {
                                ArchError::Configuration(
                                    manifest.clone(),
                                    "Dependency manifest has no parent.".to_owned(),
                                )
                            })
                        })
                        .transpose()?;
                    Ok(Dependency {
                        name: dependency.name,
                        alias: dependency.rename,
                        path,
                        kind: dependency.kind.unwrap_or_else(|| "normal".to_owned()),
                        target: dependency.target,
                        optional: dependency.optional,
                        default_features: dependency.uses_default_features,
                        features: dependency.features,
                    })
                })
                .collect::<Result<Vec<_>, ArchError>>()?;
            architecture.packages.insert(
                manifest.clone(),
                Package {
                    name: package.name,
                    manifest,
                    declaration,
                    provenance,
                    dependencies,
                    proc_macro,
                    document: doc,
                    library,
                    workspace_lints: lints.clone(),
                    workspace_manifest: workspace_manifest.clone(),
                },
            );
        }
    }
    if architecture.packages.is_empty() {
        return Err(ArchError::Configuration(
            manifest.to_owned(),
            "No workspace packages were available to inspect.".to_owned(),
        ));
    }
    architecture.work.manifests_read = cache.len();
    Ok(architecture)
}

fn canonical(path: &Path, cache: &mut BTreeMap<PathBuf, PathBuf>) -> Result<PathBuf, ArchError> {
    if let Some(resolved) = cache.get(path) {
        return Ok(resolved.clone());
    }
    let resolved = path
        .canonicalize()
        .map_err(|error| ArchError::Read(path.to_owned(), error))?;
    cache.insert(path.to_owned(), resolved.clone());
    cache
        .entry(resolved.clone())
        .or_insert_with(|| resolved.clone());
    Ok(resolved)
}