diff --git a/src/db/types.rs b/src/db/types.rs index e2a973676..74d5f1e12 100644 --- a/src/db/types.rs +++ b/src/db/types.rs @@ -1,15 +1,19 @@ use postgres_types::{FromSql, ToSql}; use serde::Serialize; -#[derive(Debug, Clone, PartialEq, Serialize, FromSql, ToSql)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, FromSql, ToSql)] #[postgres(name = "feature")] pub struct Feature { - name: String, - subfeatures: Vec, + pub(crate) name: String, + pub(crate) subfeatures: Vec, } impl Feature { pub fn new(name: String, subfeatures: Vec) -> Self { Feature { name, subfeatures } } + + pub fn is_private(&self) -> bool { + self.name.starts_with('_') + } } diff --git a/src/test/fakes.rs b/src/test/fakes.rs index 6344e60ec..14d6350e4 100644 --- a/src/test/fakes.rs +++ b/src/test/fakes.rs @@ -210,6 +210,11 @@ impl<'a> FakeRelease<'a> { self } + pub(crate) fn features(mut self, features: HashMap>) -> Self { + self.package.features = features; + self + } + /// Returns the release_id pub(crate) fn create(self) -> Result { use std::fs; diff --git a/src/web/crate_details.rs b/src/web/crate_details.rs index 9dfae8930..700d66621 100644 --- a/src/web/crate_details.rs +++ b/src/web/crate_details.rs @@ -325,6 +325,7 @@ mod tests { use crate::test::{wrapper, TestDatabase}; use failure::Error; use kuchiki::traits::TendrilSink; + use std::collections::HashMap; fn assert_last_successful_build_equals( db: &TestDatabase, @@ -741,4 +742,132 @@ mod tests { Ok(()) }); } + + #[test] + fn feature_flags_report_empty() { + wrapper(|env| { + env.fake_release() + .name("library") + .version("0.1.0") + .features(HashMap::new()) + .create()?; + + let page = kuchiki::parse_html().one( + env.frontend() + .get("/crate/library/0.1.0/features") + .send()? + .text()?, + ); + assert!(page.select_first(r#"p[data-id="empty-features"]"#).is_ok()); + Ok(()) + }); + } + + #[test] + fn feature_private_feature_flags_are_hidden() { + wrapper(|env| { + let features = [("_private".into(), Vec::new())] + .iter() + .cloned() + .collect::>>(); + env.fake_release() + .name("library") + .version("0.1.0") + .features(features) + .create()?; + + let page = kuchiki::parse_html().one( + env.frontend() + .get("/crate/library/0.1.0/features") + .send()? + .text()?, + ); + assert!(page.select_first(r#"p[data-id="empty-features"]"#).is_ok()); + Ok(()) + }); + } + + #[test] + fn feature_flags_without_default() { + wrapper(|env| { + let features = [("feature1".into(), Vec::new())] + .iter() + .cloned() + .collect::>>(); + env.fake_release() + .name("library") + .version("0.1.0") + .features(features) + .create()?; + + let page = kuchiki::parse_html().one( + env.frontend() + .get("/crate/library/0.1.0/features") + .send()? + .text()?, + ); + assert!(page.select_first(r#"p[data-id="empty-features"]"#).is_err()); + let def_len = page + .select_first(r#"b[data-id="default-feature-len"]"#) + .unwrap(); + assert_eq!(def_len.text_contents(), "0"); + Ok(()) + }); + } + + #[test] + fn feature_flags_with_nested_default() { + wrapper(|env| { + let features = [ + ("default".into(), vec!["feature1".into()]), + ("feature1".into(), vec!["feature2".into()]), + ("feature2".into(), Vec::new()), + ] + .iter() + .cloned() + .collect::>>(); + env.fake_release() + .name("library") + .version("0.1.0") + .features(features) + .create()?; + + let page = kuchiki::parse_html().one( + env.frontend() + .get("/crate/library/0.1.0/features") + .send()? + .text()?, + ); + assert!(page.select_first(r#"p[data-id="empty-features"]"#).is_err()); + let def_len = page + .select_first(r#"b[data-id="default-feature-len"]"#) + .unwrap(); + assert_eq!(def_len.text_contents(), "3"); + Ok(()) + }); + } + + #[test] + fn feature_flags_report_null() { + wrapper(|env| { + let id = env + .fake_release() + .name("library") + .version("0.1.0") + .create()?; + + env.db() + .conn() + .query("UPDATE releases SET features = NULL WHERE id = $1", &[&id])?; + + let page = kuchiki::parse_html().one( + env.frontend() + .get("/crate/library/0.1.0/features") + .send()? + .text()?, + ); + assert!(page.select_first(r#"p[data-id="null-features"]"#).is_ok()); + Ok(()) + }); + } } diff --git a/src/web/features.rs b/src/web/features.rs new file mode 100644 index 000000000..ea13cf5bc --- /dev/null +++ b/src/web/features.rs @@ -0,0 +1,220 @@ +use crate::db::types::Feature; +use crate::{ + db::Pool, + impl_webpage, + web::{page::WebPage, MetaData}, +}; +use iron::{IronResult, Request, Response}; +use router::Router; +use serde::Serialize; +use std::collections::{HashMap, VecDeque}; + +const DEFAULT_NAME: &str = "default"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct FeaturesPage { + metadata: MetaData, + features: Option>, + default_len: usize, +} + +impl_webpage! { + FeaturesPage = "crate/features.html", +} + +pub fn build_features_handler(req: &mut Request) -> IronResult { + let router = extension!(req, Router); + let name = cexpect!(req, router.find("name")); + let version = cexpect!(req, router.find("version")); + + let mut conn = extension!(req, Pool).get()?; + let rows = ctry!( + req, + conn.query( + "SELECT releases.features FROM releases + INNER JOIN crates ON crates.id = releases.crate_id + WHERE crates.name = $1 AND releases.version = $2", + &[&name, &version] + ) + ); + + let row = cexpect!(req, rows.get(0)); + + let mut features = None; + let mut default_len = 0; + + if let Some(raw) = row.get(0) { + let result = order_features_and_count_default_len(raw); + features = Some(result.0); + default_len = result.1; + } + + FeaturesPage { + metadata: cexpect!(req, MetaData::from_crate(&mut conn, &name, &version)), + features, + default_len, + } + .into_response(req) +} + +fn order_features_and_count_default_len(raw: Vec) -> (Vec, usize) { + let mut feature_map = get_feature_map(raw); + let mut features = get_tree_structure_from_default(&mut feature_map); + let mut remaining: Vec<_> = feature_map + .into_iter() + .map(|(_, feature)| feature) + .collect(); + remaining.sort_by_key(|feature| feature.subfeatures.len()); + + let default_len = features.len(); + + features.extend(remaining.into_iter().rev()); + (features, default_len) +} + +fn get_tree_structure_from_default(feature_map: &mut HashMap) -> Vec { + let mut features = Vec::new(); + let mut queue: VecDeque = VecDeque::new(); + + queue.push_back(DEFAULT_NAME.into()); + while !queue.is_empty() { + let name = queue.pop_front().unwrap(); + if let Some(feature) = feature_map.remove(&name) { + feature + .subfeatures + .iter() + .for_each(|sub| queue.push_back(sub.clone())); + features.push(feature); + } + } + features +} + +fn get_feature_map(raw: Vec) -> HashMap { + raw.into_iter() + .filter(|feature| !feature.is_private()) + .map(|feature| (feature.name.clone(), feature)) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::db::types::Feature; + use crate::web::features::{ + get_feature_map, get_tree_structure_from_default, order_features_and_count_default_len, + DEFAULT_NAME, + }; + + #[test] + fn test_feature_map_filters_private() { + let private1 = Feature::new("_private1".into(), vec!["feature1".into()]); + let feature2 = Feature::new("feature2".into(), Vec::new()); + + let raw = vec![private1.clone(), feature2.clone()]; + let feature_map = get_feature_map(raw); + + assert_eq!(feature_map.len(), 1); + assert!(feature_map.contains_key(&feature2.name)); + assert!(!feature_map.contains_key(&private1.name)); + } + + #[test] + fn test_default_tree_structure_with_nested_default() { + let default = Feature::new(DEFAULT_NAME.into(), vec!["feature1".into()]); + let non_default = Feature::new("non-default".into(), Vec::new()); + let feature1 = Feature::new( + "feature1".into(), + vec!["feature2".into(), "feature3".into()], + ); + let feature2 = Feature::new("feature2".into(), Vec::new()); + let feature3 = Feature::new("feature3".into(), Vec::new()); + + let raw = vec![ + default.clone(), + non_default.clone(), + feature3.clone(), + feature2.clone(), + feature1.clone(), + ]; + let mut feature_map = get_feature_map(raw); + let default_tree = get_tree_structure_from_default(&mut feature_map); + + assert_eq!(feature_map.len(), 1); + assert_eq!(default_tree.len(), 4); + assert!(feature_map.contains_key(&non_default.name)); + assert!(!feature_map.contains_key(&default.name)); + assert_eq!(default_tree[0], default); + assert_eq!(default_tree[1], feature1); + assert_eq!(default_tree[2], feature2); + assert_eq!(default_tree[3], feature3); + } + + #[test] + fn test_default_tree_structure_without_default() { + let feature1 = Feature::new( + "feature1".into(), + vec!["feature2".into(), "feature3".into()], + ); + let feature2 = Feature::new("feature2".into(), Vec::new()); + let feature3 = Feature::new("feature3".into(), Vec::new()); + + let raw = vec![feature3.clone(), feature2.clone(), feature1.clone()]; + let mut feature_map = get_feature_map(raw); + let default_tree = get_tree_structure_from_default(&mut feature_map); + + assert_eq!(feature_map.len(), 3); + assert_eq!(default_tree.len(), 0); + assert!(feature_map.contains_key(&feature1.name)); + assert!(feature_map.contains_key(&feature2.name)); + assert!(feature_map.contains_key(&feature3.name)); + } + + #[test] + fn test_default_tree_structure_single_default() { + let default = Feature::new(DEFAULT_NAME.into(), Vec::new()); + let non_default = Feature::new("non-default".into(), Vec::new()); + + let raw = vec![default.clone(), non_default.clone()]; + let mut feature_map = get_feature_map(raw); + let default_tree = get_tree_structure_from_default(&mut feature_map); + + assert_eq!(feature_map.len(), 1); + assert_eq!(default_tree.len(), 1); + assert!(feature_map.contains_key(&non_default.name)); + assert!(!feature_map.contains_key(&default.name)); + assert_eq!(default_tree[0], default); + } + + #[test] + fn test_order_features_and_get_len_without_default() { + let feature1 = Feature::new( + "feature1".into(), + vec!["feature10".into(), "feature11".into()], + ); + let feature2 = Feature::new("feature2".into(), vec!["feature20".into()]); + let feature3 = Feature::new("feature3".into(), Vec::new()); + + let raw = vec![feature3.clone(), feature2.clone(), feature1.clone()]; + let (features, default_len) = order_features_and_count_default_len(raw); + + assert_eq!(features.len(), 3); + assert_eq!(default_len, 0); + assert_eq!(features[0], feature1); + assert_eq!(features[1], feature2); + assert_eq!(features[2], feature3); + } + + #[test] + fn test_order_features_and_get_len_single_default() { + let default = Feature::new(DEFAULT_NAME.into(), Vec::new()); + let non_default = Feature::new("non-default".into(), Vec::new()); + + let raw = vec![default.clone(), non_default.clone()]; + let (features, default_len) = order_features_and_count_default_len(raw); + + assert_eq!(features.len(), 2); + assert_eq!(default_len, 1); + assert_eq!(features[0], default); + assert_eq!(features[1], non_default); + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 7e26847e2..0a58ea2b2 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -81,6 +81,7 @@ mod builds; mod crate_details; mod error; mod extensions; +mod features; mod file; pub(crate) mod metrics; mod releases; diff --git a/src/web/routes.rs b/src/web/routes.rs index 71c8b171e..44b808471 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -87,6 +87,10 @@ pub(super) fn build_routes() -> Routes { "/crate/:name/:version/builds/:id", super::builds::build_list_handler, ); + routes.internal_page( + "/crate/:name/:version/features", + super::features::build_features_handler, + ); routes.internal_page( "/crate/:name/:version/source", SimpleRedirect::new(|url| url.set_path(&format!("{}/", url.path()))), diff --git a/templates/crate/features.html b/templates/crate/features.html new file mode 100644 index 000000000..37e63e04f --- /dev/null +++ b/templates/crate/features.html @@ -0,0 +1,76 @@ +{%- extends "base.html" -%} +{%- import "header/package_navigation.html" as navigation -%} + +{%- block title -%} + {{ macros::doc_title(name=metadata.name, version=metadata.version) }} +{%- endblock title -%} + +{%- block topbar -%} + {%- set latest_version = "" -%} + {%- set latest_path = "" -%} + {%- set target = "" -%} + {%- set inner_path = metadata.target_name ~ "/index.html" -%} + {%- set is_latest_version = true -%} + {%- set is_prerelease = false -%} + {%- include "rustdoc/topbar.html" -%} +{%- endblock topbar -%} + +{%- block header -%} + {{ navigation::package_navigation(metadata=metadata, active_tab="features") }} +{%- endblock header -%} + +{%- block body -%} +
+
+
+
+
    +
  • Feature flags
  • + {%- if features -%} + {%- for feature in features -%} +
  • + + {{ feature.name }} + +
  • + {%- endfor -%} + {%- elif features is iterable -%} +
  • + This release does not have any feature flags. +
  • + {%- else -%} +
  • + Feature flags data are not available for this release. +
  • + {%- endif -%} +
