upcloud-api 0.1.0

The UpCloud API 1.3 surface the nordisk estates use, as ONE trait (`UpCloudApi`) with ONE wire implementation. Which cloud a run talks to (the account, or a mock-upcloud on loopback) is an `Endpoint` decided once at the edge, and a mock endpoint cannot be pointed off this machine. The fake that answers the trait in-process lives beside mock-upcloud's state machine.
Documentation
//! **The test every consuming repository runs**: nothing outside the files it
//! names spells the provider or builds an UpCloud API path.
//!
//! Lane T3's `nothing_outside_this_module_spells_the_provider` was the
//! template: `grow_cloud.rs:17` carried its own `const API =
//! "https://api.upcloud.com/1.3"` for months while the crate's variable was
//! believed to cover it. A trait nobody is forced through is a suggestion. This
//! scan is what forces it — in each repository, over its own sources.
//!
//! What counts: a CODE line (not a `//` comment) before the file's first
//! `#[cfg(test)]` that contains one of [`PATTERNS`]. A test module may say
//! `api.upcloud.com` (a redaction test must), and a doc may discuss it.

use std::path::{Path, PathBuf};

/// The spellings that mean "this line talks to UpCloud by itself": the host,
/// the versioned root, and the path shapes only an API client builds.
pub const PATTERNS: &[&str] = &[
    "api.upcloud.com",
    "upcloud.com/1.3",
    "\"/1.3",
    "/1.3/server",
    "/1.3/storage",
    "/firewall_rule",
    "/cdrom/eject",
    "/storage/attach",
    "/storage/detach",
    "/storage/private",
    "\"/server/{",
    "\"/storage/{",
];

/// Every offending line under `roots` (recursively, `*.rs` only), as
/// `path:line: text`. `allowed` names files by their path SUFFIX
/// (`"src/upcloud_api.rs"`) whose job it is to spell the provider.
pub fn offenders(roots: &[&Path], allowed: &[&str]) -> Vec<String> {
    let mut out = Vec::new();
    let mut stack: Vec<PathBuf> = roots.iter().map(|p| p.to_path_buf()).collect();
    while let Some(p) = stack.pop() {
        if p.is_dir() {
            // A build tree is not a source tree.
            if p.file_name().map(|n| n == "target" || n == ".git").unwrap_or(false) {
                continue;
            }
            if let Ok(rd) = std::fs::read_dir(&p) {
                stack.extend(rd.flatten().map(|e| e.path()));
            }
            continue;
        }
        if p.extension().and_then(|s| s.to_str()) != Some("rs") {
            continue;
        }
        let shown = p.to_string_lossy().replace('\\', "/");
        if allowed.iter().any(|a| shown.ends_with(a)) {
            continue;
        }
        let Ok(text) = std::fs::read_to_string(&p) else { continue };
        out.extend(scan(&shown, &text));
    }
    out.sort();
    out
}

/// One file's offending lines.
pub fn scan(name: &str, text: &str) -> Vec<String> {
    let mut out = Vec::new();
    for (i, line) in text.lines().enumerate() {
        let t = line.trim_start();
        if t.starts_with("#[cfg(test)]") {
            break;
        }
        if t.starts_with("//") {
            continue;
        }
        let code = without_labels(line);
        if PATTERNS.iter().any(|p| code.contains(p)) {
            out.push(format!("{name}:{}: {}", i + 1, line.trim()));
        }
    }
    out
}

/// A string literal that STARTS with an HTTP method — `"GET /storage/private"`,
/// `"POST /server/{uuid}/cdrom/eject"` — is a label for a log line or a
/// refusal, not a request: it names the call so an operator can read which one
/// failed. It is cut out before matching, so describing a call is allowed and
/// building one is not.
fn without_labels(line: &str) -> String {
    let mut out = String::with_capacity(line.len());
    let mut rest = line;
    while let Some(i) = rest.find('"') {
        out.push_str(&rest[..i]);
        let after = &rest[i + 1..];
        let is_label = ["GET /", "POST /", "PUT /", "DELETE /"].iter().any(|m| after.starts_with(m));
        match after.find('"') {
            Some(j) if is_label => {
                out.push_str("\"\"");
                rest = &after[j + 1..];
            }
            _ => {
                out.push('"');
                rest = after;
            }
        }
    }
    out.push_str(rest);
    out
}

/// Panic with every offender named — the one-line body of each repository's
/// guard test.
pub fn assert_none(roots: &[&Path], allowed: &[&str]) {
    let o = offenders(roots, allowed);
    assert!(
        o.is_empty(),
        "these lines talk to UpCloud by themselves instead of through `upcloud_api::UpCloudApi` — so a run \
         aimed at the fake does not test them, and one of them is how a mock run reaches the account:\n{}",
        o.join("\n")
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_hardcoded_base_is_caught_and_a_comment_or_a_test_is_not() {
        let src = "// api.upcloud.com is discussed here\n\
                   const API: &str = \"https://api.upcloud.com/1.3\";\n\
                   let u = format!(\"{API}/server/{uuid}/stop\");\n\
                   #[cfg(test)]\n\
                   const T: &str = \"https://api.upcloud.com/1.3\";\n";
        let o = scan("x.rs", src);
        assert_eq!(o.len(), 1, "{o:?}");
        assert!(o[0].starts_with("x.rs:2:"), "{o:?}");
    }

    #[test]
    fn a_path_built_without_the_host_is_caught_too() {
        // The shape `upcloud.rs` had: a base from somewhere, a path spelled here.
        for l in ["let u = format!(\"{}/storage/private\", base);", "get(\"/1.3/account\")", "self.get(&format!(\"/server/{uuid}\"))"] {
            assert_eq!(scan("y.rs", l).len(), 1, "{l}");
        }
    }

    #[test]
    fn a_label_that_names_a_call_is_not_a_call() {
        assert!(scan("z.rs", "must(\"GET /storage/private\", self.api.storages_private())?;").is_empty());
        assert!(scan("z.rs", "log(\"POST /server/{uuid}/cdrom/eject\"); get(\"/storage/private\")").len() == 1, "a label does not excuse the rest of the line");
    }

    /// This crate's own sources pass with exactly the files whose job it is.
    #[test]
    fn this_crate_spells_the_provider_only_where_it_belongs() {
        let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
        assert_none(&[&src], &["src/lib.rs", "src/wire.rs", "src/guard.rs"]);
        // …and the scan is not vacuous: without the allowance it finds them.
        assert!(!offenders(&[&src], &["src/guard.rs"]).is_empty());
    }
}