OXYGENHUB
◆ v0.5.3 oxygenflow / crates / flow-checks / src / main.rs
15.1 KiB · Rust · last changed A changelog check on the WASM tier, a job in Flow's pipeline 1 day ago source blame history raw
5544c899 Andy McCall ↶ 1
//! OxygenFlow's cheap checks as one WASI module (#96): what
2
//! `.flow/jobs/{layout,todos,size,summary}.sh` did in an Alpine microVM, so
3
//! that any runner offering the WASM tier (a phone, plex, windows-1) can take
4
//! them. The job's files are preopened as `/src`, which is also the current
5
//! directory, so every path here is relative to the workspace.
6
//!
7
//! - `layout PATH...`: each path is a file; fails naming the first missing.
8
//! - `todos DIR...`: the lines holding a note marker, counted, the first 20
9
//!   listed; never fails.
10
//! - `size DIR`: lines of Rust in each crate directory under DIR.
11
//! - `summary`: one line naming `$PROJECT`.
0aac9efc Andy McCall ↶ 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.
5544c899 Andy McCall ↶ 16
17
use std::fs;
18
use std::io::{self, Write};
19
use std::path::{Path, PathBuf};
20
use std::process::ExitCode;
21
22
/// The note markers, spelt in pieces so this file does not count itself.
23
const MARKERS: [&str; 3] = [concat!("TO", "DO"), concat!("FIX", "ME"), concat!("X", "XX")];
24
25
/// How many noted lines `todos` lists.
26
const LISTED: usize = 20;
27
0aac9efc Andy McCall ↶ 28
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary | changelog FILE [MANIFEST]";
5544c899 Andy McCall ↶ 29
30
fn main() -> ExitCode {
31
    let args: Vec<String> = std::env::args().skip(1).collect();
32
    let root = Path::new(".");
33
    let mut out = io::stdout().lock();
34
    let done = match args.split_first() {
35
        Some((cmd, rest)) => match cmd.as_str() {
36
            "layout" => layout(root, rest, &mut out),
37
            "todos" => todos(root, rest, &mut out),
38
            "size" => match rest {
39
                [dir] => size(root, dir, &mut out),
40
                _ => Err(USAGE.to_string()),
41
            },
42
            "summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out),
0aac9efc Andy McCall ↶ 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
            },
5544c899 Andy McCall ↶ 48
            _ => Err(USAGE.to_string()),
49
        },
50
        None => Err(USAGE.to_string()),
51
    };
52
    match done {
53
        Ok(()) => ExitCode::SUCCESS,
54
        Err(e) => {
55
            eprintln!("{e}");
56
            ExitCode::FAILURE
57
        }
58
    }
59
}
60
61
type Done = Result<(), String>;
62
63
fn emit(out: &mut impl Write, line: &str) -> Done {
64
    writeln!(out, "{line}").map_err(|e| format!("write: {e}"))
65
}
66
67
/// The workspace has the files it should.
68
fn layout(root: &Path, paths: &[String], out: &mut impl Write) -> Done {
69
    if paths.is_empty() {
70
        return Err(USAGE.to_string());
71
    }
72
    for p in paths {
73
        if !root.join(p).is_file() {
74
            return Err(format!("missing: {p}"));
75
        }
76
        emit(out, &format!("found {p}"))?;
77
    }
78
    Ok(())
79
}
80
81
/// Notes left in the code, for the log.
82
fn todos(root: &Path, dirs: &[String], out: &mut impl Write) -> Done {
83
    if dirs.is_empty() {
84
        return Err(USAGE.to_string());
85
    }
86
    let mut noted = Vec::new();
87
    for d in dirs {
88
        for file in files(&root.join(d))? {
89
            // Text only, as grep reports a binary file rather than its lines.
90
            let Ok(text) = fs::read_to_string(&file) else { continue };
91
            let name = shown(root, &file);
92
            for (i, line) in text.lines().enumerate() {
93
                if MARKERS.iter().any(|m| line.contains(m)) {
94
                    noted.push(format!("{name}:{}:{line}", i + 1));
95
                }
96
            }
97
        }
98
    }
99
    emit(out, &format!("{} {} notes in {}", noted.len(), MARKERS.join("/"), shown_dirs(dirs)))?;
100
    for line in noted.iter().take(LISTED) {
101
        emit(out, line)?;
102
    }
103
    Ok(())
104
}
105
106
/// Lines of Rust per crate: each directory directly under `dir`.
107
fn size(root: &Path, dir: &str, out: &mut impl Write) -> Done {
108
    for krate in entries(&root.join(dir))?.into_iter().filter(|p| p.is_dir()) {
109
        let mut lines = 0;
110
        for file in files(&krate)?.into_iter().filter(|f| f.extension().is_some_and(|e| e == "rs")) {
111
            let bytes = fs::read(&file).map_err(|e| format!("{}: {e}", file.display()))?;
112
            lines += bytes.iter().filter(|&&b| b == b'\n').count();
113
        }
114
        emit(out, &format!("{:<24} {lines:>6} lines", format!("{}/", shown(root, &krate))))?;
115
    }
116
    Ok(())
117
}
118
119
fn summary(project: Option<&str>, out: &mut impl Write) -> Done {
120
    emit(out, &format!("{}: every check passed on this state.", project.unwrap_or("This project")))
121
}
122
123
/// A directory's entries, sorted by name so every runner prints the same.
0aac9efc Andy McCall ↶ 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
5544c899 Andy McCall ↶ 226
fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> {
227
    let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
228
    let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?;
229
    all.sort();
230
    Ok(all)
231
}
232
233
/// Every file under `dir`, depth first, in name order; links are not followed.
234
fn files(dir: &Path) -> Result<Vec<PathBuf>, String> {
235
    let mut found = Vec::new();
236
    for p in entries(dir)? {
237
        let kind = fs::symlink_metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?.file_type();
238
        if kind.is_dir() {
239
            found.extend(files(&p)?);
240
        } else if kind.is_file() {
241
            found.push(p);
242
        }
243
    }
244
    Ok(found)
245
}
246
247
/// A path as the workspace names it, with `/` between its parts.
248
fn shown(root: &Path, p: &Path) -> String {
249
    let rel = p.strip_prefix(root).unwrap_or(p);
250
    rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
251
}
252
253
fn shown_dirs(dirs: &[String]) -> String {
254
    dirs.iter().map(|d| format!("{}/", d.trim_end_matches('/'))).collect::<Vec<_>>().join(" ")
255
}
256
257
#[cfg(test)]
258
mod tests {
259
    use super::*;
260
261
    fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
262
        let dir = tempfile::tempdir().unwrap();
263
        for (path, text) in files {
264
            let p = dir.path().join(path);
265
            fs::create_dir_all(p.parent().unwrap()).unwrap();
266
            fs::write(p, text).unwrap();
267
        }
268
        dir
269
    }
