Skip to main content

faucet_cli/commands/
install.rs

1//! `faucet install` — tell the user how to obtain/enable a connector (#208).
2//!
3//! Never executes anything: it resolves the connector in the registry index and
4//! prints the exact recipe (a `cargo install … --features …` line for a
5//! built-in, or custom-binary guidance for a community connector).
6
7use crate::cli::InstallArgs;
8use crate::error::{CliError, CliResult};
9use crate::registry::{sink_exists, source_exists};
10use crate::registry_index::{InstallRecipe, RegistryIndex, install_recipe};
11
12/// Execute the `install` subcommand.
13pub async fn run(args: InstallArgs) -> CliResult<()> {
14    let idx = RegistryIndex::load(args.index.as_deref())?;
15    let matches = idx.find(&args.name, args.kind.as_deref());
16    let entry = match matches.as_slice() {
17        [] => {
18            return Err(CliError::Config(format!(
19                "connector '{}' is not in the registry index — try `faucet search {}`",
20                args.name, args.name
21            )));
22        }
23        [one] => *one,
24        many => {
25            return Err(CliError::Config(format!(
26                "connector '{}' is ambiguous ({} entries: {}); disambiguate with --kind source|sink",
27                args.name,
28                many.len(),
29                many.iter()
30                    .map(|c| c.kind.as_str())
31                    .collect::<Vec<_>>()
32                    .join(", ")
33            )));
34        }
35    };
36
37    let compiled = match entry.kind.as_str() {
38        "source" => source_exists(&entry.name),
39        "sink" => sink_exists(&entry.name),
40        _ => false,
41    };
42
43    match install_recipe(entry, compiled) {
44        InstallRecipe::AlreadyAvailable { feature } => {
45            println!(
46                "✔ {} '{}' is already available in this binary (feature `{}`).",
47                entry.kind, entry.name, feature
48            );
49            println!("   Use it directly: `type: {}` in your config.", entry.name);
50        }
51        InstallRecipe::CargoInstall { feature } => {
52            println!(
53                "'{}' is a built-in {} connector, not compiled into this binary.",
54                entry.name, entry.kind
55            );
56            println!("Reinstall the CLI with it enabled:\n");
57            println!("  cargo install faucet-cli --features {feature}\n");
58            println!("(add `{feature}` to your existing `--features` list to keep the others).");
59        }
60        InstallRecipe::CustomBinary { krate, feature } => {
61            println!(
62                "'{}' is a community {} connector (crate `{}`).",
63                entry.name, entry.kind, krate
64            );
65            println!("Use it by building a custom `faucet` binary that registers it:\n");
66            println!("  cargo new my-faucet && cd my-faucet");
67            println!("  cargo add faucet-cli faucet-core {krate}\n");
68            let (reg_fn, trait_ctor) = match entry.kind.as_str() {
69                "source" => ("register_source", "MySource::from_value(cfg)?"),
70                _ => ("register_sink", "MySink::from_value(cfg)?"),
71            };
72            println!("  // src/main.rs");
73            println!("  use faucet_cli::registry::PluginRegistry;");
74            println!("  fn main() -> std::process::ExitCode {{");
75            println!(
76                "      let reg = PluginRegistry::with_builtins().{reg_fn}(\"{}\", |cfg| Ok(Box::new({trait_ctor})));",
77                entry.name
78            );
79            println!("      faucet_cli::run_main(reg)");
80            println!("  }}\n");
81            println!(
82                "See cli/README.md → \"Custom binaries with third-party connectors\". (feature hint: `{feature}`)"
83            );
84        }
85    }
86    Ok(())
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::cli::InstallArgs;
93
94    #[tokio::test]
95    async fn install_builtin_ok() {
96        // `jsonl` sink is built-in; under default features it is compiled in.
97        run(InstallArgs {
98            name: "jsonl".into(),
99            kind: Some("sink".into()),
100            index: None,
101        })
102        .await
103        .unwrap();
104    }
105
106    #[tokio::test]
107    async fn install_unknown_errors() {
108        let err = run(InstallArgs {
109            name: "nope-connector".into(),
110            kind: None,
111            index: None,
112        })
113        .await
114        .unwrap_err();
115        assert!(matches!(err, CliError::Config(_)));
116    }
117
118    #[tokio::test]
119    async fn install_ambiguous_requires_kind() {
120        // `postgres` exists as both a source and a sink → ambiguous without --kind.
121        let err = run(InstallArgs {
122            name: "postgres".into(),
123            kind: None,
124            index: None,
125        })
126        .await
127        .unwrap_err();
128        match err {
129            CliError::Config(msg) => assert!(msg.contains("ambiguous"), "{msg}"),
130            other => panic!("expected ambiguity error, got {other:?}"),
131        }
132    }
133
134    #[tokio::test]
135    async fn install_community_custom_binary() {
136        let dir = tempfile::tempdir().unwrap();
137        let p = dir.path().join("idx.json");
138        std::fs::write(
139            &p,
140            r#"{"version":1,"connectors":[{"name":"acme","kind":"source","verified":false,"description":"Acme"}]}"#,
141        )
142        .unwrap();
143        run(InstallArgs {
144            name: "acme".into(),
145            kind: None,
146            index: Some(p),
147        })
148        .await
149        .unwrap();
150    }
151}