1mod commands;
7
8use clap::{ArgMatches, Command, CommandFactory, FromArgMatches};
9use knowledge_base_crud::KnowledgeBaseRepository;
10use knowledge_base_extension_framework::bindings::ResolvedBindings;
11use knowledge_base_extension_framework::contracts::{ContractVersion, ExtensionId, KnowledgeBaseExtension};
12use knowledge_base_extension_framework::manifest::{ExtensionManifest, ManifestActivation, ManifestError};
13use knowledge_base_extension_framework::registry::ExtensionRegistry;
14use std::collections::BTreeMap;
15use std::env;
16use std::error::Error;
17use std::ffi::OsString;
18use std::fmt;
19use std::io::{self, Write};
20use std::path::{Path, PathBuf};
21use std::process::ExitCode;
22use std::sync::Arc;
23
24const KNOWLEDGE_BASE_PATH: &str = "KNOWLEDGE_BASE_PATH";
25
26#[derive(Debug)]
28pub struct CliError {
29 message: String,
30}
31
32impl CliError {
33 pub fn new(message: impl Into<String>) -> Self {
34 Self { message: message.into() }
35 }
36}
37
38impl fmt::Display for CliError {
39 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40 formatter.write_str(&self.message)
41 }
42}
43
44impl Error for CliError {}
45
46impl From<io::Error> for CliError {
47 fn from(error: io::Error) -> Self {
48 Self::new(format!("cannot write command output: {error}"))
49 }
50}
51
52pub struct ExtensionCommandContext<'a> {
54 repository: Option<&'a KnowledgeBaseRepository>,
55 bindings: Option<&'a ResolvedBindings>,
56}
57
58impl<'a> ExtensionCommandContext<'a> {
59 fn new(repository: Option<&'a KnowledgeBaseRepository>, bindings: Option<&'a ResolvedBindings>) -> Self {
60 Self { repository, bindings }
61 }
62
63 pub fn repository(&self) -> Option<&'a KnowledgeBaseRepository> {
65 self.repository
66 }
67
68 pub fn bindings(&self) -> Option<&'a ResolvedBindings> {
70 self.bindings
71 }
72
73 pub fn write_stdout(&self, content: &str) -> Result<(), CliError> {
75 io::stdout().lock().write_all(content.as_bytes()).map_err(CliError::from)
76 }
77
78 pub fn write_stderr(&self, content: &str) -> Result<(), CliError> {
80 io::stderr().lock().write_all(content.as_bytes()).map_err(CliError::from)
81 }
82}
83
84pub trait KnowledgeBaseCliExtension: KnowledgeBaseExtension {
86 fn command(&self) -> Command;
88 fn requires_repository(&self, matches: &ArgMatches) -> bool;
90 fn execute(&self, matches: &ArgMatches, context: ExtensionCommandContext<'_>) -> Result<ExitCode, CliError>;
92}
93
94#[derive(Debug)]
96pub enum BuildError {
97 Framework(knowledge_base_extension_framework::error::FrameworkError),
98 InvalidCliCommand { extension: ExtensionId, command: String },
99}
100
101impl fmt::Display for BuildError {
102 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103 match self {
104 Self::Framework(error) => error.fmt(formatter),
105 Self::InvalidCliCommand { extension, command } => write!(formatter, "CLI extension {extension} must provide command {extension}, not {command}"),
106 }
107 }
108}
109
110impl Error for BuildError {}
111
112#[derive(Default)]
114pub struct Builder {
115 core_extensions: Vec<Arc<dyn KnowledgeBaseExtension>>,
116 cli_extensions: Vec<Arc<dyn KnowledgeBaseCliExtension>>,
117}
118
119impl Builder {
120 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn with_core_extension<E>(mut self, extension: E) -> Self
126 where
127 E: KnowledgeBaseExtension + 'static,
128 {
129 self.core_extensions.push(Arc::new(extension));
130 self
131 }
132
133 pub fn with_extension<E>(mut self, extension: E) -> Self
135 where
136 E: KnowledgeBaseCliExtension + 'static,
137 {
138 let extension = Arc::new(extension);
139 self.core_extensions.push(extension.clone());
140 self.cli_extensions.push(extension);
141 self
142 }
143
144 pub fn build(self) -> Result<Application, BuildError> {
145 let registry = ExtensionRegistry::new(self.core_extensions).map_err(BuildError::Framework)?;
146 let mut cli_extensions = BTreeMap::new();
147 for extension in self.cli_extensions {
148 let id = extension.metadata().id.clone();
149 let command = extension.command();
150 if command.get_name() != id.as_str() {
151 return Err(BuildError::InvalidCliCommand {
152 extension: id,
153 command: command.get_name().to_owned(),
154 });
155 }
156 let previous = cli_extensions.insert(id, extension);
157 debug_assert!(previous.is_none(), "duplicate CLI extensions are rejected by the core registry");
158 }
159 Ok(Application { registry, cli_extensions })
160 }
161}
162
163pub struct Application {
165 registry: ExtensionRegistry,
166 cli_extensions: BTreeMap<ExtensionId, Arc<dyn KnowledgeBaseCliExtension>>,
167}
168
169impl Application {
170 pub fn builder() -> Builder {
172 Builder::new()
173 }
174
175 pub fn run(&self) -> ExitCode {
177 self.run_from(env::args_os())
178 }
179
180 pub fn run_from<I, T>(&self, arguments: I) -> ExitCode
182 where
183 I: IntoIterator<Item = T>,
184 T: Into<OsString> + Clone,
185 {
186 let matches = match self.command().try_get_matches_from(arguments) {
187 Ok(matches) => matches,
188 Err(error) => error.exit(),
189 };
190 match self.execute_matches(&matches) {
191 Ok(code) => code,
192 Err(error) => {
193 eprintln!("{error}");
194 ExitCode::FAILURE
195 }
196 }
197 }
198
199 pub fn command(&self) -> Command {
201 let extension = self.cli_extensions.values().fold(
202 Command::new("extension")
203 .about("Inspect and run knowledge-base extensions")
204 .subcommand(Command::new("list").about("List declared and compiled extensions"))
205 .subcommand(Command::new("check").about("Check configured extensions")),
206 |command, extension| command.subcommand(extension.command()),
207 );
208 commands::Cli::command().subcommand(extension)
209 }
210
211 fn execute_matches(&self, matches: &ArgMatches) -> Result<ExitCode, CliError> {
212 if let Some(("extension", extension_matches)) = matches.subcommand() {
213 return self.execute_extension(extension_matches);
214 }
215 let cli = commands::Cli::from_arg_matches(matches).map_err(|error| CliError::new(error.to_string()))?;
216 if cli.command.requires_knowledge_base() {
217 let context = self.repository_context(&knowledge_base_path().map_err(CliError::new)?)?;
218 commands::execute(cli.command, Some(&commands::RepositoryContext { repository: context.repository() })).map_err(|error| CliError::new(error.to_string()))
219 } else {
220 commands::execute(cli.command, None).map_err(|error| CliError::new(error.to_string()))
221 }
222 }
223
224 fn execute_extension(&self, matches: &ArgMatches) -> Result<ExitCode, CliError> {
225 match matches.subcommand() {
226 Some(("list", _)) => self.list_extensions(&knowledge_base_path().map_err(CliError::new)?),
227 Some(("check", _)) => self.check_extensions(&knowledge_base_path().map_err(CliError::new)?),
228 Some((name, command_matches)) => {
229 let id: ExtensionId = name.parse().expect("registered extension command names are canonical IDs");
230 let extension = self.cli_extensions.get(&id).expect("registered extension command has a handler");
231 let context = if extension.requires_repository(command_matches) {
232 Some(self.repository_context(&knowledge_base_path().map_err(CliError::new)?)?)
233 } else {
234 None
235 };
236 extension.execute(
237 command_matches,
238 ExtensionCommandContext::new(context.as_ref().map(RepositoryContext::repository), context.as_ref().map(RepositoryContext::bindings)),
239 )
240 }
241 None => Err(CliError::new("an extension subcommand is required")),
242 }
243 }
244
245 fn repository_context(&self, root: &Path) -> Result<RepositoryContext, CliError> {
246 let activation = ExtensionManifest::load_and_activate(root, &self.registry).map_err(manifest_error)?;
247 let validators = activation.active().validators(activation.bindings()).map_err(|error| CliError::new(error.to_string()))?;
248 Ok(RepositoryContext {
249 repository: KnowledgeBaseRepository::with_validators(root.to_path_buf(), validators),
250 activation,
251 })
252 }
253
254 fn list_extensions(&self, root: &Path) -> Result<ExitCode, CliError> {
255 let manifest = ExtensionManifest::load(root).map_err(manifest_error)?;
256 let activation = manifest.activate(root, &self.registry);
257 let report = ExtensionList::new(&manifest, &self.registry, activation.is_ok());
258 let output = serde_yaml::to_string(&report).map_err(|error| CliError::new(format!("cannot serialize command output: {error}")))?;
259 write_stdout(&output)?;
260 match activation {
261 Ok(_) => Ok(ExitCode::SUCCESS),
262 Err(error) => {
263 eprintln!("{}", manifest_error(error));
264 Ok(ExitCode::FAILURE)
265 }
266 }
267 }
268
269 fn check_extensions(&self, root: &Path) -> Result<ExitCode, CliError> {
270 self.repository_context(root)?;
271 let output = serde_yaml::to_string(&ExtensionCheck { version: 1, status: "valid" }).map_err(|error| CliError::new(format!("cannot serialize command output: {error}")))?;
272 write_stdout(&output)?;
273 Ok(ExitCode::SUCCESS)
274 }
275}
276
277struct RepositoryContext {
278 repository: KnowledgeBaseRepository,
279 activation: ManifestActivation,
280}
281
282impl RepositoryContext {
283 fn repository(&self) -> &KnowledgeBaseRepository {
284 &self.repository
285 }
286 fn bindings(&self) -> &ResolvedBindings {
287 self.activation.bindings()
288 }
289}
290
291#[derive(serde::Serialize)]
292struct ExtensionList {
293 version: u32,
294 extensions: BTreeMap<ExtensionId, ExtensionListEntry>,
295}
296
297#[derive(serde::Serialize)]
298struct ExtensionListEntry {
299 declared_contract: Option<ContractVersion>,
300 available_contract: Option<ContractVersion>,
301 active: bool,
302 incompatible: bool,
303}
304
305impl ExtensionList {
306 fn new(manifest: &ExtensionManifest, registry: &ExtensionRegistry, activation_succeeded: bool) -> Self {
307 let mut ids = manifest.extensions.keys().cloned().collect::<std::collections::BTreeSet<_>>();
308 ids.extend(registry.extensions().map(|extension| extension.metadata().id.clone()));
309 let extensions = ids
310 .into_iter()
311 .map(|id| {
312 let declared = manifest.extensions.get(&id).map(|extension| extension.contract);
313 let available = registry.metadata(&id).map(|extension| extension.contract);
314 let compatible = declared.zip(available).is_some_and(|(declared, available)| declared == available);
315 (
316 id,
317 ExtensionListEntry {
318 declared_contract: declared,
319 available_contract: available,
320 active: activation_succeeded && compatible,
321 incompatible: declared.is_some() && (!activation_succeeded || !compatible),
322 },
323 )
324 })
325 .collect();
326 Self { version: 1, extensions }
327 }
328}
329
330#[derive(serde::Serialize)]
331struct ExtensionCheck {
332 version: u32,
333 status: &'static str,
334}
335
336fn manifest_error(error: ManifestError) -> CliError {
337 CliError::new(error.to_string())
338}
339
340fn write_stdout(content: &str) -> Result<(), CliError> {
341 io::stdout().lock().write_all(content.as_bytes()).map_err(CliError::from)
342}
343
344fn knowledge_base_path() -> Result<PathBuf, &'static str> {
345 match env::var_os(KNOWLEDGE_BASE_PATH) {
346 Some(value) if !value.is_empty() => Ok(value.into()),
347 _ => Err("KNOWLEDGE_BASE_PATH must be set to the knowledge-base root directory"),
348 }
349}
350
351pub fn run() -> ExitCode {
353 Application::builder().build().expect("base application is valid").run()
354}
355
356pub fn run_from<I, T>(arguments: I) -> ExitCode
358where
359 I: IntoIterator<Item = T>,
360 T: Into<OsString> + Clone,
361{
362 Application::builder().build().expect("base application is valid").run_from(arguments)
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use knowledge_base_extension_framework::bindings::ResolvedBindings;
369 use knowledge_base_extension_framework::contracts::{ExtensionMetadata, OntologyRequirements};
370 use knowledge_base_extension_framework::error::FrameworkError;
371 use knowledge_base_validation::{Diagnostic, KnowledgeBaseValidator, ValidationContext, ValidationLayer};
372 use std::fs;
373 use std::path::Path;
374 use std::sync::atomic::{AtomicBool, Ordering};
375
376 struct CoreExtension(ExtensionMetadata);
377
378 impl KnowledgeBaseExtension for CoreExtension {
379 fn metadata(&self) -> &ExtensionMetadata {
380 &self.0
381 }
382 }
383
384 struct TestExtension {
385 metadata: ExtensionMetadata,
386 called: Arc<AtomicBool>,
387 command_name: &'static str,
388 }
389
390 impl KnowledgeBaseExtension for TestExtension {
391 fn metadata(&self) -> &ExtensionMetadata {
392 &self.metadata
393 }
394 }
395
396 impl KnowledgeBaseCliExtension for TestExtension {
397 fn command(&self) -> Command {
398 Command::new(self.command_name)
399 }
400 fn requires_repository(&self, _: &ArgMatches) -> bool {
401 false
402 }
403 fn execute(&self, _: &ArgMatches, context: ExtensionCommandContext<'_>) -> Result<ExitCode, CliError> {
404 assert!(context.repository().is_none());
405 assert!(context.bindings().is_none());
406 self.called.store(true, Ordering::SeqCst);
407 Ok(ExitCode::SUCCESS)
408 }
409 }
410
411 fn id(value: &str) -> ExtensionId {
412 value.parse().unwrap()
413 }
414
415 fn metadata(name: &str) -> ExtensionMetadata {
416 ExtensionMetadata {
417 id: id(name),
418 contract: ContractVersion::new(1),
419 dependencies: Vec::new(),
420 bindings: Vec::new(),
421 ontology_requirements: OntologyRequirements::default(),
422 }
423 }
424
425 struct RejectingExtension(ExtensionMetadata);
426
427 impl KnowledgeBaseExtension for RejectingExtension {
428 fn metadata(&self) -> &ExtensionMetadata {
429 &self.0
430 }
431
432 fn validators(&self, _: &ResolvedBindings) -> Result<Vec<Arc<dyn KnowledgeBaseValidator>>, FrameworkError> {
433 Ok(vec![Arc::new(|_: &ValidationContext<'_>| {
434 vec![Diagnostic {
435 layer: ValidationLayer::Domain,
436 path: "entities/Q1.yaml".into(),
437 line: None,
438 identifier: Some("Q1".to_owned()),
439 message: "test extension rejects staged repository".to_owned(),
440 }]
441 })])
442 }
443 }
444
445 fn copy_fixture(destination: &Path) {
446 let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join("fixtures/valid/minimal");
447 for directory in ["entities", "entity_types", "properties", "references", "entity_context"] {
448 fs::create_dir(destination.join(directory)).unwrap();
449 for entry in fs::read_dir(source.join(directory)).unwrap() {
450 let entry = entry.unwrap();
451 fs::copy(entry.path(), destination.join(directory).join(entry.file_name())).unwrap();
452 }
453 }
454 fs::copy(source.join("id_allocation.yaml"), destination.join("id_allocation.yaml")).unwrap();
455 }
456
457 #[test]
458 fn builder_routes_repository_independent_extension_commands() {
459 let called = Arc::new(AtomicBool::new(false));
460 let application = Application::builder()
461 .with_extension(TestExtension {
462 metadata: metadata("demo"),
463 called: called.clone(),
464 command_name: "demo",
465 })
466 .build()
467 .unwrap();
468 assert_eq!(application.run_from(["knowledge-base", "extension", "demo"]), ExitCode::SUCCESS);
469 assert!(called.load(Ordering::SeqCst));
470 }
471
472 #[test]
473 fn builder_rejects_an_extension_command_with_the_wrong_name() {
474 let result = Application::builder()
475 .with_extension(TestExtension {
476 metadata: metadata("demo"),
477 called: Arc::new(AtomicBool::new(false)),
478 command_name: "wrong",
479 })
480 .build();
481 let Err(error) = result else { panic!("builder unexpectedly succeeded") };
482 assert!(matches!(error, BuildError::InvalidCliCommand { extension, command } if extension == id("demo") && command == "wrong"));
483 }
484
485 #[test]
486 fn builder_rejects_duplicate_extension_ids() {
487 let result = Application::builder()
488 .with_core_extension(CoreExtension(metadata("demo")))
489 .with_extension(TestExtension {
490 metadata: metadata("demo"),
491 called: Arc::new(AtomicBool::new(false)),
492 command_name: "demo",
493 })
494 .build();
495 let Err(error) = result else { panic!("builder unexpectedly succeeded") };
496 assert!(matches!(
497 error,
498 BuildError::Framework(knowledge_base_extension_framework::error::FrameworkError::DuplicateExtension(extension))
499 if extension == id("demo")
500 ));
501 }
502
503 #[test]
504 fn builder_accepts_a_core_only_extension() {
505 let application = Application::builder().with_core_extension(CoreExtension(metadata("demo"))).build().unwrap();
506 assert!(application.registry.metadata(&id("demo")).is_some());
507 assert!(application.cli_extensions.is_empty());
508 }
509
510 #[test]
511 fn activated_extension_validators_reject_staged_statement_mutations_atomically() {
512 let root = tempfile::tempdir().unwrap();
513 copy_fixture(root.path());
514 fs::write(root.path().join("extensions.yaml"), "version: 1\nextensions:\n rejector:\n contract: 1\n").unwrap();
515 fs::write(
516 root.path().join("statements.yaml"),
517 "statements:\n - entity: Q1\n property: P1\n value: { type: integer, value: 999 }\n references: [R1]\n",
518 )
519 .unwrap();
520 let extension = RejectingExtension(ExtensionMetadata {
521 id: id("rejector"),
522 contract: ContractVersion::new(1),
523 dependencies: Vec::new(),
524 bindings: Vec::new(),
525 ontology_requirements: OntologyRequirements::default(),
526 });
527 let application = Application::builder().with_core_extension(extension).build().unwrap();
528 let context = application.repository_context(root.path()).unwrap();
529 let entity_before = fs::read(root.path().join("entities/Q1.yaml")).unwrap();
530 let allocation_before = fs::read(root.path().join("id_allocation.yaml")).unwrap();
531 let batch = knowledge_base_crud::write::StatementBatch::read(root.path().join("statements.yaml")).unwrap();
532 let error = context
533 .repository()
534 .write()
535 .statements()
536 .apply(&batch, knowledge_base_crud::write::WriteMode::Commit)
537 .unwrap_err();
538 assert!(error.to_string().contains("test extension rejects staged repository"));
539 assert_eq!(fs::read(root.path().join("entities/Q1.yaml")).unwrap(), entity_before);
540 assert_eq!(fs::read(root.path().join("id_allocation.yaml")).unwrap(), allocation_before);
541 }
542}