OXYGENHUB

Coverage / crates/flow-checks/src/main.rs 88.0%

264 of 300 lines run, 58 of 66 functions · run #228 on main · run missed

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`.
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.
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
28
const USAGE: &str = "usage: flow-checks layout PATH... | todos DIR... | size DIR | summary | changelog FILE [MANIFEST]";
29
300×
fn main() -> ExitCode {
310×
    let args: Vec<String> = std::env::args().skip(1).collect();
320×
    let root = Path::new(".");
330×
    let mut out = io::stdout().lock();
340×
    let done = match args.split_first() {
350×
        Some((cmd, rest)) => match cmd.as_str() {
360×
            "layout" => layout(root, rest, &mut out),
370×
            "todos" => todos(root, rest, &mut out),
380×
            "size" => match rest {
390×
                [dir] => size(root, dir, &mut out),
400×
                _ => Err(USAGE.to_string()),
41
            },
420×
            "summary" => summary(std::env::var("PROJECT").ok().as_deref(), &mut out),
430×
            "changelog" => match rest {
440×
                [file] => changelog(root, file, None, &mut out),
450×
                [file, manifest] => changelog(root, file, Some(manifest), &mut out),
460×
                _ => Err(USAGE.to_string()),
47
            },
480×
            _ => Err(USAGE.to_string()),
49
        },
500×
        None => Err(USAGE.to_string()),
51
    };
520×
    match done {
530×
        Ok(()) => ExitCode::SUCCESS,
540×
        Err(e) => {
550×
            eprintln!("{e}");
560×
            ExitCode::FAILURE
57
        }
58
    }
590×
}
60
61
type Done = Result<(), String>;
62
6330×
fn emit(out: &mut impl Write, line: &str) -> Done {
6430×
    writeln!(out, "{line}").map_err(|e| format!("write: {e}"))
6530×
}
66
67
/// The workspace has the files it should.
683×
fn layout(root: &Path, paths: &[String], out: &mut impl Write) -> Done {
693×
    if paths.is_empty() {
700×
        return Err(USAGE.to_string());
713×
    }
725×
    for p in paths {
735×
        if !root.join(p).is_file() {
742×
            return Err(format!("missing: {p}"));
753×
        }
763×
        emit(out, &format!("found {p}"))?;
77
    }
781×
    Ok(())
793×
}
80
81
/// Notes left in the code, for the log.
821×
fn todos(root: &Path, dirs: &[String], out: &mut impl Write) -> Done {
831×
    if dirs.is_empty() {
840×
        return Err(USAGE.to_string());
851×
    }
861×
    let mut noted = Vec::new();
871×
    for d in dirs {
883×
        for file in files(&root.join(d))? {
89
            // Text only, as grep reports a binary file rather than its lines.
903×
            let Ok(text) = fs::read_to_string(&file) else { continue };
912×
            let name = shown(root, &file);
9227×
            for (i, line) in text.lines().enumerate() {
9330×
                if MARKERS.iter().any(|m| line.contains(m)) {
9426×
                    noted.push(format!("{name}:{}:{line}", i + 1));
9526×
                }
96
            }
97
        }
98
    }
991×
    emit(out, &format!("{} {} notes in {}", noted.len(), MARKERS.join("/"), shown_dirs(dirs)))?;
10020×
    for line in noted.iter().take(LISTED) {
10120×
        emit(out, line)?;
102
    }
1031×
    Ok(())
1041×
}
105
106
/// Lines of Rust per crate: each directory directly under `dir`.
1071×
fn size(root: &Path, dir: &str, out: &mut impl Write) -> Done {
1083×
    for krate in entries(&root.join(dir))?.into_iter().filter(|p| p.is_dir()) {
1092×
        let mut lines = 0;
1104×
        for file in files(&krate)?.into_iter().filter(|f| f.extension().is_some_and(|e| e == "rs")) {
1113×
            let bytes = fs::read(&file).map_err(|e| format!("{}: {e}", file.display()))?;
11212×
            lines += bytes.iter().filter(|&&b| b == b'\n').count();
113
        }
1142×
        emit(out, &format!("{:<24} {lines:>6} lines", format!("{}/", shown(root, &krate))))?;
115
    }
1161×
    Ok(())
1171×
}
118
1191×
fn summary(project: Option<&str>, out: &mut impl Write) -> Done {
1201×
    emit(out, &format!("{}: every check passed on this state.", project.unwrap_or("This project")))
1211×
}
122
123
/// A directory's entries, sorted by name so every runner prints the same.
124
/// A version as three numbers, or `None`.
12514×
fn version(s: &str) -> Option<(u64, u64, u64)> {
12640×
    let mut parts = s.split('.').map(|p| p.parse::<u64>().ok());
12714×
    let v = (parts.next()??, parts.next()??, parts.next()??);
12813×
    parts.next().is_none().then_some(v)
12914×
}
130
131
/// A `YYYY-MM-DD` date with a real month and day.
13213×
fn is_date(s: &str) -> bool {
13313×
    let b = s.as_bytes();
13413×
    b.len() == 10
13513×
        && b[4] == b'-'
13613×
        && b[7] == b'-'
13713×
        && s[..4].parse::<u32>().is_ok()
13813×
        && s[5..7].parse::<u32>().is_ok_and(|m| (1..=12).contains(&m))
13912×
        && s[8..].parse::<u32>().is_ok_and(|d| (1..=31).contains(&d))
14013×
}
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`.
1453×
fn manifest_version(path: &str, text: &str) -> Option<String> {
1463×
    let unquote = |v: &str| v.trim().trim_matches('"').trim_end_matches(',').trim_matches('"').to_string();
1473×
    if path.ends_with(".properties") {
1482×
        return text.lines().find_map(|l| {
1492×
            let (k, v) = l.split_once('=')?;
1502×
            (k.trim() == "version" || k.trim().ends_with(".version")).then(|| v.trim().to_string())
1512×
        });
1522×
    }
1532×
    if path.ends_with(".json") {
1543×
        return text.lines().find_map(|l| l.trim().strip_prefix("\"version\":").map(unquote));
1551×
    }
1561×
    let mut section = "";
1575×
    for l in text.lines() {
1585×
        let l = l.trim();
1595×
        if l.starts_with('[') {
1602×
            section = l;
1613×
        } else if matches!(section, "[package]" | "[workspace.package]") {
1621×
            if let Some(v) = l.strip_prefix("version").and_then(|r| r.trim_start().strip_prefix('=')) {
1631×
                return Some(unquote(v));
1640×
            }
1652×
        }
166
    }
1670×
    None
1683×
}
169
170
/// The changelog's shape, and the manifest's version in it.
1716×
fn changelog(root: &Path, file: &str, manifest: Option<&String>, out: &mut impl Write) -> Done {
1726×
    let text = fs::read_to_string(root.join(file)).map_err(|e| format!("{file}: {e}"))?;
1736×
    let mut sections: Vec<(usize, String, usize)> = Vec::new(); // (line, heading, entries)
17472×
    for (i, line) in text.lines().enumerate() {
17572×
        if let Some(h) = line.strip_prefix("## ") {
17619×
            sections.push((i + 1, h.trim().to_string(), 0));
17753×
        } else if let Some(last) = sections.last_mut() {
17837×
            if line.trim_start().starts_with("- ") || line.trim_start().starts_with("* ") {
17912×
                last.2 += 1;
18025×
            }
18116×
        }
182
    }
1836×
    let mut problems = Vec::new();
1846×
    let mut last: Option<(u64, u64, u64)> = None;
1856×
    let mut released = Vec::new();
18619×
    for (k, (line, h, n)) in sections.iter().enumerate() {
18719×
        if h == "Unreleased" {
1885×
            if k != 0 {
1891×
                problems.push(format!("{file}:{line}: ## Unreleased comes first"));
1904×
            }
1915×
            continue;
19214×
        }
19314×
        let (v, rest) = h.split_once(' ').unwrap_or((h.as_str(), ""));
19414×
        let date = rest.trim().strip_prefix('(').and_then(|r| r.strip_suffix(')'));
19514×
        match (version(v), date) {
19613×
            (Some(ver), Some(d)) if is_date(d) => {
19712×
                if last.is_some_and(|l| ver >= l) {
1981×
                    problems.push(format!("{file}:{line}: {v} is not older than the section above it"));
19911×
                }
20012×
                last = Some(ver);
20112×
                released.push(v.to_string());
20212×
                if *n == 0 {
2031×
                    problems.push(format!("{file}:{line}: ## {h} has no entries"));
20411×
                }
205
            }
2062×
            _ => problems.push(format!("{file}:{line}: \"## {h}\" is not \"## X.Y.Z (YYYY-MM-DD)\" or \"## Unreleased\"")),
207
        }
208
    }
2096×
    let mut said = format!("changelog: {} released section{}", released.len(), if released.len() == 1 { "" } else { "s" });
2106×
    if let Some(m) = manifest {
2113×
        let mtext = fs::read_to_string(root.join(m)).map_err(|e| format!("{m}: {e}"))?;
2123×
        match manifest_version(m, &mtext) {
2133×
            Some(v) if released.contains(&v) => said.push_str(&format!(", {m} at {v} has its section")),
2141×
            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
            )),
2170×
            None => problems.push(format!("{m}: no version found")),
218
        }
