| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | |
|
| 13 | |
|
| 14 | |
|
| 15 | |
|
| 16 | |
|
| 17 | | use std::fs; |
| 18 | | use std::io::{self, Write}; |
| 19 | | use std::path::{Path, PathBuf}; |
| 20 | | use std::process::ExitCode; |
| 21 | |
|
| 22 | |
|
| 23 | | const MARKERS: [&str; 3] = [concat!("TO", "DO"), concat!("FIX", "ME"), concat!("X", "XX")]; |
| 24 | |
|
| 25 | |
|
| 26 | | const LISTED: usize = 20; |
| 27 | |
|
| 28 | | const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary | changelog FILE [MANIFEST]"; |
| 29 | |
|
| 30 | 0× | fn main() -> ExitCode { |
| 31 | 0× | let args: Vec<String> = std::env::args().skip(1).collect(); |
| 32 | 0× | let root = Path::new("."); |
| 33 | 0× | let mut out = io::stdout().lock(); |
| 34 | 0× | let done = match args.split_first() { |
| 35 | 0× | Some((cmd, rest)) => match cmd.as_str() { |
| 36 | 0× | "layout" => layout(root, rest, &mut out), |
| 37 | 0× | "todos" => todos(root, rest, &mut out), |
| 38 | 0× | "size" => match rest { |
| 39 | 0× | [dir] => size(root, dir, &mut out), |
| 40 | 0× | _ => Err(USAGE.to_string()), |
| 41 | | }, |
| 42 | 0× | "summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out), |
| 43 | 0× | "changelog" => match rest { |
| 44 | 0× | [file] => changelog(root, file, None, &mut out), |
| 45 | 0× | [file, manifest] => changelog(root, file, Some(manifest), &mut out), |
| 46 | 0× | _ => Err(USAGE.to_string()), |
| 47 | | }, |
| 48 | 0× | _ => Err(USAGE.to_string()), |
| 49 | | }, |
| 50 | 0× | None => Err(USAGE.to_string()), |
| 51 | | }; |
| 52 | 0× | match done { |
| 53 | 0× | Ok(()) => ExitCode::SUCCESS, |
| 54 | 0× | Err(e) => { |
| 55 | 0× | eprintln!("{e}"); |
| 56 | 0× | ExitCode::FAILURE |
| 57 | | } |
| 58 | | } |
| 59 | 0× | } |
| 60 | |
|
| 61 | | type Done = Result<(), String>; |
| 62 | |
|
| 63 | 30× | fn emit(out: &mut impl Write, line: &str) -> Done { |
| 64 | 30× | writeln!(out, "{line}").map_err(|e| format!("write: {e}")) |
| 65 | 30× | } |
| 66 | |
|
| 67 | |
|
| 68 | 3× | fn layout(root: &Path, paths: &[String], out: &mut impl Write) -> Done { |
| 69 | 3× | if paths.is_empty() { |
| 70 | 0× | return Err(USAGE.to_string()); |
| 71 | 3× | } |
| 72 | 5× | for p in paths { |
| 73 | 5× | if !root.join(p).is_file() { |
| 74 | 2× | return Err(format!("missing: {p}")); |
| 75 | 3× | } |
| 76 | 3× | emit(out, &format!("found {p}"))?; |
| 77 | | } |
| 78 | 1× | Ok(()) |
| 79 | 3× | } |
| 80 | |
|
| 81 | |
|
| 82 | 1× | fn todos(root: &Path, dirs: &[String], out: &mut impl Write) -> Done { |
| 83 | 1× | if dirs.is_empty() { |
| 84 | 0× | return Err(USAGE.to_string()); |
| 85 | 1× | } |
| 86 | 1× | let mut noted = Vec::new(); |
| 87 | 1× | for d in dirs { |
| 88 | 3× | for file in files(&root.join(d))? { |
| 89 | | |
| 90 | 3× | let Ok(text) = fs::read_to_string(&file) else { continue }; |
| 91 | 2× | let name = shown(root, &file); |
| 92 | 27× | for (i, line) in text.lines().enumerate() { |
| 93 | 30× | if MARKERS.iter().any(|m| line.contains(m)) { |
| 94 | 26× | noted.push(format!("{name}:{}:{line}", i + 1)); |
| 95 | 26× | } |
| 96 | | } |
| 97 | | } |
| 98 | | } |
| 99 | 1× | emit(out, &format!("{} {} notes in {}", noted.len(), MARKERS.join("/"), shown_dirs(dirs)))?; |
| 100 | 20× | for line in noted.iter().take(LISTED) { |
| 101 | 20× | emit(out, line)?; |
| 102 | | } |
| 103 | 1× | Ok(()) |
| 104 | 1× | } |
| 105 | |
|
| 106 | |
|
| 107 | 1× | fn size(root: &Path, dir: &str, out: &mut impl Write) -> Done { |
| 108 | 3× | for krate in entries(&root.join(dir))?.into_iter().filter(|p| p.is_dir()) { |
| 109 | 2× | let mut lines = 0; |
| 110 | 4× | for file in files(&krate)?.into_iter().filter(|f| f.extension().is_some_and(|e| e == "rs")) { |
| 111 | 3× | let bytes = fs::read(&file).map_err(|e| format!("{}: {e}", file.display()))?; |
| 112 | 12× | lines += bytes.iter().filter(|&&b| b == b'\n').count(); |
| 113 | | } |
| 114 | 2× | emit(out, &format!("{:<24} {lines:>6} lines", format!("{}/", shown(root, &krate))))?; |
| 115 | | } |
| 116 | 1× | Ok(()) |
| 117 | 1× | } |
| 118 | |
|
| 119 | 1× | fn summary(project: Option<&str>, out: &mut impl Write) -> Done { |
| 120 | 1× | emit(out, &format!("{}: every check passed on this state.", project.unwrap_or("This project"))) |
| 121 | 1× | } |
| 122 | |
|
| 123 | |
|
| 124 | |
|
| 125 | 14× | fn version(s: &str) -> Option<(u64, u64, u64)> { |
| 126 | 40× | let mut parts = s.split('.').map(|p| p.parse::<u64>().ok()); |
| 127 | 14× | let v = (parts.next()??, parts.next()??, parts.next()??); |
| 128 | 13× | parts.next().is_none().then_some(v) |
| 129 | 14× | } |
| 130 | |
|
| 131 | |
|
| 132 | 13× | fn is_date(s: &str) -> bool { |
| 133 | 13× | let b = s.as_bytes(); |
| 134 | 13× | b.len() == 10 |
| 135 | 13× | && b[4] == b'-' |
| 136 | 13× | && b[7] == b'-' |
| 137 | 13× | && s[..4].parse::<u32>().is_ok() |
| 138 | 13× | && s[5..7].parse::<u32>().is_ok_and(|m| (1..=12).contains(&m)) |
| 139 | 12× | && s[8..].parse::<u32>().is_ok_and(|d| (1..=31).contains(&d)) |
| 140 | 13× | } |
| 141 | |
|
| 142 | |
|
| 143 | |
|
| 144 | |
|
| 145 | 3× | fn manifest_version(path: &str, text: &str) -> Option<String> { |
| 146 | 3× | let unquote = |v: &str| v.trim().trim_matches('"').trim_end_matches(',').trim_matches('"').to_string(); |
| 147 | 3× | if path.ends_with(".properties") { |
| 148 | 2× | return text.lines().find_map(|l| { |
| 149 | 2× | let (k, v) = l.split_once('=')?; |
| 150 | 2× | (k.trim() == "version" || k.trim().ends_with(".version")).then(|| v.trim().to_string()) |
| 151 | 2× | }); |
| 152 | 2× | } |
| 153 | 2× | if path.ends_with(".json") { |
| 154 | 3× | return text.lines().find_map(|l| l.trim().strip_prefix("\"version\":").map(unquote)); |
| 155 | 1× | } |
| 156 | 1× | let mut section = ""; |
| 157 | 5× | for l in text.lines() { |
| 158 | 5× | let l = l.trim(); |
| 159 | 5× | if l.starts_with('[') { |
| 160 | 2× | section = l; |
| 161 | 3× | } else if matches!(section, "[package]" | "[workspace.package]") { |
| 162 | 1× | if let Some(v) = l.strip_prefix("version").and_then(|r| r.trim_start().strip_prefix('=')) { |
| 163 | 1× | return Some(unquote(v)); |
| 164 | 0× | } |
| 165 | 2× | } |
| 166 | | } |
| 167 | 0× | None |
| 168 | 3× | } |
| 169 | |
|
| 170 | |
|
| 171 | 6× | fn changelog(root: &Path, file: &str, manifest: Option<&String>, out: &mut impl Write) -> Done { |
| 172 | 6× | let text = fs::read_to_string(root.join(file)).map_err(|e| format!("{file}: {e}"))?; |
| 173 | 6× | let mut sections: Vec<(usize, String, usize)> = Vec::new(); |
| 174 | 72× | for (i, line) in text.lines().enumerate() { |
| 175 | 72× | if let Some(h) = line.strip_prefix("## ") { |
| 176 | 19× | sections.push((i + 1, h.trim().to_string(), 0)); |
| 177 | 53× | } else if let Some(last) = sections.last_mut() { |
| 178 | 37× | if line.trim_start().starts_with("- ") || line.trim_start().starts_with("* ") { |
| 179 | 12× | last.2 += 1; |
| 180 | 25× | } |
| 181 | 16× | } |
| 182 | | } |
| 183 | 6× | let mut problems = Vec::new(); |
| 184 | 6× | let mut last: Option<(u64, u64, u64)> = None; |
| 185 | 6× | let mut released = Vec::new(); |
| 186 | 19× | for (k, (line, h, n)) in sections.iter().enumerate() { |
| 187 | 19× | if h == "Unreleased" { |
| 188 | 5× | if k != 0 { |
| 189 | 1× | problems.push(format!("{file}:{line}: ## Unreleased comes first")); |
| 190 | 4× | } |
| 191 | 5× | continue; |
| 192 | 14× | } |
| 193 | 14× | let (v, rest) = h.split_once(' ').unwrap_or((h.as_str(), "")); |
| 194 | 14× | let date = rest.trim().strip_prefix('(').and_then(|r| r.strip_suffix(')')); |
| 195 | 14× | match (version(v), date) { |
| 196 | 13× | (Some(ver), Some(d)) if is_date(d) => { |
| 197 | 12× | if last.is_some_and(|l| ver >= l) { |
| 198 | 1× | problems.push(format!("{file}:{line}: {v} is not older than the section above it")); |
| 199 | 11× | } |
| 200 | 12× | last = Some(ver); |
| 201 | 12× | released.push(v.to_string()); |
| 202 | 12× | if *n == 0 { |
| 203 | 1× | problems.push(format!("{file}:{line}: ## {h} has no entries")); |
| 204 | 11× | } |
| 205 | | } |
| 206 | 2× | _ => problems.push(format!("{file}:{line}: \"## {h}\" is not \"## X.Y.Z (YYYY-MM-DD)\" or \"## Unreleased\"")), |
| 207 | | } |
| 208 | | } |
| 209 | 6× | let mut said = format!("changelog: {} released section{}", released.len(), if released.len() == 1 { "" } else { "s" }); |
| 210 | 6× | if let Some(m) = manifest { |
| 211 | 3× | let mtext = fs::read_to_string(root.join(m)).map_err(|e| format!("{m}: {e}"))?; |
| 212 | 3× | match manifest_version(m, &mtext) { |
| 213 | 3× | Some(v) if released.contains(&v) => said.push_str(&format!(", {m} at {v} has its section")), |
| 214 | 1× | 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 | 0× | None => problems.push(format!("{m}: no version found")), |
| 218 | | } |
| 219 | 3× | } |
| 220 | 6× | if problems.is_empty() { |
| 221 | 3× | return emit(out, &said); |
| 222 | 3× | } |
| 223 | 3× | Err(problems.join("\n")) |
| 224 | 6× | } |
| 225 | |
|
| 226 | 11× | fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> { |
| 227 | 11× | let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; |
| 228 | 17× | let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?; |
| 229 | 11× | all.sort(); |
| 230 | 11× | Ok(all) |
| 231 | 11× | } |
| 232 | |
|
| 233 | |
|
| 234 | 10× | fn files(dir: &Path) -> Result<Vec<PathBuf>, String> { |
| 235 | 10× | let mut found = Vec::new(); |
| 236 | 14× | for p in entries(dir)? { |
| 237 | 14× | let kind = fs::symlink_metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?.file_type(); |
| 238 | 14× | if kind.is_dir() { |
| 239 | 7× | found.extend(files(&p)?); |
| 240 | 7× | } else if kind.is_file() { |
| 241 | 7× | found.push(p); |
| 242 | 7× | } |
| 243 | | } |
| 244 | 10× | Ok(found) |
| 245 | 10× | } |
| 246 | |
|
| 247 | |
|
| 248 | 4× | fn shown(root: &Path, p: &Path) -> String { |
| 249 | 4× | let rel = p.strip_prefix(root).unwrap_or(p); |
| 250 | 12× | rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/") |
| 251 | 4× | } |
| 252 | |
|
| 253 | 1× | fn shown_dirs(dirs: &[String]) -> String { |
| 254 | 1× | dirs.iter().map(|d| format!("{}/", d.trim_end_matches('/'))).collect::<Vec<_>>().join(" ") |
| 255 | 1× | } |
| 256 | |
|
| 257 | | #[cfg(test)] |
| 258 | | mod tests { |
| 259 | | use super::*; |
| 260 | |
|
| 261 | 6× | fn tree(files: &[(&str, &str)]) -> tempfile::TempDir { |
| 262 | 6× | let dir = tempfile::tempdir().unwrap(); |
| 263 | 16× | for (path, text) in files { |
| 264 | 16× | let p = dir.path().join(path); |
| 265 | 16× | fs::create_dir_all(p.parent().unwrap()).unwrap(); |
| 266 | 16× | fs::write(p, text).unwrap(); |
| 267 | 16× | } |
| 268 | 6× | dir |
| 269 | 6× | } |
| 270 | |
|
| 271 | 11× | fn run(f: impl FnOnce(&mut Vec<u8>) -> Done) -> (Done, String) { |
| 272 | 11× | let mut out = Vec::new(); |
| 273 | 11× | let done = f(&mut out); |
| 274 | 11× | (done, String::from_utf8(out).unwrap()) |
| 275 | 11× | } |
| 276 | |
|
| 277 | 4× | fn args(a: &[&str]) -> Vec<String> { |
| 278 | 7× | a.iter().map(|s| s.to_string()).collect() |
| 279 | 4× | } |
| 280 | |
|
| 281 | | #[test] |
| 282 | 1× | fn changelog_shape_and_the_manifest_version() { |
| 283 | 1× | 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 | 1× | let dir = tree(&[ |
| 285 | 1× | ("CHANGELOG.md", good), |
| 286 | 1× | ("Cargo.toml", "[workspace]\nmembers = []\n\n[workspace.package]\nversion = \"0.2.0\"\n"), |
| 287 | 1× | ("gradle.properties", "android.useAndroidX=true\noxygenhub.version=0.1.9\n"), |
| 288 | 1× | ("package.json", "{\n \"name\": \"x\",\n \"version\": \"0.3.0\",\n}\n"), |
| 289 | 1× | ]); |
| 290 | 4× | let r = |m: Option<&str>| run(|o| changelog(dir.path(), "CHANGELOG.md", m.map(String::from).as_ref(), o)); |
| 291 | 1× | let (done, out) = r(None); |
| 292 | 1× | assert_eq!((done, out.trim()), (Ok(()), "changelog: 2 released sections")); |
| 293 | 1× | assert!(r(Some("Cargo.toml")).1.contains("Cargo.toml at 0.2.0 has its section")); |
| 294 | 1× | assert!(r(Some("gradle.properties")).0.is_ok(), "an older release's section counts"); |
| 295 | 1× | let e = r(Some("package.json")).0.unwrap_err(); |
| 296 | 1× | assert!(e.contains("version 0.3.0") && e.contains("no \"## 0.3.0 (YYYY-MM-DD)\""), "{e}"); |
| 297 | 1× | 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 | 1× | let dir = tree(&[("CHANGELOG.md", bad)]); |
| 299 | 1× | let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err(); |
| 300 | 1× | assert!(e.contains(":5: ## Unreleased comes first"), "{e}"); |
| 301 | 1× | assert!(e.contains(":7: \"## 0.2.0 (2026-13-01)\" is not"), "a month 13: {e}"); |
| 302 | 1× | assert!(e.contains(":11: ## 0.0.9 (2026-09-01) has no entries"), "{e}"); |
| 303 | 1× | assert!(e.contains(":13: \"## v1\""), "{e}"); |
| 304 | 1× | let order = "## 0.1.0 (2026-10-01)\n\n- a\n\n## 0.2.0 (2026-11-01)\n\n- b\n"; |
| 305 | 1× | let dir = tree(&[("CHANGELOG.md", order)]); |
| 306 | 1× | let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err(); |
| 307 | 1× | assert!(e.contains("0.2.0 is not older than the section above it"), "{e}"); |
| 308 | 1× | } |
| 309 | |
|
| 310 | | #[test] |
| 311 | 1× | fn layout_lists_what_it_found_and_fails_on_the_first_missing() { |
| 312 | 1× | let t = tree(&[("Cargo.toml", ""), ("docs/m0.md", "")]); |
| 313 | 1× | let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "docs/m0.md"]), o)); |
| 314 | 1× | assert_eq!(done, Ok(())); |
| 315 | 1× | assert_eq!(out, "found Cargo.toml\nfound docs/m0.md\n"); |
| 316 | 1× | let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "nope.rs", "docs/m0.md"]), o)); |
| 317 | 1× | assert_eq!(done, Err("missing: nope.rs".to_string())); |
| 318 | 1× | assert_eq!(out, "found Cargo.toml\n"); |
| 319 | | |
| 320 | 1× | assert!(layout(t.path(), &args(&["docs"]), &mut Vec::new()).is_err()); |
| 321 | 1× | } |
| 322 | |
|
| 323 | | #[test] |
| 324 | 1× | fn todos_counts_every_marked_line_and_lists_the_first_twenty() { |
| 325 | 1× | let marker = MARKERS[0]; |
| 326 | 25× | let many: String = (0..25).map(|i| format!("// {marker} {i}\n")).collect(); |
| 327 | 1× | let t = tree(&[ |
| 328 | 1× | ("crates/a/src/lib.rs", &format!("fn a() {{}}\n// {} later\n", MARKERS[1])), |
| 329 | 1× | ("crates/b/src/many.rs", &many), |
| 330 | 1× | ("crates/b/blob.bin", "\u{0}"), |
| 331 | 1× | ]); |
| 332 | | |
| 333 | 1× | fs::write(t.path().join("crates/b/blob.bin"), [0xff, 0xfe, b'T']).unwrap(); |
| 334 | 1× | let (done, out) = run(|o| todos(t.path(), &args(&["crates"]), o)); |
| 335 | 1× | assert_eq!(done, Ok(())); |
| 336 | 1× | let lines: Vec<&str> = out.lines().collect(); |
| 337 | 1× | assert_eq!(lines[0], format!("26 {} notes in crates/", MARKERS.join("/"))); |
| 338 | 1× | assert_eq!(lines[1], format!("crates/a/src/lib.rs:2:// {} later", MARKERS[1])); |
| 339 | 1× | assert_eq!(lines[2], format!("crates/b/src/many.rs:1:// {marker} 0")); |
| 340 | 1× | assert_eq!(lines.len(), 1 + LISTED); |
| 341 | 1× | } |
| 342 | |
|
| 343 | | #[test] |
| 344 | 1× | fn size_counts_rust_lines_per_crate_in_name_order() { |
| 345 | 1× | let t = tree(&[ |
| 346 | 1× | ("crates/zeta/src/main.rs", "a\nb\nc\n"), |
| 347 | 1× | ("crates/alpha/src/lib.rs", "a\n"), |
| 348 | 1× | ("crates/alpha/src/deep/mod.rs", "a\nb\n"), |
| 349 | 1× | ("crates/alpha/README.md", "not rust\n"), |
| 350 | 1× | ("crates/loose.txt", "a file, not a crate\n"), |
| 351 | 1× | ]); |
| 352 | 1× | let (done, out) = run(|o| size(t.path(), "crates", o)); |
| 353 | 1× | assert_eq!(done, Ok(())); |
| 354 | 1× | assert_eq!(out, format!("{:<24} {:>6} lines\n{:<24} {:>6} lines\n", "crates/alpha/", 3, "crates/zeta/", 3)); |
| 355 | 1× | } |
| 356 | |
|
| 357 | | #[test] |
| 358 | 1× | fn summary_names_the_project() { |
| 359 | 1× | let (_, out) = run(|o| summary(Some("OxygenFlow"), o)); |
| 360 | 1× | assert_eq!(out, "OxygenFlow: every check passed on this state.\n"); |
| 361 | 1× | } |
| 362 | | } |