use crate::symbols::SymbolIndex;
use regex::Regex;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FfiModule {
pub source: String,
pub import_path: String,
pub symbols: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FfiManifest {
pub modules: Vec<FfiModule>,
}
impl FfiModule {
#[must_use]
pub fn from_source(source_label: &str, src: &str) -> Option<Self> {
let pyclass_re = Regex::new(r"(?s)#\[pyclass\((.*?)\)\]").unwrap();
let module_re = Regex::new(r#"\bmodule\s*=\s*"([^"]+)""#).unwrap();
let name_re = Regex::new(r#"\bname\s*=\s*"([^"]+)""#).unwrap();
let add_re = Regex::new(r#"\.add\(\s*"([^"]+)""#).unwrap();
let mut import_path: Option<String> = None;
let mut symbols: Vec<String> = Vec::new();
for caps in pyclass_re.captures_iter(src) {
let body = &caps[1];
if let Some(m) = module_re.captures(body) {
import_path.get_or_insert_with(|| m[1].to_string());
}
if let Some(n) = name_re.captures(body) {
symbols.push(n[1].to_string());
}
}
let import_path = import_path?;
for caps in add_re.captures_iter(src) {
symbols.push(caps[1].to_string());
}
symbols.sort();
symbols.dedup();
Some(Self {
source: source_label.to_string(),
import_path,
symbols,
})
}
}
impl FfiManifest {
#[must_use]
pub fn from_sources<'a>(sources: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
let modules = sources
.into_iter()
.filter_map(|(label, src)| FfiModule::from_source(label, src))
.collect();
Self { modules }
}
pub fn from_workspace(root: &Path) -> std::io::Result<Self> {
let mut found: Vec<(String, String)> = Vec::new();
for entry in std::fs::read_dir(root)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let binding = entry.path().join("src").join("pyo3_module.rs");
if let Ok(src) = std::fs::read_to_string(&binding) {
found.push((entry.file_name().to_string_lossy().into_owned(), src));
}
}
found.sort();
Ok(Self::from_sources(
found.iter().map(|(l, s)| (l.as_str(), s.as_str())),
))
}
#[must_use]
pub fn len(&self) -> usize {
self.modules.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.modules.is_empty()
}
#[must_use]
pub fn render_block(&self) -> String {
if self.modules.is_empty() {
return String::new();
}
let mut out = String::from(
"[PYO3 IMPORT SURFACE — authoritative; import these exact paths, \
do not infer a path from the crate name]\n",
);
for m in &self.modules {
out.push_str("- ");
out.push_str(&m.source);
out.push_str(" → ");
out.push_str(&m.import_path);
if !m.symbols.is_empty() {
out.push_str(" (");
out.push_str(&m.symbols.join(", "));
out.push(')');
}
out.push('\n');
}
out
}
#[must_use]
pub fn known_modules(&self) -> std::collections::BTreeSet<String> {
let mut set = std::collections::BTreeSet::new();
for m in &self.modules {
for ancestor in dotted_ancestors(&m.import_path) {
set.insert(ancestor);
}
let stitched = public_stitched_path(&m.import_path);
if !stitched.is_empty() && stitched != m.import_path {
for ancestor in dotted_ancestors(&stitched) {
set.insert(ancestor);
}
}
}
set
}
#[must_use]
pub fn to_symbol_index(&self) -> SymbolIndex {
let mut index = SymbolIndex::new();
for m in &self.modules {
for ancestor in dotted_ancestors(&m.import_path) {
index.add_module(ancestor);
}
for symbol in &m.symbols {
index.add_symbol(m.import_path.clone(), symbol.clone());
}
}
index
}
}
fn public_stitched_path(path: &str) -> String {
path.split('.')
.filter(|seg| !seg.starts_with('_'))
.collect::<Vec<_>>()
.join(".")
}
fn dotted_ancestors(path: &str) -> Vec<String> {
let mut acc = String::new();
let mut out = Vec::new();
for part in path.split('.') {
if !acc.is_empty() {
acc.push('.');
}
acc.push_str(part);
out.push(acc.clone());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::symbols::{Reference, Resolution};
const NEWT_CORE_SRC: &str = r#"
create_exception!(_newt_agent, PyNewtError, PyException);
#[pyclass(
name = "Tier",
module = "newt_agent._newt_agent.core",
eq,
)]
pub struct PyTier;
#[pyclass(name = "Router", module = "newt_agent._newt_agent.core")]
pub struct PyRouter;
pub fn register(py: Python<'_>, parent: &Bound<'_, PyModule>) -> PyResult<()> {
let m = PyModule::new(py, "core")?;
m.add_class::<PyTier>()?;
m.add_class::<PyRouter>()?;
m.add("NewtError", py.get_type::<PyNewtError>())?;
Ok(())
}
"#;
const NEWT_DATA_SRC: &str = r#"
#[pyclass(name = "DataStore", module = "newt_agent._newt_agent.data")]
pub struct PyDataStore;
"#;
#[test]
fn extracts_import_path_and_symbols() {
let m = FfiModule::from_source("newt-core", NEWT_CORE_SRC).unwrap();
assert_eq!(m.source, "newt-core");
assert_eq!(m.import_path, "newt_agent._newt_agent.core");
assert_eq!(m.symbols, vec!["NewtError", "Router", "Tier"]);
}
#[test]
fn source_without_pyclass_module_is_none() {
assert!(FfiModule::from_source("plain", "pub fn f() {}").is_none());
assert!(FfiModule::from_source("nomod", r#"#[pyclass(name = "X")] struct X;"#).is_none());
}
#[test]
fn manifest_skips_unbound_sources() {
let manifest = FfiManifest::from_sources([
("newt-core", NEWT_CORE_SRC),
("plain", "pub fn nothing() {}"),
("newt-data", NEWT_DATA_SRC),
]);
assert_eq!(manifest.len(), 2);
assert!(!manifest.is_empty());
let paths: Vec<&str> = manifest
.modules
.iter()
.map(|m| m.import_path.as_str())
.collect();
assert_eq!(
paths,
vec!["newt_agent._newt_agent.core", "newt_agent._newt_agent.data"]
);
}
#[test]
fn from_workspace_discovers_bindings() {
let tmp = tempfile::tempdir().unwrap();
for (crate_dir, src) in [("newt-core", NEWT_CORE_SRC), ("newt-data", NEWT_DATA_SRC)] {
let dir = tmp.path().join(crate_dir).join("src");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("pyo3_module.rs"), src).unwrap();
}
std::fs::create_dir_all(tmp.path().join("docs")).unwrap();
std::fs::create_dir_all(tmp.path().join("newt-empty").join("src")).unwrap();
let manifest = FfiManifest::from_workspace(tmp.path()).unwrap();
assert_eq!(manifest.len(), 2);
assert_eq!(manifest.modules[0].source, "newt-core");
assert_eq!(
manifest.modules[0].import_path,
"newt_agent._newt_agent.core"
);
assert_eq!(manifest.modules[1].source, "newt-data");
}
#[test]
fn render_block_is_the_injectable_fact() {
let manifest = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]);
let block = manifest.render_block();
assert!(block.contains("authoritative"));
assert!(block.contains("newt-core → newt_agent._newt_agent.core"));
assert!(block.contains("NewtError, Router, Tier"));
}
#[test]
fn empty_manifest_renders_nothing() {
let manifest = FfiManifest::default();
assert!(manifest.is_empty());
assert_eq!(manifest.render_block(), "");
}
#[test]
fn symbol_index_resolves_real_and_flags_fabricated() {
let manifest = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]);
let index = manifest.to_symbol_index();
assert!(index.has_module("newt_agent._newt_agent.core"));
assert!(index.has_module("newt_agent._newt_agent"));
assert!(index.has_module("newt_agent"));
assert_eq!(
index.resolve(&Reference::import_from(
"newt_agent._newt_agent.core",
"Router",
1
)),
Resolution::Resolved
);
assert_eq!(
index.resolve(&Reference::import_module("newt_core", 1)),
Resolution::UnknownModule
);
}
#[test]
fn known_modules_covers_native_and_stitched_paths() {
let known = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]).known_modules();
assert!(known.contains("newt_agent._newt_agent.core"), "native path");
assert!(known.contains("newt_agent.core"), "stitched public path");
assert!(known.contains("newt_agent"), "umbrella root");
assert!(!known.contains("newt_agent._newt_core"));
assert!(!known.contains("newt_core"));
}
#[test]
fn public_stitched_path_drops_private_segments() {
assert_eq!(
public_stitched_path("newt_agent._newt_agent.core"),
"newt_agent.core"
);
assert_eq!(public_stitched_path("a.b.c"), "a.b.c");
}
#[test]
fn dotted_ancestors_are_every_prefix() {
assert_eq!(
dotted_ancestors("a.b.c"),
vec!["a".to_string(), "a.b".to_string(), "a.b.c".to_string()]
);
assert_eq!(dotted_ancestors("solo"), vec!["solo".to_string()]);
}
}