+
+
+ +
+

{{ metadata.name }}

+ {%- if features -%} +

This version has {{ features | length }} feature flags, {{ default_len }} of them enabled by default.

+ {%- for feature in features -%} +

{{ feature.name }}

+
    + {%- if feature.subfeatures -%} + {%- for subfeature in feature.subfeatures -%} +
  • + {{ subfeature }} +
  • + {%- endfor -%} + {%- else -%} +

    This feature flag does not enable additional features.

    + {%- endif -%} +
+ {%- endfor -%} + {%- elif features is iterable -%} +

This release does not have any feature flags.

+ {%- else -%} +

Feature flags data are not available for this release.

+ {%- endif -%} +
+
+
+{%- endblock body -%} diff --git a/templates/header/package_navigation.html b/templates/header/package_navigation.html index 38cd6802d..702285b27 100644 --- a/templates/header/package_navigation.html +++ b/templates/header/package_navigation.html @@ -8,6 +8,7 @@ * `crate` * `source` * `builds` + * `features` Note: `false` here is acting as a pseudo-null value since you can't directly construct null values and tera requires all parameters without defaults to be filled @@ -85,6 +86,15 @@

Builds + + {# The features tab #} +
  • + + {{ "flag" | fas }} + Feature flags + +
  • diff --git a/templates/rustdoc/topbar.html b/templates/rustdoc/topbar.html index 868a0a7d6..1d6b0c8a2 100644 --- a/templates/rustdoc/topbar.html +++ b/templates/rustdoc/topbar.html @@ -229,6 +229,14 @@ {%- endfor -%} + {# + Display the features available in current build + #} +
  • + + {{ "flag" | fas }} + Feature flags +