返回 CodeWhale
feature_registry.rs
根目录 / crates / tui / tests / feature_registry.rs
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 &registry.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, &registry);
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, &registry).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
256 lines RUST