Skip to main content

apcore_toolkit/output/
registry_writer.rs

1// Registry writer for direct module registration.
2//
3// Converts ScannedModule instances into apcore Module implementations
4// and registers them directly into an apcore Registry.
5//
6// Framework adapters provide a `HandlerFactory` to resolve targets to real
7// async handlers. Without a factory, modules are registered with a passthrough
8// handler that echoes inputs (useful for schema-only registration).
9// For streaming modules, supply a `StreamingHandlerFactory` that maps target
10// strings to `StreamHandlerFn` implementations.
11
12use std::pin::Pin;
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use tracing::{debug, warn};
17
18use apcore::context::Context;
19use apcore::errors::ModuleError;
20use apcore::Registry;
21use apcore::{ChunkStream, StreamingModule};
22
23use crate::output::types::{Verifier, WriteResult};
24use crate::output::verifiers::{run_verifier_chain, RegistryVerifier};
25use crate::types::ScannedModule;
26
27/// Async stream-chunk function type for streaming modules.
28///
29/// Maps `(inputs, context)` to a `ChunkStream` (a self-contained async
30/// stream of JSON chunks).  The stream MUST NOT borrow from `ctx` past the
31/// call boundary — capture any needed context data by cloning before
32/// returning.
33pub type StreamHandlerFn =
34    Arc<dyn Fn(serde_json::Value, &Context<serde_json::Value>) -> ChunkStream + Send + Sync>;
35
36/// Factory that resolves a `target` string to a `StreamHandlerFn`.
37///
38/// Provide this to `RegistryWriter::with_streaming_handler_factory` when
39/// you need modules with `annotations.streaming = true` to be registered as
40/// proper `StreamingModule` implementations.  If the factory returns `None`
41/// for a given target, the toolkit falls back to logging a warning and
42/// clearing the `streaming` annotation so `Registry.register` does not
43/// reject the module.
44pub type StreamingHandlerFactory = Arc<dyn Fn(&str) -> Option<StreamHandlerFn> + Send + Sync>;
45
46/// Async handler function type for registered modules.
47pub type HandlerFn = Arc<
48    dyn for<'a> Fn(
49            serde_json::Value,
50            &'a Context<serde_json::Value>,
51        ) -> Pin<
52            Box<
53                dyn std::future::Future<Output = Result<serde_json::Value, ModuleError>>
54                    + Send
55                    + 'a,
56            >,
57        > + Send
58        + Sync,
59>;
60
61/// Factory that resolves a `target` string to an async handler.
62///
63/// Framework adapters implement this to map target strings (e.g., `"myapp:get_user"`)
64/// to actual handler functions. For example, an Axum adapter might look up the
65/// handler in a route table; a generic adapter might use a dynamic dispatch map.
66///
67/// ```ignore
68/// let factory: HandlerFactory = Arc::new(|target: &str| {
69///     let handler = lookup_handler(target);
70///     Some(Arc::new(move |inputs, _ctx| {
71///         let h = handler.clone();
72///         Box::pin(async move { h.call(inputs).await })
73///     }))
74/// });
75/// let writer = RegistryWriter::with_handler_factory(factory);
76/// ```
77pub type HandlerFactory = Arc<dyn Fn(&str) -> Option<HandlerFn> + Send + Sync>;
78
79/// A module that implements both `Module` and `StreamingModule`.
80///
81/// Wraps an inner `FunctionModule` for the `execute` path and adds streaming
82/// via a caller-supplied `StreamHandlerFn`.  `as_streaming()` returns
83/// `Some(self)`, satisfying the Registry's streaming-interface check.
84struct StreamingFunctionModule {
85    inner: apcore::decorator::FunctionModule,
86    stream_fn: StreamHandlerFn,
87}
88
89#[async_trait]
90impl apcore::module::Module for StreamingFunctionModule {
91    fn input_schema(&self) -> serde_json::Value {
92        self.inner.input_schema()
93    }
94    fn output_schema(&self) -> serde_json::Value {
95        self.inner.output_schema()
96    }
97    fn description(&self) -> &str {
98        self.inner.description()
99    }
100    async fn execute(
101        &self,
102        inputs: serde_json::Value,
103        ctx: &Context<serde_json::Value>,
104    ) -> Result<serde_json::Value, ModuleError> {
105        self.inner.execute(inputs, ctx).await
106    }
107    fn stream(
108        &self,
109        inputs: serde_json::Value,
110        ctx: &Context<serde_json::Value>,
111    ) -> Option<ChunkStream> {
112        Some((self.stream_fn)(inputs, ctx))
113    }
114    fn as_streaming(&self) -> Option<&dyn StreamingModule> {
115        Some(self)
116    }
117}
118
119impl StreamingModule for StreamingFunctionModule {
120    fn stream_typed(
121        &self,
122        inputs: serde_json::Value,
123        ctx: &Context<serde_json::Value>,
124    ) -> ChunkStream {
125        (self.stream_fn)(inputs, ctx)
126    }
127}
128
129/// Registers ScannedModule instances directly into an apcore Registry.
130///
131/// This is the default writer used when no output_format is specified.
132/// Instead of writing files, it registers modules directly for immediate use.
133///
134/// ## Handler Resolution
135///
136/// By default (`RegistryWriter::new()`), modules are registered with a passthrough
137/// handler that returns inputs unchanged — useful for schema-only registration
138/// where execution is handled elsewhere.
139///
140/// For executable modules, use `RegistryWriter::with_handler_factory(factory)` to
141/// provide a [`HandlerFactory`] that resolves target strings to real handlers.
142pub struct RegistryWriter {
143    handler_factory: Option<HandlerFactory>,
144    /// Optional factory for streaming handlers. When a module has
145    /// `annotations.streaming = true`, this factory is queried with the
146    /// module's `target` string. If it returns `Some(stream_fn)`, the module
147    /// is registered as a `StreamingFunctionModule`; otherwise a WARNING is
148    /// logged and the `streaming` annotation is cleared so
149    /// `Registry.register` does not raise `StreamingInterfaceMismatch`.
150    streaming_handler_factory: Option<StreamingHandlerFactory>,
151    /// Optional allow-list of `target` prefixes. When set, any module whose
152    /// `target` does not start with one of these prefixes is rejected with a
153    /// failed `WriteResult` before any handler factory is invoked. Mirrors the
154    /// `allowed_prefixes` parameter on the Python and TypeScript SDKs and
155    /// provides a defence-in-depth boundary on dynamically-supplied targets.
156    allowed_prefixes: Option<Vec<String>>,
157}
158
159impl Default for RegistryWriter {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165impl RegistryWriter {
166    /// Create a RegistryWriter with passthrough handlers (schema-only registration).
167    ///
168    /// # Handler resolution
169    ///
170    /// Unlike the Python and TypeScript implementations which dynamically import
171    /// the target function at write time (`resolve_target`), the Rust implementation
172    /// registers a passthrough handler that echoes its inputs when no HandlerFactory
173    /// is configured. This means calling a module registered by this writer will
174    /// succeed but will not execute real business logic. To register real handlers,
175    /// use the HandlerFactory integration.
176    ///
177    /// # Panics
178    ///
179    /// This constructor does not panic. However, note that without a `HandlerFactory`,
180    /// all registered modules will use a passthrough handler that echoes inputs unchanged.
181    /// This is suitable for schema-only registration. For real execution, use
182    /// [`RegistryWriter::with_handler_factory`] to supply a factory that resolves targets
183    /// to actual async handlers.
184    pub fn new() -> Self {
185        Self {
186            handler_factory: None,
187            streaming_handler_factory: None,
188            allowed_prefixes: None,
189        }
190    }
191
192    /// Create a RegistryWriter with a custom handler factory for target resolution.
193    pub fn with_handler_factory(factory: HandlerFactory) -> Self {
194        Self {
195            handler_factory: Some(factory),
196            streaming_handler_factory: None,
197            allowed_prefixes: None,
198        }
199    }
200
201    /// Attach a streaming handler factory.
202    ///
203    /// When a module has `annotations.streaming = true`, the factory is called
204    /// with the module's `target` string.  Returning `Some(stream_fn)` causes
205    /// the module to be registered as a `StreamingFunctionModule` (implements
206    /// both `Module` and `StreamingModule`).  Returning `None` causes the
207    /// toolkit to log a WARNING and clear the `streaming` annotation so
208    /// `Registry.register` does not reject the module.
209    ///
210    /// # Example
211    /// ```ignore
212    /// let stream_factory: StreamingHandlerFactory = Arc::new(|target: &str| {
213    ///     if target == "myapp:my_streaming_handler" {
214    ///         Some(Arc::new(|inputs, _ctx| {
215    ///             let stream = futures::stream::iter(vec![Ok(json!({"chunk": 1}))]);
216    ///             Box::pin(stream)
217    ///         }))
218    ///     } else {
219    ///         None
220    ///     }
221    /// });
222    /// let writer = RegistryWriter::new().with_streaming_handler_factory(stream_factory);
223    /// ```
224    pub fn with_streaming_handler_factory(mut self, factory: StreamingHandlerFactory) -> Self {
225        self.streaming_handler_factory = Some(factory);
226        self
227    }
228
229    /// Restrict registration to modules whose `target` starts with one of the
230    /// supplied prefixes. Modules with a non-matching target are rejected with
231    /// a failed `WriteResult` and never reach the handler factory.
232    ///
233    /// Matches the `allowed_prefixes` parameter on the Python `RegistryWriter`
234    /// and the TypeScript `allowedPrefixes` option. Use it to bound the set of
235    /// callable Python/Rust paths a binding YAML may resolve to (defence in
236    /// depth against forged or attacker-controlled `target` strings).
237    pub fn with_allowed_prefixes(mut self, prefixes: Vec<String>) -> Self {
238        self.allowed_prefixes = Some(prefixes);
239        self
240    }
241
242    /// Returns `true` when the module target is permitted by the configured
243    /// `allowed_prefixes` (or when no allow-list is configured).
244    ///
245    /// Performs boundary-aware module-path matching: the module path component
246    /// of `target` (everything before the `:` separator) must equal the prefix
247    /// or be a dotted descendant of it. Mirrors Python's
248    /// `_module_path_matches_prefix` — `"myapp"` does NOT permit `"myappx"`.
249    fn target_allowed(&self, target: &str) -> bool {
250        match self.allowed_prefixes.as_ref() {
251            None => true,
252            Some(prefixes) => {
253                let module_path = target.split(':').next().unwrap_or(target);
254                prefixes
255                    .iter()
256                    .any(|p| module_path_matches_prefix(module_path, p))
257            }
258        }
259    }
260}
261
262/// Boundary-aware module-path prefix match.
263///
264/// Returns `true` when `module_path` is exactly `prefix` or a dotted
265/// descendant of it. A trailing dot on `prefix` is tolerated; an empty
266/// prefix never matches. Mirrors the Python `_module_path_matches_prefix`
267/// helper in `apcore-toolkit-python/src/apcore_toolkit/resolve_target.py`.
268fn module_path_matches_prefix(module_path: &str, prefix: &str) -> bool {
269    let normalized = prefix.trim_end_matches('.');
270    if normalized.is_empty() {
271        return false;
272    }
273    if module_path == normalized {
274        return true;
275    }
276    let mut boundary = String::with_capacity(normalized.len() + 1);
277    boundary.push_str(normalized);
278    boundary.push('.');
279    module_path.starts_with(&boundary)
280}
281
282impl RegistryWriter {
283    /// Register scanned modules into the registry.
284    ///
285    /// - `registry`: The apcore Registry to register modules into.
286    /// - `dry_run`: If true, skip registration and return results only.
287    /// - `verify`: If true, verify modules are retrievable after registration.
288    /// - `verifiers`: Optional custom verifiers run after the built-in check.
289    ///
290    /// # Verifier contract for registry-based modules
291    ///
292    /// Registry modules have no output file, so custom verifiers receive
293    /// `path = ""`. Built-in file-based verifiers (`YAMLVerifier`, `JSONVerifier`,
294    /// etc.) skip gracefully when path is empty. Custom verifiers must also
295    /// handle `path = ""` without erroring — use `module_id` for any
296    /// registry-based checks.
297    pub fn write(
298        &self,
299        modules: &[ScannedModule],
300        registry: &mut Registry,
301        dry_run: bool,
302        verify: bool,
303        verifiers: Option<&[&dyn Verifier]>,
304    ) -> Vec<WriteResult> {
305        let mut results: Vec<WriteResult> = Vec::new();
306
307        for module in modules {
308            if dry_run {
309                results.push(WriteResult::new(module.module_id.clone()));
310                continue;
311            }
312
313            if !self.target_allowed(&module.target) {
314                warn!(
315                    module_id = %module.module_id,
316                    target = %module.target,
317                    "RegistryWriter: target rejected by allowed_prefixes"
318                );
319                results.push(WriteResult::failed(
320                    module.module_id.clone(),
321                    None,
322                    format!(
323                        "target '{}' is not in allowed_prefixes — registration refused",
324                        module.target
325                    ),
326                ));
327                continue;
328            }
329
330            let (module_obj, descriptor) = self.to_module(module);
331            // Note: unlike Python/TypeScript, Rust collects per-module registration errors
332            // rather than aborting. This is intentional — partial registration is preferred
333            // over a hard stop, giving callers the opportunity to inspect and handle each failure.
334            if let Err(e) = registry.register(&module.module_id, module_obj, descriptor) {
335                warn!(
336                    module_id = %module.module_id,
337                    error = %e,
338                    "RegistryWriter registration failed"
339                );
340                results.push(WriteResult::failed(
341                    module.module_id.clone(),
342                    None,
343                    format!("Registration failed: {e}"),
344                ));
345                continue;
346            }
347            debug!("Registered module: {}", module.module_id);
348
349            let mut result = WriteResult::new(module.module_id.clone());
350            if verify {
351                result = verify_registry(&result, &module.module_id, registry);
352            }
353            // Run custom verifiers unconditionally (no gate on result.verified),
354            // aligning with the TypeScript implementation which calls custom
355            // verifiers via Promise.allSettled regardless of the built-in check
356            // outcome. The final `verified` status is the AND-merge of the
357            // built-in result and the custom chain: both must pass.
358            if let Some(vs) = verifiers {
359                let chain_result = run_verifier_chain(vs, "", &module.module_id);
360                if !chain_result.ok {
361                    result = WriteResult::failed(
362                        result.module_id,
363                        result.path,
364                        chain_result.error.unwrap_or_default(),
365                    );
366                }
367            }
368            results.push(result);
369        }
370
371        results
372    }
373}
374
375impl RegistryWriter {
376    /// Convert a ScannedModule to a boxed apcore module ready for registration.
377    ///
378    /// Returns a `StreamingFunctionModule` when `annotations.streaming = true`
379    /// AND the `StreamingHandlerFactory` provides a handler for the target.
380    /// When streaming is declared but no stream handler is available, logs a
381    /// WARNING and clears the annotation so `Registry.register` does not
382    /// raise `StreamingInterfaceMismatch` — the module is registered as a
383    /// plain (non-streaming) `FunctionModule`.
384    ///
385    /// If only a plain `HandlerFactory` is configured (or none), the execute
386    /// handler is resolved from that factory with a passthrough fallback.
387    fn to_module(
388        &self,
389        module: &ScannedModule,
390    ) -> (
391        Box<dyn apcore::module::Module + Send + Sync>,
392        apcore::registry::registry::ModuleDescriptor,
393    ) {
394        let mut annotations = module.annotations.clone().unwrap_or_default();
395        let input_schema = module.input_schema.clone();
396        let output_schema = module.output_schema.clone();
397
398        // Build the execute handler (shared between streaming and non-streaming).
399        let exec_handler: HandlerFn = if let Some(factory) = &self.handler_factory {
400            if let Some(handler) = factory(&module.target) {
401                handler
402            } else {
403                Self::passthrough_handler()
404            }
405        } else {
406            debug!(
407                module_id = %module.module_id,
408                "RegistryWriter using passthrough handler (no HandlerFactory configured)",
409            );
410            Self::passthrough_handler()
411        };
412
413        // Check for streaming.
414        if annotations.streaming {
415            if let Some(stream_fn) = self
416                .streaming_handler_factory
417                .as_ref()
418                .and_then(|f| f(&module.target))
419            {
420                // Build StreamingFunctionModule.
421                let inner = apcore::decorator::FunctionModule::with_description(
422                    annotations.clone(),
423                    input_schema.clone(),
424                    output_schema.clone(),
425                    module.description.clone(),
426                    module.documentation.clone(),
427                    module.tags.clone(),
428                    module.version.clone(),
429                    module.metadata.clone(),
430                    module.examples.clone(),
431                    move |inputs, ctx| exec_handler(inputs, ctx),
432                );
433                let descriptor = Self::make_descriptor(module, &annotations);
434                return (
435                    Box::new(StreamingFunctionModule { inner, stream_fn }),
436                    descriptor,
437                );
438            }
439
440            // Streaming declared but no stream handler available.
441            warn!(
442                module_id = %module.module_id,
443                target = %module.target,
444                "RegistryWriter: module declares annotations.streaming=true but no \
445                 StreamingHandlerFactory provided a handler for this target; clearing \
446                 streaming flag to avoid StreamingInterfaceMismatch at registration. \
447                 Attach a StreamingHandlerFactory via with_streaming_handler_factory().",
448            );
449            annotations.streaming = false;
450        }
451
452        let fm = apcore::decorator::FunctionModule::with_description(
453            annotations.clone(),
454            input_schema,
455            output_schema,
456            module.description.clone(),
457            module.documentation.clone(),
458            module.tags.clone(),
459            module.version.clone(),
460            module.metadata.clone(),
461            module.examples.clone(),
462            move |inputs, ctx| exec_handler(inputs, ctx),
463        );
464        let descriptor = Self::make_descriptor(module, &annotations);
465        (Box::new(fm), descriptor)
466    }
467
468    fn passthrough_handler() -> HandlerFn {
469        Arc::new(|inputs, _ctx| Box::pin(async move { Ok(inputs) }))
470    }
471
472    fn make_descriptor(
473        module: &ScannedModule,
474        annotations: &apcore::module::ModuleAnnotations,
475    ) -> apcore::registry::registry::ModuleDescriptor {
476        apcore::registry::registry::ModuleDescriptor {
477            module_id: module.module_id.clone(),
478            name: Some(module.module_id.clone()),
479            description: module.description.clone(),
480            documentation: module.documentation.clone(),
481            input_schema: module.input_schema.clone(),
482            output_schema: module.output_schema.clone(),
483            version: module.version.clone(),
484            tags: module.tags.clone(),
485            annotations: Some(annotations.clone()),
486            examples: module.examples.clone(),
487            metadata: module.metadata.clone(),
488            display: module.display.clone(),
489            sunset_date: None,
490            dependencies: vec![],
491            enabled: true,
492        }
493    }
494}
495
496/// Verify that a module was successfully registered and is retrievable.
497fn verify_registry(result: &WriteResult, module_id: &str, registry: &Registry) -> WriteResult {
498    let verifier = RegistryVerifier::new(registry);
499    let vr = verifier.verify("", module_id);
500    if vr.ok {
501        result.clone()
502    } else {
503        WriteResult::failed(module_id.into(), None, vr.error.unwrap_or_default())
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use serde_json::json;
511
512    fn sample_module() -> ScannedModule {
513        ScannedModule::new(
514            "users.get".into(),
515            "Get user".into(),
516            json!({"type": "object"}),
517            json!({"type": "object"}),
518            vec!["users".into()],
519            "app:get_user".into(),
520        )
521    }
522
523    #[test]
524    fn test_write_dry_run() {
525        let writer = RegistryWriter::new();
526        let mut registry = Registry::new();
527        let modules = vec![sample_module()];
528        let results = writer.write(&modules, &mut registry, true, false, None);
529        assert_eq!(results.len(), 1);
530        assert_eq!(results[0].module_id, "users.get");
531        assert!(!registry.has("users.get"));
532    }
533
534    #[test]
535    fn test_write_registers_module() {
536        let writer = RegistryWriter::new();
537        let mut registry = Registry::new();
538        let modules = vec![sample_module()];
539        let results = writer.write(&modules, &mut registry, false, false, None);
540        assert_eq!(results.len(), 1);
541        assert!(registry.has("users.get"));
542    }
543
544    #[test]
545    fn test_write_with_verify() {
546        let writer = RegistryWriter::new();
547        let mut registry = Registry::new();
548        let modules = vec![sample_module()];
549        let results = writer.write(&modules, &mut registry, false, true, None);
550        assert_eq!(results.len(), 1);
551        assert!(results[0].verified);
552    }
553
554    #[test]
555    fn test_write_empty_list() {
556        let writer = RegistryWriter::new();
557        let mut registry = Registry::new();
558        let results = writer.write(&[], &mut registry, false, false, None);
559        assert!(results.is_empty());
560    }
561
562    #[test]
563    fn test_custom_verifier_runs_even_when_verify_false() {
564        // D11-011: verify=false skips the built-in registry check, but custom
565        // verifiers must still run. A failing custom verifier with verify=false
566        // should produce a result with verified=false.
567        use crate::output::types::{Verifier, VerifyResult};
568
569        struct AlwaysFail;
570        impl Verifier for AlwaysFail {
571            fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
572                VerifyResult::fail("custom verifier failed".into())
573            }
574        }
575
576        let writer = RegistryWriter::new();
577        let mut registry = Registry::new();
578        let modules = vec![sample_module()];
579        let failing_verifier = AlwaysFail;
580        let verifiers: &[&dyn Verifier] = &[&failing_verifier];
581        // verify=false: built-in registry check skipped, but custom verifier runs
582        let results = writer.write(&modules, &mut registry, false, false, Some(verifiers));
583        assert_eq!(results.len(), 1);
584        // Module was registered successfully
585        assert!(registry.has("users.get"));
586        // But custom verifier ran and failed — verified must be false
587        assert!(
588            !results[0].verified,
589            "custom verifier must run even when verify=false; result: {:?}",
590            results[0]
591        );
592        assert!(
593            results[0]
594                .verification_error
595                .as_deref()
596                .unwrap_or("")
597                .contains("custom verifier failed"),
598            "verification_error should contain the custom verifier message"
599        );
600    }
601
602    #[test]
603    fn test_write_multiple_modules() {
604        let writer = RegistryWriter::new();
605        let mut registry = Registry::new();
606        let modules = vec![
607            ScannedModule::new(
608                "mod.a".into(),
609                "A".into(),
610                json!({"type": "object"}),
611                json!({"type": "object"}),
612                vec![],
613                "app:a".into(),
614            ),
615            ScannedModule::new(
616                "mod.b".into(),
617                "B".into(),
618                json!({"type": "object"}),
619                json!({"type": "object"}),
620                vec![],
621                "app:b".into(),
622            ),
623        ];
624        let results = writer.write(&modules, &mut registry, false, false, None);
625        assert_eq!(results.len(), 2);
626        assert!(registry.has("mod.a"));
627        assert!(registry.has("mod.b"));
628        assert!(results[0].verified);
629        assert!(results[1].verified);
630    }
631
632    // D11-2 regression: allowed_prefixes is a defence-in-depth allow-list on
633    // the `target` field. A module whose target does not match any prefix
634    // must be rejected with a failed WriteResult and never registered.
635    #[test]
636    fn test_allowed_prefixes_rejects_non_matching_target() {
637        // Use module-path-only prefixes (no trailing colon) — matches the
638        // canonical Python/TypeScript behavior where prefixes are dotted
639        // module paths, not target strings with the `:callable` suffix.
640        let writer =
641            RegistryWriter::new().with_allowed_prefixes(vec!["app".into(), "myapp".into()]);
642        let mut registry = Registry::new();
643        let allowed = sample_module(); // target = "app:get_user"
644        let denied = ScannedModule::new(
645            "evil.module".into(),
646            "Forged target".into(),
647            json!({"type": "object"}),
648            json!({"type": "object"}),
649            vec![],
650            "evil:run_attacker_code".into(),
651        );
652        let results = writer.write(&[allowed, denied], &mut registry, false, false, None);
653        assert_eq!(results.len(), 2);
654        // app:get_user is in allowed_prefixes — registered.
655        assert!(registry.has("users.get"));
656        assert!(results[0].verified);
657        // evil:* is not — rejected, NOT registered.
658        assert!(!registry.has("evil.module"));
659        assert!(!results[1].verified);
660        let err = results[1].verification_error.as_deref().unwrap_or("");
661        assert!(
662            err.contains("allowed_prefixes"),
663            "rejection message should mention allowed_prefixes: got {err:?}"
664        );
665    }
666
667    // D11-002 regression: boundary-aware module-path matching. Prefix `"myapp"`
668    // must reject `"myappx.evil:fn"` (peer SDKs already reject; Rust used to
669    // accept due to bare `starts_with`). Mirrors Python's
670    // `_module_path_matches_prefix`.
671    #[test]
672    fn test_target_allowed_boundary_aware() {
673        let writer = RegistryWriter::new().with_allowed_prefixes(vec!["myapp".into()]);
674        // Exact match
675        assert!(writer.target_allowed("myapp:fn"));
676        // Dotted descendant
677        assert!(writer.target_allowed("myapp.foo:fn"));
678        assert!(writer.target_allowed("myapp.foo.bar:fn"));
679        // Non-match: same character prefix without dotted boundary
680        assert!(!writer.target_allowed("myappx.evil:fn"));
681        assert!(!writer.target_allowed("myappx:fn"));
682        // Unrelated module path
683        assert!(!writer.target_allowed("other:fn"));
684
685        // Nested prefix
686        let writer2 = RegistryWriter::new().with_allowed_prefixes(vec!["myapp.foo".into()]);
687        assert!(writer2.target_allowed("myapp.foo:fn"));
688        assert!(writer2.target_allowed("myapp.foo.bar:fn"));
689        assert!(!writer2.target_allowed("myapp.foobar:fn"));
690        assert!(!writer2.target_allowed("myapp:fn"));
691
692        // Trailing-dot tolerance and empty-prefix rejection
693        let writer3 = RegistryWriter::new().with_allowed_prefixes(vec!["myapp.".into()]);
694        assert!(writer3.target_allowed("myapp:fn"));
695        let writer4 = RegistryWriter::new().with_allowed_prefixes(vec!["".into()]);
696        assert!(!writer4.target_allowed("anything:fn"));
697    }
698
699    #[test]
700    fn test_allowed_prefixes_default_none_admits_everything() {
701        // Without allowed_prefixes set, target_allowed must return true for
702        // every input — preserves existing behaviour for callers that have
703        // not opted in.
704        let writer = RegistryWriter::new();
705        let mut registry = Registry::new();
706        let module = ScannedModule::new(
707            "any.module".into(),
708            "Any target".into(),
709            json!({"type": "object"}),
710            json!({"type": "object"}),
711            vec![],
712            "anything-goes:func".into(),
713        );
714        let results = writer.write(&[module], &mut registry, false, false, None);
715        assert_eq!(results.len(), 1);
716        assert!(registry.has("any.module"));
717    }
718
719    // ── Streaming module tests (apcore 0.22.0) ──────────────────────────────
720
721    fn make_streaming_module() -> ScannedModule {
722        use apcore::module::ModuleAnnotations;
723        let mut m = ScannedModule::new(
724            "stream.test".into(),
725            "streaming module".into(),
726            json!({"type": "object"}),
727            json!({"type": "object"}),
728            vec![],
729            "app:stream_handler".into(),
730        );
731        m.annotations = Some(ModuleAnnotations {
732            streaming: true,
733            ..Default::default()
734        });
735        m
736    }
737
738    #[test]
739    fn test_streaming_factory_registers_streaming_module() {
740        // When a StreamingHandlerFactory provides a handler, the module must be
741        // registered as a StreamingFunctionModule (as_streaming() returns Some).
742        use futures::stream;
743        let stream_factory: StreamingHandlerFactory = Arc::new(|_target: &str| {
744            Some(Arc::new(|_inputs: serde_json::Value, _ctx: &_| {
745                let s = stream::iter(vec![Ok(json!({"chunk": 1}))]);
746                Box::pin(s) as ChunkStream
747            }))
748        });
749
750        let writer = RegistryWriter::new().with_streaming_handler_factory(stream_factory);
751        let mut registry = Registry::new();
752        let module = make_streaming_module();
753        let results = writer.write(&[module], &mut registry, false, false, None);
754
755        assert_eq!(results.len(), 1);
756        assert!(
757            results[0].verified,
758            "streaming module should register successfully"
759        );
760        assert!(registry.has("stream.test"));
761    }
762
763    #[test]
764    fn test_streaming_annotation_no_factory_clears_streaming_and_warns() {
765        // Without a StreamingHandlerFactory, annotations.streaming is cleared so
766        // Registry.register does not raise StreamingInterfaceMismatch. The
767        // module is still registered as a non-streaming FunctionModule.
768        let writer = RegistryWriter::new(); // no streaming factory
769        let mut registry = Registry::new();
770        let module = make_streaming_module();
771        let results = writer.write(&[module], &mut registry, false, false, None);
772
773        assert_eq!(results.len(), 1);
774        assert!(
775            results[0].verified,
776            "module should register even without streaming factory"
777        );
778        assert!(registry.has("stream.test"));
779    }
780
781    #[test]
782    fn test_non_streaming_module_unaffected_by_streaming_factory() {
783        // A non-streaming module must not be affected by the presence of a
784        // StreamingHandlerFactory.
785        use futures::stream;
786        let stream_factory: StreamingHandlerFactory = Arc::new(|_: &str| {
787            Some(Arc::new(|inputs: serde_json::Value, _ctx: &_| {
788                let s = stream::iter(vec![Ok(inputs)]);
789                Box::pin(s) as ChunkStream
790            }))
791        });
792
793        let writer = RegistryWriter::new().with_streaming_handler_factory(stream_factory);
794        let mut registry = Registry::new();
795        let module = sample_module(); // annotations.streaming = false (default)
796        let results = writer.write(&[module], &mut registry, false, false, None);
797
798        assert_eq!(results.len(), 1);
799        assert!(results[0].verified);
800        assert!(registry.has("users.get"));
801    }
802
803    #[test]
804    fn test_custom_verifier_runs_unconditionally_and_merge_semantics() {
805        // Issue 4: custom verifiers must run regardless of the built-in verify
806        // result. Final verified = builtin_verified AND custom_chain_ok.
807        // Here verify=true (builtin passes) AND a failing custom verifier —
808        // the AND-merge must yield verified=false.
809        use crate::output::types::{Verifier, VerifyResult};
810
811        struct AlwaysFail;
812        impl Verifier for AlwaysFail {
813            fn verify(&self, _path: &str, _module_id: &str) -> VerifyResult {
814                VerifyResult::fail("custom fail and-merge".into())
815            }
816        }
817
818        let writer = RegistryWriter::new();
819        let mut registry = Registry::new();
820        let modules = vec![sample_module()];
821        let failing_verifier = AlwaysFail;
822        let verifiers: &[&dyn Verifier] = &[&failing_verifier];
823        // verify=true: built-in passes (module registered OK), but custom fails.
824        // AND-merge: final verified must be false.
825        let results = writer.write(&modules, &mut registry, false, true, Some(verifiers));
826        assert_eq!(results.len(), 1);
827        assert!(registry.has("users.get"), "module must be registered");
828        assert!(
829            !results[0].verified,
830            "AND-merge: builtin=true AND custom=false must yield verified=false; got: {:?}",
831            results[0]
832        );
833        assert!(
834            results[0]
835                .verification_error
836                .as_deref()
837                .unwrap_or("")
838                .contains("custom fail and-merge"),
839            "verification_error must contain the custom verifier message"
840        );
841    }
842}