F3: Binding Output -- Replace Binding Generator with apcore-toolkit Writers¶
| Field | Value |
|---|---|
| Feature ID | F3 |
| Tech Design Section | 5.3 |
| Priority | P1 (Output) |
| Dependencies | F1 (Scanner Adapter) |
| Depended On By | F4 (MCP Server) |
| New Files | src/output/mod.rs, src/output/yaml.rs, src/output/registry.rs, src/output/loader.rs |
| Deleted Files | src/binding/binding_gen.rs, src/binding/schema_gen.rs, src/binding/module_id.rs, src/binding/writer.rs, src/binding/mod.rs |
| Estimated LOC | ~400 |
| Estimated Tests | ~20 |
1. Purpose¶
Replace apexe's custom binding generator (BindingGenerator, SchemaGenerator, BindingYAMLWriter) with apcore-toolkit's standardized output pipeline (YAMLWriter, RegistryWriter, Verifier). This gains output verification, display metadata resolution, and consistency with other apcore ecosystem tools.
2. Module Structure¶
2.1 src/output/mod.rs¶
pub mod loader;
pub mod registry;
pub mod yaml;
pub use loader::load_modules_from_dir;
pub use registry::RegistryOutput;
pub use yaml::YamlOutput;
2.2 src/output/yaml.rs -- YamlOutput¶
use std::path::Path;
use apcore::ModuleError;
use apcore_toolkit::{ScannedModule, WriteResult, YAMLWriter, YAMLVerifier, SyntaxVerifier, Verifier};
/// Writes ScannedModules to .binding.yaml files using apcore-toolkit's YAMLWriter.
pub struct YamlOutput {
/// Underlying toolkit writer.
writer: YAMLWriter,
/// Verifiers to run before writing.
verifiers: Vec<Box<dyn Verifier>>,
}
impl YamlOutput {
/// Create a new YamlOutput with default verifiers (YAML syntax + structure).
pub fn new() -> Self {
Self {
writer: YAMLWriter,
verifiers: vec![
Box::new(YAMLVerifier),
Box::new(SyntaxVerifier),
],
}
}
/// Create a YamlOutput with no verifiers (for testing or speed).
pub fn without_verification() -> Self {
Self {
writer: YAMLWriter,
verifiers: vec![],
}
}
/// Write modules to YAML binding files in the given directory.
///
/// Steps:
/// 1. Group modules by tool name (extracted from module_id prefix).
/// 2. For each group, call YAMLWriter::write() with the module list.
/// 3. If verify is true, run all verifiers on the output.
/// 4. Return Vec<WriteResult> with paths and verification status.
pub fn write(
&self,
modules: &[ScannedModule],
output_dir: &Path,
dry_run: bool,
verify: bool,
) -> Result<Vec<WriteResult>, ModuleError>;
}
Write logic:
1. Create output_dir if it does not exist.
2. Group modules by extracting tool name from module_id:
"cli.git.commit" -> tool_name = "git"
"cli.docker.container.ls" -> tool_name = "docker"
3. For each tool group:
a. filename = "{tool_name}.binding.yaml"
b. Call self.writer.write(
&group_modules,
output_dir,
dry_run,
verify,
&self.verifiers,
)
c. Collect WriteResults
4. Return all WriteResults.
2.3 src/output/registry.rs -- RegistryOutput¶
use std::sync::Arc;
use apcore::{ModuleError, Registry};
use apcore_toolkit::{RegistryWriter, ScannedModule, HandlerFactory};
use crate::module::CliModule;
use crate::governance::{AuditManager, SandboxManager};
/// Registers ScannedModules directly into an apcore Registry as CliModules.
pub struct RegistryOutput {
writer: RegistryWriter,
}
impl RegistryOutput {
/// Create a new RegistryOutput with a handler factory that produces CliModules.
pub fn new(
timeout_ms: u64,
sandbox: Option<Arc<SandboxManager>>,
audit: Option<Arc<AuditManager>>,
) -> Self {
let timeout = timeout_ms;
let sb = sandbox.clone();
let au = audit.clone();
let factory: HandlerFactory = Box::new(move |module: &ScannedModule| {
Box::new(CliModule::from_scanned(module, timeout, sb.clone(), au.clone())?)
});
Self {
writer: RegistryWriter::with_handler_factory(factory),
}
}
/// Register all modules into the given registry.
///
/// Steps:
/// 1. For each ScannedModule, create a CliModule via the handler factory.
/// 2. Register the CliModule in the registry.
/// 3. Return count of registered modules or error.
pub fn register(
&self,
modules: &[ScannedModule],
registry: &Registry,
dry_run: bool,
verify: bool,
) -> Result<usize, ModuleError>;
}
2.4 src/output/loader.rs -- Module Loader¶
use std::path::Path;
use apcore::ModuleError;
use apcore_toolkit::{DisplayResolver, ScannedModule};
/// Load ScannedModules from .binding.yaml files in a directory.
///
/// Uses DisplayResolver to merge display metadata from files.
pub fn load_modules_from_dir(dir: &Path) -> Result<Vec<ScannedModule>, ModuleError>;
Load logic:
1. Read all *.binding.yaml files from dir.
2. For each file, deserialize YAML into Vec<ScannedModule>.
3. Use DisplayResolver to resolve display metadata.
4. Flatten into single Vec<ScannedModule>.
5. Return modules or error.
3. File Format Compatibility¶
The output YAML format must be readable by apcore-toolkit and by the load_modules_from_dir() loader. The format is defined by apcore-toolkit's YAMLWriter and looks like:
# git.binding.yaml
modules:
- module_id: cli.git.commit
description: "Record changes to the repository"
input_schema:
type: object
properties:
message:
type: string
description: "Commit message"
required: [message]
output_schema:
type: object
properties:
stdout: { type: string }
stderr: { type: string }
exit_code: { type: integer }
tags: [cli, git, gnu]
target: "exec:///usr/bin/git commit"
version: "2.43.0"
annotations:
readonly: false
destructive: false
idempotent: false
examples:
- "git commit -m 'initial commit'"
warnings: []
This replaces the v0.1.x format which had a different structure (bindings: key with metadata subfields). The migration is clean because the loader reads the new format exclusively.
4. Integration with CLI¶
4.1 Updated ScanArgs::execute()¶
// In src/cli/mod.rs
impl ScanArgs {
pub fn execute(self, config: &ApexeConfig) -> Result<(), ModuleError> {
let orchestrator = ScanOrchestrator::new(config.clone());
let scanned_tools = orchestrator.scan(&self.tools, self.no_cache, self.depth)?;
let converter = CliToolConverter::new();
let modules: Vec<ScannedModule> = scanned_tools
.iter()
.flat_map(|tool| converter.convert(tool))
.collect();
let output_dir = self.output_dir
.unwrap_or_else(|| config.modules_dir.clone());
let yaml_output = YamlOutput::new();
let results = yaml_output.write(&modules, &output_dir, self.dry_run, self.verify)?;
// Display results
for result in &results {
println!("Written: {} (verified: {})", result.path.display(), result.verified);
}
// Generate ACL (calls into F5)
let acl = AclManager::generate_default(&modules);
// ... write ACL file
Ok(())
}
}
5. Test Scenarios¶
5.1 YamlOutput Tests¶
| Test Name | Scenario | Expected |
|---|---|---|
test_yaml_output_writes_file |
3 modules from "git" tool | git.binding.yaml created |
test_yaml_output_groups_by_tool |
Modules from "git" and "docker" | Two files: git.binding.yaml, docker.binding.yaml |
test_yaml_output_file_is_valid_yaml |
Write and re-read | Deserialized modules match originals |
test_yaml_output_dry_run_no_files |
dry_run = true | No files created, WriteResults returned |
test_yaml_output_verify_catches_invalid |
Malformed module (empty module_id) | WriteResult.verification_error set |
test_yaml_output_creates_directory |
output_dir does not exist | Directory created, file written |
test_yaml_output_overwrites_existing |
File already exists | File overwritten with new content |
test_yaml_output_empty_modules |
Empty module list | No files created, empty results |
test_yaml_output_without_verification |
Use without_verification() | No verification errors even for edge cases |
5.2 RegistryOutput Tests¶
| Test Name | Scenario | Expected |
|---|---|---|
test_registry_output_registers_modules |
3 modules | Registry contains 3 entries |
test_registry_output_dry_run |
dry_run = true | Registry unchanged, returns count |
test_registry_output_creates_cli_modules |
Register and execute | Module executes CLI command |
test_registry_output_duplicate_ids_handled |
Two modules with same ID | Deduplication applied |
5.3 Loader Tests¶
| Test Name | Scenario | Expected |
|---|---|---|
test_loader_reads_binding_files |
Directory with 2 .binding.yaml files | All modules loaded |
test_loader_empty_directory |
Empty dir | Empty Vec returned |
test_loader_nonexistent_directory |
Dir does not exist | Err(ModuleError) |
test_loader_ignores_non_yaml |
Dir with .txt files | Only .binding.yaml processed |
test_loader_handles_malformed_yaml |
Invalid YAML content | Err with descriptive message |
6. Migration Notes¶
Deleted Types¶
| v0.1.x Type | Replacement |
|---|---|
GeneratedBinding |
ScannedModule from apcore-toolkit |
GeneratedBindingFile |
Vec |
BindingGenerator |
CliToolConverter (F1) + YamlOutput |
SchemaGenerator |
adapter::schema module (F1) |
BindingYAMLWriter |
YamlOutput wrapping YAMLWriter |
Test Migration¶
63 binding tests are deleted. 20 new output tests replace them. The test count is lower because: - Schema generation tests move to F1 (adapter). - Module ID generation tests move to F1 (adapter). - The remaining tests focus on write/load behavior, not generation logic.