|
|
… |
8 unchanged lines |
| 9 |
9 |
|
//! listed; never fails. |
| 10 |
10 |
|
//! - `size DIR`: lines of Rust in each crate directory under DIR. |
| 11 |
11 |
|
//! - `summary`: one line naming `$PROJECT`. |
|
12 |
+ |
//! - `changelog FILE [MANIFEST]`: the changelog's shape: `## Unreleased` |
|
13 |
+ |
//! first if at all, then `## X.Y.Z (YYYY-MM-DD)` sections, newest first, |
|
14 |
+ |
//! none empty; with a manifest (`Cargo.toml`, `*.properties`, |
|
15 |
+ |
//! `package.json`), its version has a section, as a release needs. |
| 12 |
16 |
|
|
| 13 |
17 |
|
use std::fs; |
| 14 |
18 |
|
use std::io::{self, Write}; |
|
|
… |
6 unchanged lines |
| 21 |
25 |
|
/// How many noted lines `todos` lists. |
| 22 |
26 |
|
const LISTED: usize = 20; |
| 23 |
27 |
|
|
| 24 |
|
- |
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary"; |
|
28 |
+ |
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary | changelog FILE [MANIFEST]"; |
| 25 |
29 |
|
|
| 26 |
30 |
|
fn main() -> ExitCode { |
| 27 |
31 |
|
let args: Vec<String> = std::env::args().skip(1).collect(); |
|
|
… |
8 unchanged lines |
| 36 |
40 |
|
_ => Err(USAGE.to_string()), |
| 37 |
41 |
|
}, |
| 38 |
42 |
|
"summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out), |
|
43 |
+ |
"changelog" => match rest { |
|
44 |
+ |
[file] => changelog(root, file, None, &mut out), |
|
45 |
+ |
[file, manifest] => changelog(root, file, Some(manifest), &mut out), |
|
46 |
+ |
_ => Err(USAGE.to_string()), |
|
47 |
+ |
}, |
| 39 |
48 |
|
_ => Err(USAGE.to_string()), |
| 40 |
49 |
|
}, |
| 41 |
50 |
|
None => Err(USAGE.to_string()), |
|
|
… |
70 unchanged lines |
| 112 |
121 |
|
} |
| 113 |
122 |
|
|
| 114 |
123 |
|
/// A directory's entries, sorted by name so every runner prints the same. |
|
124 |
+ |
/// A version as three numbers, or `None`. |
|
125 |
+ |
fn version(s: &str) -> Option<(u64, u64, u64)> { |
|
126 |
+ |
let mut parts = s.split('.').map(|p| p.parse::<u64>().ok()); |
|
127 |
+ |
let v = (parts.next()??, parts.next()??, parts.next()??); |
|
128 |
+ |
parts.next().is_none().then_some(v) |
|
129 |
+ |
} |
|
130 |
+ |
|
|
131 |
+ |
/// A `YYYY-MM-DD` date with a real month and day. |
|
132 |
+ |
fn is_date(s: &str) -> bool { |
|
133 |
+ |
let b = s.as_bytes(); |
|
134 |
+ |
b.len() == 10 |
|
135 |
+ |
&& b[4] == b'-' |
|
136 |
+ |
&& b[7] == b'-' |
|
137 |
+ |
&& s[..4].parse::<u32>().is_ok() |
|
138 |
+ |
&& s[5..7].parse::<u32>().is_ok_and(|m| (1..=12).contains(&m)) |
|
139 |
+ |
&& s[8..].parse::<u32>().is_ok_and(|d| (1..=31).contains(&d)) |
|
140 |
+ |
} |
|
141 |
+ |
|
|
142 |
+ |
/// The version a manifest declares: `version = "…"` in `Cargo.toml`'s |
|
143 |
+ |
/// `[package]` or `[workspace.package]`, a `….version=` key of a |
|
144 |
+ |
/// `.properties` file, or `"version"` in a `package.json`. |
|
145 |
+ |
fn manifest_version(path: &str, text: &str) -> Option<String> { |
|
146 |
+ |
let unquote = |v: &str| v.trim().trim_matches('"').trim_end_matches(',').trim_matches('"').to_string(); |
|
147 |
+ |
if path.ends_with(".properties") { |
|
148 |
+ |
return text.lines().find_map(|l| { |
|
149 |
+ |
let (k, v) = l.split_once('=')?; |
|
150 |
+ |
(k.trim() == "version" || k.trim().ends_with(".version")).then(|| v.trim().to_string()) |
|
151 |
+ |
}); |
|
152 |
+ |
} |
|
153 |
+ |
if path.ends_with(".json") { |
|
154 |
+ |
return text.lines().find_map(|l| l.trim().strip_prefix("\"version\":").map(unquote)); |
|
155 |
+ |
} |
|
156 |
+ |
let mut section = ""; |
|
157 |
+ |
for l in text.lines() { |
|
158 |
+ |
let l = l.trim(); |
|
159 |
+ |
if l.starts_with('[') { |
|
160 |
+ |
section = l; |
|
161 |
+ |
} else if matches!(section, "[package]" | "[workspace.package]") { |
|
162 |
+ |
if let Some(v) = l.strip_prefix("version").and_then(|r| r.trim_start().strip_prefix('=')) { |
|
163 |
+ |
return Some(unquote(v)); |
|
164 |
+ |
} |
|
165 |
+ |
} |
|
166 |
+ |
} |
|
167 |
+ |
None |
|
168 |
+ |
} |
|
169 |
+ |
|
|
170 |
+ |
/// The changelog's shape, and the manifest's version in it. |
|
171 |
+ |
fn changelog(root: &Path, file: &str, manifest: Option<&String>, out: &mut impl Write) -> Done { |
|
172 |
+ |
let text = fs::read_to_string(root.join(file)).map_err(|e| format!("{file}: {e}"))?; |
|
173 |
+ |
let mut sections: Vec<(usize, String, usize)> = Vec::new(); // (line, heading, entries) |
|
174 |
+ |
for (i, line) in text.lines().enumerate() { |
|
175 |
+ |
if let Some(h) = line.strip_prefix("## ") { |
|
176 |
+ |
sections.push((i + 1, h.trim().to_string(), 0)); |
|
177 |
+ |
} else if let Some(last) = sections.last_mut() { |
|
178 |
+ |
if line.trim_start().starts_with("- ") || line.trim_start().starts_with("* ") { |
|
179 |
+ |
last.2 += 1; |
|
180 |
+ |
} |
|
181 |
+ |
} |
|
182 |
+ |
} |
|
183 |
+ |
let mut problems = Vec::new(); |
|
184 |
+ |
let mut last: Option<(u64, u64, u64)> = None; |
|
185 |
+ |
let mut released = Vec::new(); |
|
186 |
+ |
for (k, (line, h, n)) in sections.iter().enumerate() { |
|
187 |
+ |
if h == "Unreleased" { |
|
188 |
+ |
if k != 0 { |
|
189 |
+ |
problems.push(format!("{file}:{line}: ## Unreleased comes first")); |
|
190 |
+ |
} |
|
191 |
+ |
continue; |
|
192 |
+ |
} |
|
193 |
+ |
let (v, rest) = h.split_once(' ').unwrap_or((h.as_str(), "")); |
|
194 |
+ |
let date = rest.trim().strip_prefix('(').and_then(|r| r.strip_suffix(')')); |
|
195 |
+ |
match (version(v), date) { |
|
196 |
+ |
(Some(ver), Some(d)) if is_date(d) => { |
|
197 |
+ |
if last.is_some_and(|l| ver >= l) { |
|
198 |
+ |
problems.push(format!("{file}:{line}: {v} is not older than the section above it")); |
|
199 |
+ |
} |
|
200 |
+ |
last = Some(ver); |
|
201 |
+ |
released.push(v.to_string()); |
|
202 |
+ |
if *n == 0 { |
|
203 |
+ |
problems.push(format!("{file}:{line}: ## {h} has no entries")); |
|
204 |
+ |
} |
|
205 |
+ |
} |
|
206 |
+ |
_ => problems.push(format!("{file}:{line}: \"## {h}\" is not \"## X.Y.Z (YYYY-MM-DD)\" or \"## Unreleased\"")), |
|
207 |
+ |
} |
|
208 |
+ |
} |
|
209 |
+ |
let mut said = format!("changelog: {} released section{}", released.len(), if released.len() == 1 { "" } else { "s" }); |
|
210 |
+ |
if let Some(m) = manifest { |
|
211 |
+ |
let mtext = fs::read_to_string(root.join(m)).map_err(|e| format!("{m}: {e}"))?; |
|
212 |
+ |
match manifest_version(m, &mtext) { |
|
213 |
+ |
Some(v) if released.contains(&v) => said.push_str(&format!(", {m} at {v} has its section")), |
|
214 |
+ |
Some(v) => problems.push(format!( |
|
215 |
+ |
"{m} says version {v}, but {file} has no \"## {v} (YYYY-MM-DD)\" section: a release of it would have no notes" |
|
216 |
+ |
)), |
|
217 |
+ |
None => problems.push(format!("{m}: no version found")), |
|
218 |
+ |
} |
|
219 |
+ |
} |
|
220 |
+ |
if problems.is_empty() { |
|
221 |
+ |
return emit(out, &said); |
|
222 |
+ |
} |
|
223 |
+ |
Err(problems.join("\n")) |
|
224 |
+ |
} |
|
225 |
+ |
|
| 115 |
226 |
|
fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> { |
| 116 |
227 |
|
let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; |
| 117 |
228 |
|
let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?; |
|
|
… |
47 unchanged lines |
| 165 |
276 |
|
|
| 166 |
277 |
|
fn args(a: &[&str]) -> Vec<String> { |
| 167 |
278 |
|
a.iter().map(|s| s.to_string()).collect() |
|
279 |
+ |
} |
|
280 |
+ |
|
|
281 |
+ |
#[test] |
|
282 |
+ |
fn changelog_shape_and_the_manifest_version() { |
|
283 |
+ |
let good = "# Changelog\n\nIntro.\n\n## Unreleased\n\n## 0.2.0 (2026-11-03)\n\n- **New.**\n\n## 0.1.9 (2026-10-01)\n\n- Old.\n"; |
|
284 |
+ |
let dir = tree(&[ |
|
285 |
+ |
("CHANGELOG.md", good), |
|
286 |
+ |
("Cargo.toml", "[workspace]\nmembers = []\n\n[workspace.package]\nversion = \"0.2.0\"\n"), |
|
287 |
+ |
("gradle.properties", "android.useAndroidX=true\noxygenhub.version=0.1.9\n"), |
|
288 |
+ |
("package.json", "{\n \"name\": \"x\",\n \"version\": \"0.3.0\",\n}\n"), |
|
289 |
+ |
]); |
|
290 |
+ |
let r = |m: Option<&str>| run(|o| changelog(dir.path(), "CHANGELOG.md", m.map(String::from).as_ref(), o)); |
|
291 |
+ |
let (done, out) = r(None); |
|
292 |
+ |
assert_eq!((done, out.trim()), (Ok(()), "changelog: 2 released sections")); |
|
293 |
+ |
assert!(r(Some("Cargo.toml")).1.contains("Cargo.toml at 0.2.0 has its section")); |
|
294 |
+ |
assert!(r(Some("gradle.properties")).0.is_ok(), "an older release's section counts"); |
|
295 |
+ |
let e = r(Some("package.json")).0.unwrap_err(); |
|
296 |
+ |
assert!(e.contains("version 0.3.0") && e.contains("no \"## 0.3.0 (YYYY-MM-DD)\""), "{e}"); |
|
297 |
+ |
let bad = "## 0.1.0 (2026-10-01)\n\n- a\n\n## Unreleased\n\n## 0.2.0 (2026-13-01)\n\n- b\n\n## 0.0.9 (2026-09-01)\n\n## v1\n"; |
|
298 |
+ |
let dir = tree(&[("CHANGELOG.md", bad)]); |
|
299 |
+ |
let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err(); |
|
300 |
+ |
assert!(e.contains(":5: ## Unreleased comes first"), "{e}"); |
|
301 |
+ |
assert!(e.contains(":7: \"## 0.2.0 (2026-13-01)\" is not"), "a month 13: {e}"); |
|
302 |
+ |
assert!(e.contains(":11: ## 0.0.9 (2026-09-01) has no entries"), "{e}"); |
|
303 |
+ |
assert!(e.contains(":13: \"## v1\""), "{e}"); |
|
304 |
+ |
let order = "## 0.1.0 (2026-10-01)\n\n- a\n\n## 0.2.0 (2026-11-01)\n\n- b\n"; |
|
305 |
+ |
let dir = tree(&[("CHANGELOG.md", order)]); |
|
306 |
+ |
let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err(); |
|
307 |
+ |
assert!(e.contains("0.2.0 is not older than the section above it"), "{e}"); |
| 168 |
308 |
|
} |
| 169 |
309 |
|
|
| 170 |
310 |
|
#[test] |
|
|
… |
52 unchanged lines |