use std::collections::HashMap;
use serde::Serialize;
use crate::deploy::cut::{CutAnnotation, CutEdge};
use crate::deploy::dependency::DependencyEntry;
use crate::deploy::metrics::PodMetrics;
use crate::deploy::pod::PodPartition;
use crate::graph::CodeGraph;
#[derive(Debug, Clone, Serialize)]
pub struct PodManifest {
pub version: String,
pub deployments: Vec<PodDeployment>,
pub edges: Vec<ManifestEdge>,
pub metrics: GlobalMetrics,
}
#[derive(Debug, Clone, Serialize)]
pub struct PodDeployment {
pub id: usize,
pub language: String,
pub files: Vec<String>,
pub metrics: PodMetrics,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<DependencyEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ManifestEdge {
pub from_pod: usize,
pub to_pod: usize,
pub kind: String,
pub confidence: f32,
pub is_cross_language: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cut_annotations: Vec<CutAnnotation>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct GlobalMetrics {
pub total_pods: usize,
pub cross_language_edges: usize,
pub total_ast_nodes: usize,
#[serde(default)]
pub dropped_files: usize,
}
pub fn generate_pod_manifest(
partition: &PodPartition,
pod_metrics: &[PodMetrics],
cuts: &[CutEdge],
dependencies: &HashMap<usize, Vec<DependencyEntry>>,
graph: &CodeGraph,
) -> PodManifest {
let mut deployments = Vec::with_capacity(partition.pods.len());
let mut dropped_files = 0usize;
for (i, pod) in partition.pods.iter().enumerate() {
let tag = crate::deploy::tags::metacall_tag(pod.language);
let mut dropped = 0usize;
let files: Vec<String> = pod
.files
.iter()
.filter_map(|fid| match graph.file_node(*fid) {
Some(file) => Some(crate::input::portable_path(&file.path)),
None => {
dropped += 1;
tracing::warn!(
pod = pod.id,
file = fid.to_raw(),
"pod file has no graph node"
);
None
}
})
.collect();
let deps = dependencies.get(&pod.id).cloned().unwrap_or_default();
let pm = match pod_metrics.get(i).cloned() {
Some(metrics) => metrics,
None => {
dropped += 1;
tracing::warn!(pod = pod.id, "pod has no metrics entry");
PodMetrics {
total_ast_nodes: 0,
file_count: 0,
symbol_count: 0,
}
}
};
dropped_files += dropped;
deployments.push(PodDeployment {
id: pod.id,
language: tag.to_string(),
files,
metrics: pm,
dependencies: deps,
});
}
let mut cut_lookup: HashMap<(usize, usize), Vec<CutAnnotation>> = HashMap::new();
for cut in cuts {
cut_lookup
.entry((cut.from_pod, cut.to_pod))
.or_default()
.push(cut.annotation.clone());
}
let mut edges: Vec<ManifestEdge> = partition
.inter_pod_edges
.iter()
.map(|ip| {
let kind = match ip.kind {
crate::graph::EdgeKind::Import => "import".to_string(),
crate::graph::EdgeKind::Reference => "reference".to_string(),
crate::graph::EdgeKind::Ownership => "ownership".to_string(),
crate::graph::EdgeKind::Flow => "flow".to_string(),
};
let annotations = cut_lookup
.get(&(ip.from_pod, ip.to_pod))
.cloned()
.unwrap_or_default();
ManifestEdge {
from_pod: ip.from_pod,
to_pod: ip.to_pod,
kind,
confidence: ip.confidence,
is_cross_language: ip.is_cross_language,
cut_annotations: annotations,
}
})
.collect();
for (pair, annotations) in &cut_lookup {
if edges
.iter()
.any(|edge| edge.kind == "rpc_stub" && edge.from_pod == pair.0 && edge.to_pod == pair.1)
{
continue;
}
let confidence = annotations
.iter()
.map(|annotation| annotation.original_confidence)
.fold(f32::INFINITY, f32::min);
edges.push(ManifestEdge {
from_pod: pair.0,
to_pod: pair.1,
kind: "rpc_stub".to_string(),
confidence,
is_cross_language: annotations.iter().any(|annotation| {
matches!(
annotation.cut_reason,
crate::deploy::cut::CutReason::CrossLanguageScc
)
}),
cut_annotations: annotations.clone(),
});
}
edges.sort_by(|a, b| (a.from_pod, a.to_pod, &a.kind).cmp(&(b.from_pod, b.to_pod, &b.kind)));
let total_ast_nodes = pod_metrics.iter().fold(0usize, |total, metrics| {
total.saturating_add(metrics.total_ast_nodes)
});
let cross_language_edges = edges.iter().filter(|e| e.is_cross_language).count();
let total_pods = deployments.len();
PodManifest {
version: "1.1".to_string(),
deployments,
edges,
metrics: GlobalMetrics {
total_pods,
cross_language_edges,
total_ast_nodes,
dropped_files,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deploy::cut::{CutAnnotation, CutReason, PortablePath};
use crate::deploy::metrics::PodMetrics;
use crate::deploy::pod::{InterPodEdge, Pod, PodPartition};
use crate::graph::EdgeKind;
use crate::graph::node::{FileNode, NodeData};
use crate::language::LangId;
use crate::model::ids::{FileId, SnapshotId};
use std::collections::HashMap;
use std::path::PathBuf;
fn test_partition() -> (PodPartition, CodeGraph) {
let mut graph = CodeGraph::new(SnapshotId::new(1).unwrap());
let py = FileId::new(1).unwrap();
let js = FileId::new(2).unwrap();
for (fid, path, lang) in [
(py, "a.py", LangId::Python),
(js, "b.js", LangId::JavaScript),
] {
let idx = graph.add_node(NodeData::File(FileNode::new(
fid,
PathBuf::from(path),
lang,
SnapshotId::new(1).unwrap(),
)));
graph.file_to_index.insert(fid, idx);
}
let partition = PodPartition {
pods: vec![
Pod {
id: 0,
files: vec![py],
language: LangId::Python,
},
Pod {
id: 1,
files: vec![js],
language: LangId::JavaScript,
},
],
inter_pod_edges: vec![InterPodEdge {
from_pod: 0,
to_pod: 1,
from_file: py,
to_file: js,
kind: EdgeKind::Import,
confidence: 0.6,
is_cross_language: true,
}],
file_languages: HashMap::from([(py, LangId::Python), (js, LangId::JavaScript)]),
};
(partition, graph)
}
#[test]
fn pod_file_without_a_graph_node_is_counted() {
let (mut partition, graph) = test_partition();
let missing = FileId::new(99).unwrap();
partition.pods[0].files.push(missing);
let metrics = vec![
PodMetrics {
total_ast_nodes: 1,
file_count: 1,
symbol_count: 0,
},
PodMetrics {
total_ast_nodes: 1,
file_count: 1,
symbol_count: 0,
},
];
let manifest = generate_pod_manifest(&partition, &metrics, &[], &HashMap::new(), &graph);
assert_eq!(
manifest.metrics.dropped_files, 1,
"a pod file with no graph node must be counted"
);
assert!(
!manifest.deployments[0]
.files
.iter()
.any(|file| file.contains("99")),
"the missing file must not appear in the deployment: {:?}",
manifest.deployments[0].files
);
}
fn cut(reason: CutReason, confidence: f32) -> CutEdge {
CutEdge {
from_pod: 0,
to_pod: 1,
annotation: CutAnnotation {
from_file: PortablePath::from_path(std::path::Path::new("a.py")),
to_file: PortablePath::from_path(std::path::Path::new("b.js")),
cut_reason: reason,
original_confidence: confidence,
},
}
}
#[test]
fn two_cuts_on_one_pod_pair_emit_one_rpc_stub() {
let (partition, graph) = test_partition();
let metrics = vec![
PodMetrics {
total_ast_nodes: 1,
file_count: 1,
symbol_count: 0,
},
PodMetrics {
total_ast_nodes: 1,
file_count: 1,
symbol_count: 0,
},
];
let cuts = vec![
cut(CutReason::CrossLanguageScc, 0.6),
cut(
CutReason::OversizedPod {
pod_size: 4,
max_size: 3,
},
0.3,
),
];
let manifest = generate_pod_manifest(&partition, &metrics, &cuts, &HashMap::new(), &graph);
let stubs: Vec<&ManifestEdge> = manifest
.edges
.iter()
.filter(|e| e.kind == "rpc_stub")
.collect();
assert_eq!(
stubs.len(),
1,
"one rpc_stub per pod pair, got {}: {:?}",
stubs.len(),
stubs
.iter()
.map(|e| (e.from_pod, e.to_pod))
.collect::<Vec<_>>()
);
let json = serde_json::to_string(&manifest).unwrap();
assert!(
json.contains("CrossLanguageScc"),
"the SCC cut annotation must survive"
);
assert!(
json.contains("OversizedPod"),
"the oversized cut annotation must survive"
);
}
}