2193×
    }
2206×
    if problems.is_empty() {
2213×
        return emit(out, &said);
2223×
    }
2233×
    Err(problems.join("\n"))
2246×
}
225
22611×
fn entries(dir: &Path) -> Result<Vec<PathBuf>, String> {
22711×
    let read = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
22817×
    let mut all = read.map(|e| e.map(|e| e.path())).collect::<Result<Vec<_>, _>>().map_err(|e| format!("{}: {e}", dir.display()))?;
22911×
    all.sort();
23011×
    Ok(all)
23111×
}
232
233
/// Every file under `dir`, depth first, in name order; links are not followed.
23410×
fn files(dir: &Path) -> Result<Vec<PathBuf>, String> {
23510×
    let mut found = Vec::new();
23614×
    for p in entries(dir)? {
23714×
        let kind = fs::symlink_metadata(&p).map_err(|e| format!("{}: {e}", p.display()))?.file_type();
23814×
        if kind.is_dir() {
2397×
            found.extend(files(&p)?);
2407×
        } else if kind.is_file() {
2417×
            found.push(p);
2427×
        }
243
    }
24410×
    Ok(found)
24510×
}
246
247
/// A path as the workspace names it, with `/` between its parts.
2484×
fn shown(root: &Path, p: &Path) -> String {
2494×
    let rel = p.strip_prefix(root).unwrap_or(p);
25012×
    rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")
2514×
}
252
2531×
fn shown_dirs(dirs: &[String]) -> String {
2541×
    dirs.iter().map(|d| format!("{}/", d.trim_end_matches('/'))).collect::<Vec<_>>().join(" ")
2551×
}
256
257
#[cfg(test)]
258
mod tests {
259
    use super::*;
260
2616×
    fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
2626×
        let dir = tempfile::tempdir().unwrap();
26316×
        for (path, text) in files {
26416×
            let p = dir.path().join(path);
26516×
            fs::create_dir_all(p.parent().unwrap()).unwrap();
26616×
            fs::write(p, text).unwrap();
26716×
        }
2686×
        dir
2696×
    }
