| 1 | //! Cross-checks `docs/features.toml` (the hand-edited feature registry) |
| 2 | //! against the code, and fails on drift. |
| 3 | //! |
| 4 | //! Checked here: every `[features]` flag in `features.rs` is owned by exactly |
| 5 | //! one row, a flag row's status follows the flag's stage and default, and |
| 6 | //! every `docs`/`owner` path exists. Not checked yet: slash-command ownership |
| 7 | //! and generated docs (later slices of the registry design). |
| 8 | |
| 9 | #[allow(dead_code)] |
| 10 | #[path = "../src/features.rs"] |
| 11 | mod features; |
| 12 | |
| 13 | use std::collections::{BTreeMap, BTreeSet}; |
| 14 | use std::path::{Path, PathBuf}; |
| 15 | |
| 16 | use features::{FEATURES, FeatureSpec, Stage}; |
| 17 | use serde::Deserialize; |
| 18 | |
| 19 | const STATUSES: &[&str] = &["stable", "experimental", "flagged", "planned"]; |
| 20 | const SURFACES: &[&str] = &["tui", "desktop", "web", "api"]; |
| 21 | const SURFACE_STATUSES: &[&str] = &["stable", "preview", "partial", "none", "planned"]; |
| 22 | |
| 23 | #[derive(Debug, Deserialize)] |
| 24 | #[serde(deny_unknown_fields)] |
| 25 | struct Registry { |
| 26 | feature: Vec<Row>, |
| 27 | } |
| 28 | |
| 29 | #[derive(Debug, Deserialize)] |
| 30 | #[serde(deny_unknown_fields)] |
| 31 | struct Row { |
| 32 | id: String, |
| 33 | name: String, |
| 34 | summary: String, |
| 35 | status: String, |
| 36 | #[serde(default)] |
| 37 | surfaces: BTreeMap<String, String>, |
| 38 | since: Option<String>, |
| 39 | flag: Option<String>, |
| 40 | docs: Option<String>, |
| 41 | owner: Option<String>, |
| 42 | } |
| 43 | |
| 44 | fn repo_root() -> PathBuf { |
| 45 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") |
| 46 | } |
| 47 | |
| 48 | #[cfg(test)] |
| 49 | fn read(root: &Path, rel: &str) -> String { |
| 50 | std::fs::read_to_string(root.join(rel)).unwrap_or_else(|err| panic!("read {rel}: {err}")) |
| 51 | } |
| 52 | |
| 53 | /// Versions with a `## [x.y.z]` heading in the changelogs. |
| 54 | fn released_versions(root: &Path) -> BTreeSet<String> { |
| 55 | ["CHANGELOG.md", "docs/CHANGELOG_ARCHIVE.md"] |
| 56 | .iter() |
| 57 | .flat_map(|file| { |
| 58 | read(root, file) |
| 59 | .lines() |
| 60 | .filter_map(|line| line.strip_prefix("## [")) |
| 61 | .filter_map(|rest| rest.split(']').next()) |
| 62 | .map(str::to_string) |
| 63 | .collect::<Vec<_>>() |
| 64 | }) |
| 65 | .collect() |
| 66 | } |
| 67 | |
| 68 | /// The registry status a flag implies: off by default is `flagged`; on by |
| 69 | /// default is `stable` for Stable flags and `experimental` otherwise. |
| 70 | fn status_for_flag(spec: &FeatureSpec) -> &'static str { |
| 71 | match (spec.default_enabled, spec.stage) { |
| 72 | (false, _) => "flagged", |
| 73 | (true, Stage::Stable) => "stable", |
| 74 | (true, Stage::Experimental | Stage::Beta) => "experimental", |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | fn is_kebab(id: &str) -> bool { |
| 79 | !id.is_empty() |
| 80 | && id.starts_with(|c: char| c.is_ascii_lowercase()) |
| 81 | && !id.ends_with('-') |
| 82 | && !id.contains("--") |
| 83 | && id |
| 84 | .chars() |
| 85 | .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') |
| 86 | } |
| 87 | |
| 88 | fn check(root: &Path, registry: &Registry) -> Vec<String> { |
| 89 | let mut errors = Vec::new(); |
| 90 | let versions = released_versions(root); |
| 91 | let mut ids = BTreeSet::new(); |
| 92 | let mut flag_owners: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); |
| 93 | |
| 94 | for row in ®istry.feature { |
| 95 | let id = row.id.as_str(); |
| 96 | let mut err = |msg: String| errors.push(format!("{id}: {msg}")); |
| 97 | |
| 98 | if !is_kebab(id) { |
| 99 | err("id must be kebab-case".into()); |
| 100 | } |
| 101 | if !ids.insert(id) { |
| 102 | err("duplicate id".into()); |
| 103 | } |
| 104 | if row.name.trim().is_empty() { |
| 105 | err("name is empty".into()); |
| 106 | } |
| 107 | let summary_len = row.summary.chars().count(); |
| 108 | if row.summary.trim().is_empty() || summary_len > 120 || row.summary.contains('\n') { |
| 109 | err(format!( |
| 110 | "summary must be one line of 1-120 characters (has {summary_len})" |
| 111 | )); |
| 112 | } |
| 113 | if !STATUSES.contains(&row.status.as_str()) { |
| 114 | err(format!( |
| 115 | "status {:?} is not one of {STATUSES:?}", |
| 116 | row.status |
| 117 | )); |
| 118 | } |
| 119 | for (surface, value) in &row.surfaces { |
| 120 | if !SURFACES.contains(&surface.as_str()) { |
| 121 | err(format!( |
| 122 | "unknown surface {surface:?}; expected one of {SURFACES:?}" |
| 123 | )); |
| 124 | } |
| 125 | if !SURFACE_STATUSES.contains(&value.as_str()) { |
| 126 | err(format!( |
| 127 | "surface {surface} = {value:?} is not one of {SURFACE_STATUSES:?}" |
| 128 | )); |
| 129 | } |
| 130 | } |
| 131 | if row.status == "planned" && (row.since.is_some() || row.flag.is_some()) { |
| 132 | err("a planned row cannot have `since` or `flag`".into()); |
| 133 | } |
| 134 | if let Some(since) = &row.since |
| 135 | && since != "unreleased" |
| 136 | && !versions.contains(since) |
| 137 | { |
| 138 | err(format!( |
| 139 | "since = {since:?} is neither \"unreleased\" nor a CHANGELOG heading" |
| 140 | )); |
| 141 | } |
| 142 | if let Some(flag) = &row.flag { |
| 143 | flag_owners.entry(flag.as_str()).or_default().push(id); |
| 144 | match FEATURES.iter().find(|spec| spec.key == flag) { |
| 145 | None => err(format!( |
| 146 | "flag {flag:?} is not a key in features.rs FEATURES" |
| 147 | )), |
| 148 | Some(spec) => { |
| 149 | let want = status_for_flag(spec); |
| 150 | if row.status != want { |
| 151 | err(format!( |
| 152 | "status {:?} disagrees with flag {flag} (stage {}, default {}): expected {want:?}", |
| 153 | row.status, |
| 154 | spec.stage, |
| 155 | if spec.default_enabled { "on" } else { "off" }, |
| 156 | )); |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | if let Some(docs) = &row.docs { |
| 162 | let file = docs.split('#').next().unwrap_or_default(); |
| 163 | if !root.join(file).exists() { |
| 164 | err(format!("docs path {file} does not exist")); |
| 165 | } |
| 166 | } |
| 167 | match &row.owner { |
| 168 | Some(owner) if !root.join(owner).exists() => { |
| 169 | err(format!("owner path {owner} does not exist")); |
| 170 | } |
| 171 | None if row.status != "planned" => err("a shipped row needs an `owner`".into()), |
| 172 | _ => {} |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | for spec in FEATURES { |
| 177 | match flag_owners.get(spec.key).map(Vec::as_slice) { |
| 178 | None | Some([]) => errors.push(format!( |
| 179 | "flag {} in features.rs has no row in docs/features.toml", |
| 180 | spec.key |
| 181 | )), |
| 182 | Some([_]) => {} |
| 183 | Some(many) => errors.push(format!( |
| 184 | "flag {} is claimed by several rows: {}", |
| 185 | spec.key, |
| 186 | many.join(", ") |
| 187 | )), |
| 188 | } |
| 189 | } |
| 190 | errors |
| 191 | } |
| 192 | |
| 193 | #[test] |
| 194 | fn feature_registry_matches_code() { |
| 195 | let root = repo_root(); |
| 196 | let registry: Registry = toml::from_str(&read(&root, "docs/features.toml")) |
| 197 | .unwrap_or_else(|err| panic!("docs/features.toml does not parse: {err}")); |
| 198 | assert!( |
| 199 | !registry.feature.is_empty(), |
| 200 | "docs/features.toml has no rows" |
| 201 | ); |
| 202 | |
| 203 | let errors = check(&root, ®istry); |
| 204 | assert!( |
| 205 | errors.is_empty(), |
| 206 | "docs/features.toml has drifted from the code ({} problem(s)); fix the row or the code:\n {}", |
| 207 | errors.len(), |
| 208 | errors.join("\n ") |
| 209 | ); |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn feature_registry_checks_catch_drift() { |
| 214 | let root = repo_root(); |
| 215 | let registry: Registry = toml::from_str( |
| 216 | r#" |
| 217 | [[feature]] |
| 218 | id = "Bad_Id" |
| 219 | name = "Wrong status" |
| 220 | summary = "The vision flag is off by default, so stable is wrong." |
| 221 | status = "stable" |
| 222 | surfaces = { phone = "stable" } |
| 223 | since = "99.0.0" |
| 224 | flag = "vision_model" |
| 225 | docs = "docs/NOPE.md#anchor" |
| 226 | owner = "crates/nope" |
| 227 | |
| 228 | [[feature]] |
| 229 | id = "twice" |
| 230 | name = "Duplicate flag" |
| 231 | summary = "Claims the same flag again." |
| 232 | status = "flagged" |
| 233 | flag = "vision_model" |
| 234 | owner = "crates/tui" |
| 235 | "#, |
| 236 | ) |
| 237 | .expect("fixture parses"); |
| 238 | |
| 239 | let errors = check(&root, ®istry).join("\n"); |
| 240 | for expected in [ |
| 241 | "Bad_Id: id must be kebab-case", |
| 242 | "unknown surface \"phone\"", |
| 243 | "since = \"99.0.0\"", |
| 244 | "expected \"flagged\"", |
| 245 | "docs path docs/NOPE.md does not exist", |
| 246 | "owner path crates/nope does not exist", |
| 247 | "flag vision_model is claimed by several rows", |
| 248 | "flag shell_tool in features.rs has no row", |
| 249 | ] { |
| 250 | assert!( |
| 251 | errors.contains(expected), |
| 252 | "missing {expected:?} in:\n{errors}" |
| 253 | ); |
| 254 | } |
| 255 | } |
| 256 |