270
271
    fn run(f: impl FnOnce(&mut Vec<u8>) -> Done) -> (Done, String) {
272
        let mut out = Vec::new();
273
        let done = f(&mut out);
274
        (done, String::from_utf8(out).unwrap())
275
    }
276
277
    fn args(a: &[&str]) -> Vec<String> {
278
        a.iter().map(|s| s.to_string()).collect()
0aac9efc Andy McCall ↶ 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}");
5544c899 Andy McCall ↶ 308
    }
309
310
    #[test]
311
    fn layout_lists_what_it_found_and_fails_on_the_first_missing() {
312
        let t = tree(&[("Cargo.toml", ""), ("docs/m0.md", "")]);
313
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "docs/m0.md"]), o));
314
        assert_eq!(done, Ok(()));
315
        assert_eq!(out, "found Cargo.toml\nfound docs/m0.md\n");
316
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "nope.rs", "docs/m0.md"]), o));
317
        assert_eq!(done, Err("missing: nope.rs".to_string()));
318
        assert_eq!(out, "found Cargo.toml\n");
319
        // A directory is not the file asked for.
320
        assert!(layout(t.path(), &args(&["docs"]), &mut Vec::new()).is_err());
321
    }
322
323
    #[test]
324
    fn todos_counts_every_marked_line_and_lists_the_first_twenty() {
325
        let marker = MARKERS[0];
326
        let many: String = (0..25).map(|i| format!("// {marker} {i}\n")).collect();
327
        let t = tree(&[
328
            ("crates/a/src/lib.rs", &format!("fn a() {{}}\n// {} later\n", MARKERS[1])),
329
            ("crates/b/src/many.rs", &many),
330
            ("crates/b/blob.bin", "\u{0}"),
331
        ]);
332
        // Invalid UTF-8 is skipped, as grep skips a binary file's lines.
333
        fs::write(t.path().join("crates/b/blob.bin"), [0xff, 0xfe, b'T']).unwrap();
334
        let (done, out) = run(|o| todos(t.path(), &args(&["crates"]), o));
335
        assert_eq!(done, Ok(()));
336
        let lines: Vec<&str> = out.lines().collect();
337
        assert_eq!(lines[0], format!("26 {} notes in crates/", MARKERS.join("/")));
338
        assert_eq!(lines[1], format!("crates/a/src/lib.rs:2:// {} later", MARKERS[1]));
339
        assert_eq!(lines[2], format!("crates/b/src/many.rs:1:// {marker} 0"));
340
        assert_eq!(lines.len(), 1 + LISTED);
341
    }
342
343
    #[test]
344
    fn size_counts_rust_lines_per_crate_in_name_order() {
345
        let t = tree(&[
346
            ("crates/zeta/src/main.rs", "a\nb\nc\n"),
347
            ("crates/alpha/src/lib.rs", "a\n"),
348
            ("crates/alpha/src/deep/mod.rs", "a\nb\n"),
349
            ("crates/alpha/README.md", "not rust\n"),
350
            ("crates/loose.txt", "a file, not a crate\n"),
351
        ]);
352
        let (done, out) = run(|o| size(t.path(), "crates", o));
353
        assert_eq!(done, Ok(()));
354
        assert_eq!(out, format!("{:<24} {:>6} lines\n{:<24} {:>6} lines\n", "crates/alpha/", 3, "crates/zeta/", 3));
355
    }
356
357
    #[test]
358
    fn summary_names_the_project() {
359
        let (_, out) = run(|o| summary(Some("OxygenFlow"), o));
360
        assert_eq!(out, "OxygenFlow: every check passed on this state.\n");
361
    }
362
}