270
27111×
    fn run(f: impl FnOnce(&mut Vec<u8>) -> Done) -> (Done, String) {
27211×
        let mut out = Vec::new();
27311×
        let done = f(&mut out);
27411×
        (done, String::from_utf8(out).unwrap())
27511×
    }
276
2774×
    fn args(a: &[&str]) -> Vec<String> {
2787×
        a.iter().map(|s| s.to_string()).collect()
2794×
    }
280
281
    #[test]
2821×
    fn changelog_shape_and_the_manifest_version() {
2831×
        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";
2841×
        let dir = tree(&[
2851×
            ("CHANGELOG.md", good),
2861×
            ("Cargo.toml", "[workspace]\nmembers = []\n\n[workspace.package]\nversion = \"0.2.0\"\n"),
2871×
            ("gradle.properties", "android.useAndroidX=true\noxygenhub.version=0.1.9\n"),
2881×
            ("package.json", "{\n  \"name\": \"x\",\n  \"version\": \"0.3.0\",\n}\n"),
2891×
        ]);
2904×
        let r = |m: Option<&str>| run(|o| changelog(dir.path(), "CHANGELOG.md", m.map(String::from).as_ref(), o));
2911×
        let (done, out) = r(None);
2921×
        assert_eq!((done, out.trim()), (Ok(()), "changelog: 2 released sections"));
2931×
        assert!(r(Some("Cargo.toml")).1.contains("Cargo.toml at 0.2.0 has its section"));
2941×
        assert!(r(Some("gradle.properties")).0.is_ok(), "an older release's section counts");
2951×
        let e = r(Some("package.json")).0.unwrap_err();
2961×
        assert!(e.contains("version 0.3.0") && e.contains("no \"## 0.3.0 (YYYY-MM-DD)\""), "{e}");
2971×
        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";
2981×
        let dir = tree(&[("CHANGELOG.md", bad)]);
2991×
        let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err();
3001×
        assert!(e.contains(":5: ## Unreleased comes first"), "{e}");
3011×
        assert!(e.contains(":7: \"## 0.2.0 (2026-13-01)\" is not"), "a month 13: {e}");
3021×
        assert!(e.contains(":11: ## 0.0.9 (2026-09-01) has no entries"), "{e}");
3031×
        assert!(e.contains(":13: \"## v1\""), "{e}");
3041×
        let order = "## 0.1.0 (2026-10-01)\n\n- a\n\n## 0.2.0 (2026-11-01)\n\n- b\n";
3051×
        let dir = tree(&[("CHANGELOG.md", order)]);
3061×
        let e = run(|o| changelog(dir.path(), "CHANGELOG.md", None, o)).0.unwrap_err();
3071×
        assert!(e.contains("0.2.0 is not older than the section above it"), "{e}");
3081×
    }
