newt_core/
ffi_manifest.rs1use crate::symbols::SymbolIndex;
26use regex::Regex;
27use std::path::Path;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct FfiModule {
32 pub source: String,
34 pub import_path: String,
37 pub symbols: Vec<String>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct FfiManifest {
46 pub modules: Vec<FfiModule>,
48}
49
50impl FfiModule {
51 #[must_use]
55 pub fn from_source(source_label: &str, src: &str) -> Option<Self> {
56 let pyclass_re = Regex::new(r"(?s)#\[pyclass\((.*?)\)\]").unwrap();
58 let module_re = Regex::new(r#"\bmodule\s*=\s*"([^"]+)""#).unwrap();
59 let name_re = Regex::new(r#"\bname\s*=\s*"([^"]+)""#).unwrap();
60 let add_re = Regex::new(r#"\.add\(\s*"([^"]+)""#).unwrap();
62
63 let mut import_path: Option<String> = None;
64 let mut symbols: Vec<String> = Vec::new();
65
66 for caps in pyclass_re.captures_iter(src) {
67 let body = &caps[1];
68 if let Some(m) = module_re.captures(body) {
69 import_path.get_or_insert_with(|| m[1].to_string());
71 }
72 if let Some(n) = name_re.captures(body) {
73 symbols.push(n[1].to_string());
74 }
75 }
76
77 let import_path = import_path?;
78 for caps in add_re.captures_iter(src) {
79 symbols.push(caps[1].to_string());
80 }
81 symbols.sort();
82 symbols.dedup();
83
84 Some(Self {
85 source: source_label.to_string(),
86 import_path,
87 symbols,
88 })
89 }
90}
91
92impl FfiManifest {
93 #[must_use]
96 pub fn from_sources<'a>(sources: impl IntoIterator<Item = (&'a str, &'a str)>) -> Self {
97 let modules = sources
98 .into_iter()
99 .filter_map(|(label, src)| FfiModule::from_source(label, src))
100 .collect();
101 Self { modules }
102 }
103
104 pub fn from_workspace(root: &Path) -> std::io::Result<Self> {
109 let mut found: Vec<(String, String)> = Vec::new();
110 for entry in std::fs::read_dir(root)? {
111 let entry = entry?;
112 if !entry.file_type()?.is_dir() {
113 continue;
114 }
115 let binding = entry.path().join("src").join("pyo3_module.rs");
116 if let Ok(src) = std::fs::read_to_string(&binding) {
117 found.push((entry.file_name().to_string_lossy().into_owned(), src));
118 }
119 }
120 found.sort();
121 Ok(Self::from_sources(
122 found.iter().map(|(l, s)| (l.as_str(), s.as_str())),
123 ))
124 }
125
126 #[must_use]
128 pub fn len(&self) -> usize {
129 self.modules.len()
130 }
131
132 #[must_use]
134 pub fn is_empty(&self) -> bool {
135 self.modules.is_empty()
136 }
137
138 #[must_use]
142 pub fn render_block(&self) -> String {
143 if self.modules.is_empty() {
144 return String::new();
145 }
146 let mut out = String::from(
147 "[PYO3 IMPORT SURFACE — authoritative; import these exact paths, \
148 do not infer a path from the crate name]\n",
149 );
150 for m in &self.modules {
151 out.push_str("- ");
152 out.push_str(&m.source);
153 out.push_str(" → ");
154 out.push_str(&m.import_path);
155 if !m.symbols.is_empty() {
156 out.push_str(" (");
157 out.push_str(&m.symbols.join(", "));
158 out.push(')');
159 }
160 out.push('\n');
161 }
162 out
163 }
164
165 #[must_use]
171 pub fn known_modules(&self) -> std::collections::BTreeSet<String> {
172 let mut set = std::collections::BTreeSet::new();
173 for m in &self.modules {
174 for ancestor in dotted_ancestors(&m.import_path) {
175 set.insert(ancestor);
176 }
177 let stitched = public_stitched_path(&m.import_path);
178 if !stitched.is_empty() && stitched != m.import_path {
179 for ancestor in dotted_ancestors(&stitched) {
180 set.insert(ancestor);
181 }
182 }
183 }
184 set
185 }
186
187 #[must_use]
192 pub fn to_symbol_index(&self) -> SymbolIndex {
193 let mut index = SymbolIndex::new();
194 for m in &self.modules {
195 for ancestor in dotted_ancestors(&m.import_path) {
196 index.add_module(ancestor);
197 }
198 for symbol in &m.symbols {
199 index.add_symbol(m.import_path.clone(), symbol.clone());
200 }
201 }
202 index
203 }
204}
205
206fn public_stitched_path(path: &str) -> String {
211 path.split('.')
212 .filter(|seg| !seg.starts_with('_'))
213 .collect::<Vec<_>>()
214 .join(".")
215}
216
217fn dotted_ancestors(path: &str) -> Vec<String> {
220 let mut acc = String::new();
221 let mut out = Vec::new();
222 for part in path.split('.') {
223 if !acc.is_empty() {
224 acc.push('.');
225 }
226 acc.push_str(part);
227 out.push(acc.clone());
228 }
229 out
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::symbols::{Reference, Resolution};
236
237 const NEWT_CORE_SRC: &str = r#"
240create_exception!(_newt_agent, PyNewtError, PyException);
241
242#[pyclass(
243 name = "Tier",
244 module = "newt_agent._newt_agent.core",
245 eq,
246)]
247pub struct PyTier;
248
249#[pyclass(name = "Router", module = "newt_agent._newt_agent.core")]
250pub struct PyRouter;
251
252pub fn register(py: Python<'_>, parent: &Bound<'_, PyModule>) -> PyResult<()> {
253 let m = PyModule::new(py, "core")?;
254 m.add_class::<PyTier>()?;
255 m.add_class::<PyRouter>()?;
256 m.add("NewtError", py.get_type::<PyNewtError>())?;
257 Ok(())
258}
259"#;
260
261 const NEWT_DATA_SRC: &str = r#"
262#[pyclass(name = "DataStore", module = "newt_agent._newt_agent.data")]
263pub struct PyDataStore;
264"#;
265
266 #[test]
267 fn extracts_import_path_and_symbols() {
268 let m = FfiModule::from_source("newt-core", NEWT_CORE_SRC).unwrap();
269 assert_eq!(m.source, "newt-core");
270 assert_eq!(m.import_path, "newt_agent._newt_agent.core");
271 assert_eq!(m.symbols, vec!["NewtError", "Router", "Tier"]);
273 }
274
275 #[test]
276 fn source_without_pyclass_module_is_none() {
277 assert!(FfiModule::from_source("plain", "pub fn f() {}").is_none());
278 assert!(FfiModule::from_source("nomod", r#"#[pyclass(name = "X")] struct X;"#).is_none());
280 }
281
282 #[test]
283 fn manifest_skips_unbound_sources() {
284 let manifest = FfiManifest::from_sources([
285 ("newt-core", NEWT_CORE_SRC),
286 ("plain", "pub fn nothing() {}"),
287 ("newt-data", NEWT_DATA_SRC),
288 ]);
289 assert_eq!(manifest.len(), 2);
290 assert!(!manifest.is_empty());
291 let paths: Vec<&str> = manifest
292 .modules
293 .iter()
294 .map(|m| m.import_path.as_str())
295 .collect();
296 assert_eq!(
297 paths,
298 vec!["newt_agent._newt_agent.core", "newt_agent._newt_agent.data"]
299 );
300 }
301
302 #[test]
303 fn from_workspace_discovers_bindings() {
304 let tmp = tempfile::tempdir().unwrap();
305 for (crate_dir, src) in [("newt-core", NEWT_CORE_SRC), ("newt-data", NEWT_DATA_SRC)] {
307 let dir = tmp.path().join(crate_dir).join("src");
308 std::fs::create_dir_all(&dir).unwrap();
309 std::fs::write(dir.join("pyo3_module.rs"), src).unwrap();
310 }
311 std::fs::create_dir_all(tmp.path().join("docs")).unwrap();
313 std::fs::create_dir_all(tmp.path().join("newt-empty").join("src")).unwrap();
314
315 let manifest = FfiManifest::from_workspace(tmp.path()).unwrap();
316 assert_eq!(manifest.len(), 2);
317 assert_eq!(manifest.modules[0].source, "newt-core");
319 assert_eq!(
320 manifest.modules[0].import_path,
321 "newt_agent._newt_agent.core"
322 );
323 assert_eq!(manifest.modules[1].source, "newt-data");
324 }
325
326 #[test]
327 fn render_block_is_the_injectable_fact() {
328 let manifest = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]);
329 let block = manifest.render_block();
330 assert!(block.contains("authoritative"));
331 assert!(block.contains("newt-core → newt_agent._newt_agent.core"));
332 assert!(block.contains("NewtError, Router, Tier"));
333 }
334
335 #[test]
336 fn empty_manifest_renders_nothing() {
337 let manifest = FfiManifest::default();
338 assert!(manifest.is_empty());
339 assert_eq!(manifest.render_block(), "");
340 }
341
342 #[test]
343 fn symbol_index_resolves_real_and_flags_fabricated() {
344 let manifest = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]);
345 let index = manifest.to_symbol_index();
346
347 assert!(index.has_module("newt_agent._newt_agent.core"));
349 assert!(index.has_module("newt_agent._newt_agent"));
350 assert!(index.has_module("newt_agent"));
351 assert_eq!(
353 index.resolve(&Reference::import_from(
354 "newt_agent._newt_agent.core",
355 "Router",
356 1
357 )),
358 Resolution::Resolved
359 );
360 assert_eq!(
362 index.resolve(&Reference::import_module("newt_core", 1)),
363 Resolution::UnknownModule
364 );
365 }
366
367 #[test]
368 fn known_modules_covers_native_and_stitched_paths() {
369 let known = FfiManifest::from_sources([("newt-core", NEWT_CORE_SRC)]).known_modules();
372 assert!(known.contains("newt_agent._newt_agent.core"), "native path");
373 assert!(known.contains("newt_agent.core"), "stitched public path");
374 assert!(known.contains("newt_agent"), "umbrella root");
375 assert!(!known.contains("newt_agent._newt_core"));
377 assert!(!known.contains("newt_core"));
378 }
379
380 #[test]
381 fn public_stitched_path_drops_private_segments() {
382 assert_eq!(
383 public_stitched_path("newt_agent._newt_agent.core"),
384 "newt_agent.core"
385 );
386 assert_eq!(public_stitched_path("a.b.c"), "a.b.c");
388 }
389
390 #[test]
391 fn dotted_ancestors_are_every_prefix() {
392 assert_eq!(
393 dotted_ancestors("a.b.c"),
394 vec!["a".to_string(), "a.b".to_string(), "a.b.c".to_string()]
395 );
396 assert_eq!(dotted_ancestors("solo"), vec!["solo".to_string()]);
397 }
398}