Skip to main content

knowledge_base_cli/
lib.rs

1//! Reusable command-line application support for a file-based knowledge base.
2//!
3//! The published binary is the base-only application. Downstream applications
4//! can statically register extensions with [`Application::builder`].
5
6mod 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/// A CLI error rendered by the application using the base CLI error convention.
27#[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
52/// Context provided to an extension command after optional repository activation.
53pub 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    /// Returns the activated repository for repository-dependent commands.
64    pub fn repository(&self) -> Option<&'a KnowledgeBaseRepository> {
65        self.repository
66    }
67
68    /// Returns semantic bindings for the activated extension set.
69    pub fn bindings(&self) -> Option<&'a ResolvedBindings> {
70        self.bindings
71    }
72
73    /// Writes command data to standard output.
74    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    /// Writes a diagnostic to standard error.
79    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
84/// Optional CLI behavior for one statically compiled extension.
85pub trait KnowledgeBaseCliExtension: KnowledgeBaseExtension {
86    /// The static command inserted below `knowledge-base extension`.
87    fn command(&self) -> Command;
88    /// Whether the selected command requires a configured and activated repository.
89    fn requires_repository(&self, matches: &ArgMatches) -> bool;
90    /// Executes a parsed extension command.
91    fn execute(&self, matches: &ArgMatches, context: ExtensionCommandContext<'_>) -> Result<ExitCode, CliError>;
92}
93
94/// Errors found while assembling one statically composed application.
95#[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/// Collects the static extensions that form one executable application.
113#[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    /// Adds an extension that does not expose CLI commands.
125    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    /// Adds one extension object with its core contract and CLI capability.
134    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
163/// A validated static CLI application.
164pub struct Application {
165    registry: ExtensionRegistry,
166    cli_extensions: BTreeMap<ExtensionId, Arc<dyn KnowledgeBaseCliExtension>>,
167}
168
169impl Application {
170    /// Starts configuring a CLI application.
171    pub fn builder() -> Builder {
172        Builder::new()
173    }
174
175    /// Parses and executes a command using process arguments.
176    pub fn run(&self) -> ExitCode {
177        self.run_from(env::args_os())
178    }
179
180    /// Parses and executes a command from supplied arguments.
181    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    /// Returns the fully composed Clap command for help and embedding.
200    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
351/// Runs the base-only published application.
352pub fn run() -> ExitCode {
353    Application::builder().build().expect("base application is valid").run()
354}
355
356/// Runs the base-only published application with supplied arguments.
357pub 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}