309
310
    #[test]
3111×
    fn layout_lists_what_it_found_and_fails_on_the_first_missing() {
3121×
        let t = tree(&[("Cargo.toml", ""), ("docs/m0.md", "")]);
3131×
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "docs/m0.md"]), o));
3141×
        assert_eq!(done, Ok(()));
3151×
        assert_eq!(out, "found Cargo.toml\nfound docs/m0.md\n");
3161×
        let (done, out) = run(|o| layout(t.path(), &args(&["Cargo.toml", "nope.rs", "docs/m0.md"]), o));
3171×
        assert_eq!(done, Err("missing: nope.rs".to_string()));
3181×
        assert_eq!(out, "found Cargo.toml\n");
319
        // A directory is not the file asked for.
3201×
        assert!(layout(t.path(), &args(&["docs"]), &mut Vec::new()).is_err());
3211×
    }
322
323
    #[test]
3241×
    fn todos_counts_every_marked_line_and_lists_the_first_twenty() {
3251×
        let marker = MARKERS[0];
32625×
        let many: String = (0..25).map(|i| format!("// {marker} {i}\n")).collect();
3271×
        let t = tree(&[
3281×
            ("crates/a/src/lib.rs", &format!("fn a() {{}}\n// {} later\n", MARKERS[1])),
3291×
            ("crates/b/src/many.rs", &many),
3301×
            ("crates/b/blob.bin", "\u{0}"),
3311×
        ]);
332
        // Invalid UTF-8 is skipped, as grep skips a binary file's lines.
3331×
        fs::write(t.path().join("crates/b/blob.bin"), [0xff, 0xfe, b'T']).unwrap();
3341×
        let (done, out) = run(|o| todos(t.path(), &args(&["crates"]), o));
3351×
        assert_eq!(done, Ok(()));
3361×
        let lines: Vec<&str> = out.lines().collect();
3371×
        assert_eq!(lines[0], format!("26 {} notes in crates/", MARKERS.join("/")));
3381×
        assert_eq!(lines[1], format!("crates/a/src/lib.rs:2:// {} later", MARKERS[1]));
3391×
        assert_eq!(lines[2], format!("crates/b/src/many.rs:1:// {marker} 0"));
3401×
        assert_eq!(lines.len(), 1 + LISTED);
3411×
    }
342
343
    #[test]
3441×
    fn size_counts_rust_lines_per_crate_in_name_order() {
3451×
        let t = tree(&[
3461×
            ("crates/zeta/src/main.rs", "a\nb\nc\n"),
3471×
            ("crates/alpha/src/lib.rs", "a\n"),
3481×
            ("crates/alpha/src/deep/mod.rs", "a\nb\n"),
3491×
            ("crates/alpha/README.md", "not rust\n"),
3501×
            ("crates/loose.txt", "a file, not a crate\n"),
3511×
        ]);
3521×
        let (done, out) = run(|o| size(t.path(), "crates", o));
3531×
        assert_eq!(done, Ok(()));
3541×
        assert_eq!(out, format!("{:<24} {:>6} lines\n{:<24} {:>6} lines\n", "crates/alpha/", 3, "crates/zeta/", 3));
3551×
    }
356
357
    #[test]
3581×
    fn summary_names_the_project() {
3591×
        let (_, out) = run(|o| summary(Some("OxygenFlow"), o));
3601×
        assert_eq!(out, "OxygenFlow: every check passed on this state.\n");
3611×
    }
362
}