From 5995c161d84dd73be72566a9bdcf496224dca015 Mon Sep 17 00:00:00 2001 From: Irina Shestak Date: Tue, 11 May 2021 15:41:53 +0200 Subject: [PATCH] feat(rover): compose from running services, registered graphs or subgraphs (#519) This commit introduces multiple ways to run composition locally. You may compose from various subgraphs that could obtain SDL from using Apollo Registry refs (`subgraph`, `graphref`), local file references (`file`) and subgraph introspection (`subgraph_url`). For example: ```yaml subgraphs: films: routing_url: https://films.example.com schema: file: ./films.graphql people: schema: subgraph_url: https://example.com/people actors: routing_url: https://localhost:4005 schema: graphref: mygraph@current subgraph: actors ``` Co-authored-by: Jesse Rosenberger --- CHANGELOG.md | 24 +++ docs/source/errors.md | 7 + docs/source/supergraphs.md | 21 +- installers/binstall/src/system/windows.rs | 6 +- installers/npm/package-lock.json | 2 +- installers/npm/package.json | 2 +- src/command/supergraph/compose.rs | 222 +++++++++++++++++++++- src/command/supergraph/config.rs | 150 +++------------ src/command/supergraph/mod.rs | 2 +- src/error/metadata/mod.rs | 2 +- src/error/metadata/suggestion.rs | 8 + src/error/mod.rs | 10 + src/lib.rs | 2 +- 13 files changed, 326 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3904404fc..ef03f0a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,30 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm [EverlastingBugstopper]: https://github.com/EverlastingBugstopper [pull/492]: https://github.com/apollographql/rover/pull/492 +- **`rover supergraph compose` allows for registry and introspection SDL sources - [lrlna], [issue/449] [pull/519]** + + Pulls subgraphs from various sources specified in the YAML config file. A valid config can now specify schema using Apollo Registry refs (`subgraph`, `graphref`), local file references (`file`) and subgraph introspection (`subgraph_url`): + + ```yaml + subgraphs: + films: + routing_url: https://films.example.com + schema: + file: ./films.graphql + people: + routing_url: https://example.com/people + schema: + subgraph_url: https://example.com/people + actors: + routing_url: https://localhost:4005 + schema: + graphref: mygraph@current + subgraph: actors + ``` + [lrlna]: https://github.com/lrlna + [issue/449]: https://github.com/apollographql/rover/issues/449 + [pull/519]: https://github.com/apollographql/rover/pull/519 + - **`--routing-url` is now an optional argument to `rover subgraph publish` - [EverlastingBusgtopper], [issue/169] [pull/484]** When publishing a subgraph, it is important to include a routing URL for that subgraph, so your graph router diff --git a/docs/source/errors.md b/docs/source/errors.md index 706363955..27a7ba995 100644 --- a/docs/source/errors.md +++ b/docs/source/errors.md @@ -227,3 +227,10 @@ This error occurs when working with a federated graph and its subgraphs. When gr To resolve this error, inspect the printed errors and correct the subgraph schemas. +### E028 + +This error occurs when a connection could not be established with to an introspection endpoint. + +To resolve this problem, make sure the endpoint URL is correct. You may wish to run the command again with `--log=debug`. + + diff --git a/docs/source/supergraphs.md b/docs/source/supergraphs.md index 12dcdc73f..a3c01e771 100644 --- a/docs/source/supergraphs.md +++ b/docs/source/supergraphs.md @@ -43,7 +43,26 @@ subgraphs: file: ./people.graphql ``` -The YAML file must specify each subgraph's public-facing URL (`routing_url`), along with the path to its schema (`schema.file`). +In the above example, The YAML file specifies each subgraph's public-facing URL (`routing_url`), along with the path to its schema (`schema.file`). + +It's also possible to pull subgraphs from various sources and specify them in the YAML file. For example, here is a configuration that specifies schema using Apollo Registry refs (`subgraph`, `graphref`) and subgraph introspection (`subgraph_url`): + +```yaml +subgraphs: + films: + routing_url: https://films.example.com + schema: + file: ./films.graphql + people: + routing_url: https://example.com/people + schema: + subgraph_url: https://example.com/people + actors: + routing_url: https://localhost:4005 + schema: + graphref: mygraph@current + subgraph: actors +``` ### Output format diff --git a/installers/binstall/src/system/windows.rs b/installers/binstall/src/system/windows.rs index 6b5e2b8a6..6f0fe18f1 100644 --- a/installers/binstall/src/system/windows.rs +++ b/installers/binstall/src/system/windows.rs @@ -44,7 +44,7 @@ fn get_windows_path_var() -> Result, InstallerError> { } } Err(ref e) if e.kind() == io::ErrorKind::NotFound => Ok(Some(String::new())), - Err(e) => Err(e)?, + Err(e) => Err(e.into()), } } @@ -83,7 +83,7 @@ fn add_to_path(old_path: &str, path_str: &str) -> Option { None } else { let mut new_path = path_str.to_string(); - new_path.push_str(";"); + new_path.push(';'); new_path.push_str(&old_path); Some(new_path) } @@ -116,7 +116,7 @@ fn apply_new_path(new_path: &str) -> Result<(), InstallerError> { SendMessageTimeoutA( HWND_BROADCAST, WM_SETTINGCHANGE, - 0 as WPARAM, + 0_usize, "Environment\0".as_ptr() as LPARAM, SMTO_ABORTIFHUNG, 5000, diff --git a/installers/npm/package-lock.json b/installers/npm/package-lock.json index 92759a651..8981bd5d5 100644 --- a/installers/npm/package-lock.json +++ b/installers/npm/package-lock.json @@ -17,7 +17,7 @@ "rover": "run.js" }, "devDependencies": { - "prettier": "^2.2.1" + "prettier": "^2.3.0" } }, "node_modules/axios": { diff --git a/installers/npm/package.json b/installers/npm/package.json index 4286ff128..c78decec7 100644 --- a/installers/npm/package.json +++ b/installers/npm/package.json @@ -33,6 +33,6 @@ "console.table": "^0.10.0" }, "devDependencies": { - "prettier": "^2.2.1" + "prettier": "^2.3.0" } } diff --git a/src/command/supergraph/compose.rs b/src/command/supergraph/compose.rs index ba1d6f9de..2cc0c18c1 100644 --- a/src/command/supergraph/compose.rs +++ b/src/command/supergraph/compose.rs @@ -1,11 +1,18 @@ -use crate::{anyhow, command::RoverStdout, Result}; +use crate::utils::{client::StudioClientConfig, parsers::parse_graph_ref}; +use crate::{anyhow, command::RoverStdout, error::RoverError, Result, Suggestion}; use ansi_term::Colour::Red; use camino::Utf8PathBuf; +use harmonizer::ServiceDefinition as SubgraphDefinition; +use rover_client::{ + blocking::Client, + query::subgraph::{fetch, introspect}, +}; use serde::Serialize; +use std::{collections::HashMap, fs}; use structopt::StructOpt; -use super::config; +use super::config::{self, SchemaSource, SupergraphConfig}; #[derive(Debug, Serialize, StructOpt)] pub struct Compose { @@ -13,12 +20,22 @@ pub struct Compose { #[structopt(long = "config")] #[serde(skip_serializing)] config_path: Utf8PathBuf, + + /// Name of configuration profile to use + #[structopt(long = "profile", default_value = "default")] + #[serde(skip_serializing)] + profile_name: String, } impl Compose { - pub fn run(&self) -> Result { + pub fn run(&self, client_config: StudioClientConfig) -> Result { let supergraph_config = config::parse_supergraph_config(&self.config_path)?; - let subgraph_definitions = supergraph_config.get_subgraph_definitions(&self.config_path)?; + let subgraph_definitions = get_subgraph_definitions( + supergraph_config, + &self.config_path, + client_config, + &self.profile_name, + )?; match harmonizer::harmonize(subgraph_definitions) { Ok(core_schema) => Ok(RoverStdout::CoreSchema(core_schema)), @@ -43,3 +60,200 @@ impl Compose { } } } + +pub(crate) fn get_subgraph_definitions( + supergraph_config: SupergraphConfig, + config_path: &Utf8PathBuf, + client_config: StudioClientConfig, + profile_name: &str, +) -> Result> { + let mut subgraphs = Vec::new(); + + for (subgraph_name, subgraph_data) in &supergraph_config.subgraphs { + match &subgraph_data.schema { + SchemaSource::File { file } => { + let relative_schema_path = match config_path.parent() { + Some(parent) => { + let mut schema_path = parent.to_path_buf(); + schema_path.push(file); + schema_path + } + None => file.clone(), + }; + + let schema = fs::read_to_string(&relative_schema_path).map_err(|e| { + let err = anyhow!("Could not read \"{}\": {}", &relative_schema_path, e); + let mut err = RoverError::new(err); + err.set_suggestion(Suggestion::ValidComposeFile); + err + })?; + + let url = &subgraph_data.routing_url.clone().ok_or_else(|| { + let err = anyhow!("No routing_url found for schema file."); + let mut err = RoverError::new(err); + err.set_suggestion(Suggestion::ValidComposeRoutingUrl); + err + })?; + + let subgraph_definition = SubgraphDefinition::new(subgraph_name, url, &schema); + subgraphs.push(subgraph_definition); + } + SchemaSource::SubgraphIntrospection { subgraph_url } => { + // given a federated introspection URL, use subgraph introspect to + // obtain SDL and add it to subgraph_definition. + let client = Client::new(&subgraph_url.to_string()); + + let introspection_response = introspect::run(&client, &HashMap::new())?; + let schema = introspection_response.result; + + // We don't require a routing_url for this variant of a schema, + // if none are provided, just use an empty string. + let url = &subgraph_data + .routing_url + .clone() + .unwrap_or_else(|| subgraph_url.to_string()); + + let subgraph_definition = SubgraphDefinition::new(subgraph_name, url, &schema); + subgraphs.push(subgraph_definition); + } + SchemaSource::Subgraph { graphref, subgraph } => { + // given a graphref and subgraph, run subgraph fetch to + // obtain SDL and add it to subgraph_definition. + let client = client_config.get_client(&profile_name)?; + let graphref = parse_graph_ref(graphref)?; + let schema = fetch::run( + fetch::fetch_subgraph_query::Variables { + graph_id: graphref.name.clone(), + variant: graphref.variant.clone(), + }, + &client, + subgraph, + )?; + + // We don't require a routing_url for this variant of a schema, + // if none are provided, just use an empty string. + // + // TODO: this should eventually get the url from the registry + // and use that when no routing_url is provided. + let url = &subgraph_data.routing_url.clone().unwrap_or_default(); + + let subgraph_definition = SubgraphDefinition::new(subgraph_name, url, &schema); + subgraphs.push(subgraph_definition); + } + } + } + + Ok(subgraphs) +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_fs::TempDir; + use houston as houston_config; + use houston_config::Config; + use std::convert::TryFrom; + + fn get_studio_config() -> StudioClientConfig { + let tmp_home = TempDir::new().unwrap(); + let tmp_path = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); + StudioClientConfig::new(None, Config::new(Some(&tmp_path), None).unwrap()) + } + + #[test] + fn it_errs_on_invalid_subgraph_path() { + let raw_good_yaml = r#"subgraphs: + films: + routing_url: https://films.example.com + schema: + file: ./films-do-not-exist.graphql + people: + routing_url: https://people.example.com + schema: + file: ./people-do-not-exist.graphql"#; + let tmp_home = TempDir::new().unwrap(); + let mut config_path = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); + config_path.push("config.yaml"); + fs::write(&config_path, raw_good_yaml).unwrap(); + let supergraph_config = config::parse_supergraph_config(&config_path).unwrap(); + assert!(get_subgraph_definitions( + supergraph_config, + &config_path, + get_studio_config(), + "profile" + ) + .is_err()) + } + + #[test] + fn it_can_get_subgraph_definitions_from_fs() { + let raw_good_yaml = r#"subgraphs: + films: + routing_url: https://films.example.com + schema: + file: ./films.graphql + people: + routing_url: https://people.example.com + schema: + file: ./people.graphql"#; + let tmp_home = TempDir::new().unwrap(); + let mut config_path = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); + config_path.push("config.yaml"); + fs::write(&config_path, raw_good_yaml).unwrap(); + let tmp_dir = config_path.parent().unwrap().to_path_buf(); + let films_path = tmp_dir.join("films.graphql"); + let people_path = tmp_dir.join("people.graphql"); + fs::write(films_path, "there is something here").unwrap(); + fs::write(people_path, "there is also something here").unwrap(); + let supergraph_config = config::parse_supergraph_config(&config_path).unwrap(); + assert!(get_subgraph_definitions( + supergraph_config, + &config_path, + get_studio_config(), + "profile" + ) + .is_ok()) + } + + #[test] + fn it_can_compute_relative_schema_paths() { + let raw_good_yaml = r#"subgraphs: + films: + routing_url: https://films.example.com + schema: + file: ../../films.graphql + people: + routing_url: https://people.example.com + schema: + file: ../../people.graphql"#; + let tmp_home = TempDir::new().unwrap(); + let tmp_dir = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); + let mut config_path = tmp_dir.clone(); + config_path.push("layer"); + config_path.push("layer"); + fs::create_dir_all(&config_path).unwrap(); + config_path.push("config.yaml"); + fs::write(&config_path, raw_good_yaml).unwrap(); + let films_path = tmp_dir.join("films.graphql"); + let people_path = tmp_dir.join("people.graphql"); + fs::write(films_path, "there is something here").unwrap(); + fs::write(people_path, "there is also something here").unwrap(); + let supergraph_config = config::parse_supergraph_config(&config_path).unwrap(); + let subgraph_definitions = get_subgraph_definitions( + supergraph_config, + &config_path, + get_studio_config(), + "profile", + ) + .unwrap(); + let film_subgraph = subgraph_definitions.get(0).unwrap(); + let people_subgraph = subgraph_definitions.get(1).unwrap(); + + assert_eq!(film_subgraph.name, "films"); + assert_eq!(film_subgraph.url, "https://films.example.com"); + assert_eq!(film_subgraph.type_defs, "there is something here"); + assert_eq!(people_subgraph.name, "people"); + assert_eq!(people_subgraph.url, "https://people.example.com"); + assert_eq!(people_subgraph.type_defs, "there is also something here"); + } +} diff --git a/src/command/supergraph/config.rs b/src/command/supergraph/config.rs index 9a6b5f1b6..f7b494f93 100644 --- a/src/command/supergraph/config.rs +++ b/src/command/supergraph/config.rs @@ -1,8 +1,8 @@ use crate::{anyhow, Result}; use camino::Utf8PathBuf; -use harmonizer::ServiceDefinition as SubgraphDefinition; use serde::{Deserialize, Serialize}; +use url::Url; use std::collections::BTreeMap; @@ -16,13 +16,16 @@ pub(crate) struct SupergraphConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct Subgraph { - pub(crate) routing_url: String, - pub(crate) schema: Schema, + pub(crate) routing_url: Option, + pub(crate) schema: SchemaSource, } #[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct Schema { - pub(crate) file: Utf8PathBuf, +#[serde(untagged)] +pub(crate) enum SchemaSource { + File { file: Utf8PathBuf }, + SubgraphIntrospection { subgraph_url: Url }, + Subgraph { graphref: String, subgraph: String }, } pub(crate) fn parse_supergraph_config(config_path: &Utf8PathBuf) -> Result { @@ -36,37 +39,6 @@ pub(crate) fn parse_supergraph_config(config_path: &Utf8PathBuf) -> Result Result> { - let mut subgraphs = Vec::new(); - - for (subgraph_name, subgraph_data) in &self.subgraphs { - // compute the path to the schema relative to the config file itself, not the working directory. - let relative_schema_path = if let Some(parent) = config_path.parent() { - let mut schema_path = parent.to_path_buf(); - schema_path.push(&subgraph_data.schema.file); - schema_path - } else { - subgraph_data.schema.file.clone() - }; - - let schema = fs::read_to_string(&relative_schema_path) - .map_err(|e| anyhow!("Could not read \"{}\": {}", &relative_schema_path, e))?; - - let subgraph_definition = - SubgraphDefinition::new(subgraph_name, &subgraph_data.routing_url, &schema); - - subgraphs.push(subgraph_definition); - } - - Ok(subgraphs) - } -} - #[cfg(test)] mod tests { use assert_fs::TempDir; @@ -96,106 +68,46 @@ mod tests { panic!("{}", e) } } - - #[test] - fn it_errors_on_invalid_config() { - let raw_bad_yaml = r#"subgraphs: - films: - routing_______url: https://films.example.com - schemaaaa: - file:: ./good-films.graphql - people: - routing____url: https://people.example.com - schema_____file: ./good-people.graphql"#; - let tmp_home = TempDir::new().unwrap(); - let mut config_path = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); - config_path.push("config.yaml"); - fs::write(&config_path, raw_bad_yaml).unwrap(); - assert!(super::parse_supergraph_config(&config_path).is_err()) - } - #[test] - fn it_errs_on_invalid_subgraph_path() { + fn it_can_parse_valid_config_with_introspection() { let raw_good_yaml = r#"subgraphs: films: routing_url: https://films.example.com - schema: - file: ./films-do-not-exist.graphql - people: - routing_url: https://people.example.com - schema: - file: ./people-do-not-exist.graphql"#; - let tmp_home = TempDir::new().unwrap(); - let mut config_path = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); - config_path.push("config.yaml"); - fs::write(&config_path, raw_good_yaml).unwrap(); - let supergraph_config = super::parse_supergraph_config(&config_path).unwrap(); - assert!(supergraph_config - .get_subgraph_definitions(&config_path) - .is_err()) - } - - #[test] - fn it_can_get_subgraph_definitions_from_fs() { - let raw_good_yaml = r#"subgraphs: - films: - routing_url: https://films.example.com - schema: + schema: file: ./films.graphql people: - routing_url: https://people.example.com schema: - file: ./people.graphql"#; + url: https://people.example.com + reviews: + schema: + graphref: mygraph@current + subgraph: reviews +"#; let tmp_home = TempDir::new().unwrap(); let mut config_path = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); config_path.push("config.yaml"); fs::write(&config_path, raw_good_yaml).unwrap(); - let tmp_dir = config_path.parent().unwrap().to_path_buf(); - let films_path = tmp_dir.join("films.graphql"); - let people_path = tmp_dir.join("people.graphql"); - fs::write(films_path, "there is something here").unwrap(); - fs::write(people_path, "there is also something here").unwrap(); - let supergraph_config = super::parse_supergraph_config(&config_path).unwrap(); - assert!(supergraph_config - .get_subgraph_definitions(&config_path) - .is_ok()) + + let supergraph_config = super::parse_supergraph_config(&config_path); + if let Err(e) = supergraph_config { + panic!("{}", e) + } } #[test] - fn it_can_compute_relative_schema_paths() { - let raw_good_yaml = r#"subgraphs: + fn it_errors_on_invalid_config() { + let raw_bad_yaml = r#"subgraphs: films: - routing_url: https://films.example.com - schema: - file: ../../films.graphql + routing_______url: https://films.example.com + schemaaaa: + file:: ./good-films.graphql people: - routing_url: https://people.example.com - schema: - file: ../../people.graphql"#; + routing____url: https://people.example.com + schema_____file: ./good-people.graphql"#; let tmp_home = TempDir::new().unwrap(); - let tmp_dir = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); - let mut config_path = tmp_dir.clone(); - config_path.push("layer"); - config_path.push("layer"); - fs::create_dir_all(&config_path).unwrap(); + let mut config_path = Utf8PathBuf::try_from(tmp_home.path().to_path_buf()).unwrap(); config_path.push("config.yaml"); - fs::write(&config_path, raw_good_yaml).unwrap(); - let films_path = tmp_dir.join("films.graphql"); - let people_path = tmp_dir.join("people.graphql"); - fs::write(films_path, "there is something here").unwrap(); - fs::write(people_path, "there is also something here").unwrap(); - let supergraph_config = super::parse_supergraph_config(&config_path).unwrap(); - let subgraph_definitions = supergraph_config - .get_subgraph_definitions(&config_path) - .unwrap(); - let people_subgraph = subgraph_definitions.get(0).unwrap(); - let film_subgraph = subgraph_definitions.get(1).unwrap(); - - assert_eq!(film_subgraph.name, "films"); - assert_eq!(film_subgraph.url, "https://films.example.com"); - assert_eq!(film_subgraph.type_defs, "there is something here"); - assert_eq!(people_subgraph.name, "people"); - assert_eq!(people_subgraph.url, "https://people.example.com"); - assert_eq!(people_subgraph.type_defs, "there is also something here"); + fs::write(&config_path, raw_bad_yaml).unwrap(); + assert!(super::parse_supergraph_config(&config_path).is_err()) } } diff --git a/src/command/supergraph/mod.rs b/src/command/supergraph/mod.rs index a8822d056..8b575b5fa 100644 --- a/src/command/supergraph/mod.rs +++ b/src/command/supergraph/mod.rs @@ -28,8 +28,8 @@ pub enum Command { impl Supergraph { pub fn run(&self, client_config: StudioClientConfig) -> Result { match &self.command { - Command::Compose(command) => command.run(), Command::Fetch(command) => command.run(client_config), + Command::Compose(command) => command.run(client_config), } } } diff --git a/src/error/metadata/mod.rs b/src/error/metadata/mod.rs index fbe9cf1f7..be46c2f45 100644 --- a/src/error/metadata/mod.rs +++ b/src/error/metadata/mod.rs @@ -2,7 +2,7 @@ pub(crate) mod code; mod suggestion; pub(crate) use code::Code; -pub(crate) use suggestion::Suggestion; +pub use suggestion::Suggestion; use houston::HoustonProblem; use rover_client::RoverClientError; diff --git a/src/error/metadata/suggestion.rs b/src/error/metadata/suggestion.rs index 38d25f88e..00bc2dafb 100644 --- a/src/error/metadata/suggestion.rs +++ b/src/error/metadata/suggestion.rs @@ -25,6 +25,8 @@ pub enum Suggestion { }, Adhoc(String), CheckKey, + ValidComposeFile, + ValidComposeRoutingUrl, ProperKey, NewUserNoProfiles, CheckServerConnection, @@ -110,6 +112,12 @@ impl Display for Suggestion { Suggestion::ProperKey => { format!("Try running {} for more details on Apollo's API keys.", Yellow.normal().paint("`rover docs open api-keys`")) } + Suggestion::ValidComposeFile => { + "Make sure supergraph compose config YAML points to a valid schema file.".to_string() + } + Suggestion::ValidComposeRoutingUrl=> { + "When trying to compose with a local .graphql file, make sure you supply a `routing_url` in your config YAML.".to_string() + } Suggestion::NewUserNoProfiles => { format!("It looks like you may be new here (we couldn't find any existing config profiles). To authenticate with Apollo Studio, run {}", Yellow.normal().paint("`rover config auth`") diff --git a/src/error/mod.rs b/src/error/mod.rs index 25704f5d4..92670f479 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -10,6 +10,8 @@ use ansi_term::Colour::{Cyan, Red}; use std::borrow::BorrowMut; use std::fmt::{self, Debug, Display}; +pub use self::metadata::Suggestion; + /// A specialized `Error` type for Rover that wraps `anyhow` /// and provides some extra `Metadata` for end users depending /// on the speicif error they encountered. @@ -39,6 +41,14 @@ impl RoverError { Self { error, metadata } } + + pub fn set_suggestion(&mut self, suggestion: Suggestion) { + self.metadata.suggestion = Some(suggestion); + } + + pub fn suggestion(&mut self) -> &Option { + &self.metadata.suggestion + } } impl Display for RoverError { diff --git a/src/lib.rs b/src/lib.rs index df95ba59d..0e87c7ef6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,6 @@ pub mod command; mod error; pub mod utils; -pub use error::{anyhow, Context, Result}; +pub use error::{anyhow, Context, Result, Suggestion}; pub use utils::pkg::*;