~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
190 lines · 6.8 KB Raw History
// atlibs new <name>: a module repo to start from (the layout every atlibs-* module repo has): a cargo
// workspace of modules/*, the abi SDK by git at this CLI's own commit, one module with a passing `cargo test`.

use std::path::Path;

use crate::{Args, usage};

/// Where the scaffold takes the abi SDK: the commit this CLI was built from (build.rs), else (built
/// outside a git checkout) its version's tag.
fn abi_pin(sha: Option<&str>) -> String {
    match sha {
        Some(sha) => format!(r#"rev = "{sha}""#),
        None => format!(r#"tag = "v{}""#, env!("CARGO_PKG_VERSION")),
    }
}

/// (path, contents); NAME is the module's name.
const FILES: &[(&str, &str)] = &[
    (
        "Cargo.toml",
        r#"[workspace]
resolver = "3"
members = ["modules/*"]

[workspace.dependencies]
abi = { git = "https://knotless.dev/johnandersen777.bsky.social/atlibs", ABI_PIN }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# Modules are release builds: a debug build is ~20 MB, past the 4 MB a host loads.
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
"#,
    ),
    (
        ".cargo/config.toml",
        r#"[net]
git-fetch-with-cli = true

[target.wasm32-unknown-unknown]
rustflags = ["--cfg", "getrandom_backend=\"unsupported\""]

[alias]
wasm = "build --release --target wasm32-unknown-unknown"
"#,
    ),
    (".gitignore", "target/\nnode_modules/\n"),
    (
        "modules/NAME/Cargo.toml",
        r#"[package]
name = "NAME"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
abi.workspace = true
serde.workspace = true
serde_json.workspace = true
"#,
    ),
    (
        "modules/NAME/module.json",
        r#"{
	"name": "NAME",
	"description": "Describe an account's repo: its handle and how many collections it has.",
	"actions": [{ "entry": "describe", "label": "Describe", "readOnly": true, "fields": [{ "key": "did", "label": "DID", "required": true }] }],
	"config": [{ "key": "did", "label": "DID", "description": "The account a composition's instance describes." }],
	"calls": ["com.atproto.repo.describeRepo"]
}
"#,
    ),
    (
        "modules/NAME/src/lib.rs",
        r#"use abi::{Call, Did, Event, Handle, Run, entries};
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct Args {
    did: Did,
}

// The host calls this once per event. Start: ask the account's PDS. Result: answer.
fn describe_handler(run: Run<Args>) -> Result<Handle, String> {
    Ok(match run.event {
        Event::Start => Handle::new().call(
            Call::new("repo", "com.atproto.repo.describeRepo")
                .params(json!({ "repo": run.args.did }))
                .service(run.args.did.as_str()),
        ),
        Event::Result { id, result, .. } if id == "repo" => {
            let repo = result.output()?;
            let collections = repo["collections"].as_array().map_or(0, Vec::len);
            Handle::new().result(json!({ "handle": repo["handle"], "collections": collections })).done()
        }
        _ => Handle::new(),
    })
}

entries!(describe => describe_handler);

#[cfg(test)]
mod tests {
    use serde_json::{Value, json};

    // One event, as the host sends it; the handle back, as the host reads it.
    fn step(event: Value) -> Value {
        let run = serde_json::from_value(json!({ "did": "did:plc:alice", "event": event })).unwrap();
        serde_json::to_value(super::describe_handler(run).unwrap()).unwrap()
    }

    #[test]
    fn start_calls_describe_repo() {
        assert_eq!(step(json!({ "type": "start" }))["calls"][0]["nsid"], "com.atproto.repo.describeRepo");
    }

    #[test]
    fn result_counts_collections() {
        let output = json!({ "handle": "alice.test", "collections": ["a.b.c", "d.e.f"] });
        let handle = step(json!({ "type": "result", "id": "repo", "nsid": "com.atproto.repo.describeRepo", "ok": true, "output": output }));
        assert_eq!(handle["result"], json!({ "handle": "alice.test", "collections": 2 }));
    }
}
"#,
    ),
];

/// `atlibs new <name>`: writes ./<name>/ (refuses an existing one).
pub fn main(rest: &[String]) -> Result<(), String> {
    let args = Args::parse(rest, &[])?;
    args.only(&[], "new")?;
    let [name] = &args.pos[..] else { return Err(usage("new")) };
    scaffold(Path::new("."), name)?;
    out!("{name}/  (cd {name} && cargo test && cargo wasm && atlibs run {name} describe '{{\"did\":\"<did>\"}}')");
    Ok(())
}

/// Writes `<base>/<name>/`.
fn scaffold(base: &Path, name: &str) -> Result<(), String> {
    let ok = name.starts_with(|c: char| c.is_ascii_lowercase()) && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
    if !ok {
        return Err(format!("{name}: a module name is lowercase letters, digits and -, starting with a letter"));
    }
    let root = &base.join(name);
    if root.exists() {
        return Err(format!("{name} already exists"));
    }
    for (path, text) in FILES {
        let path = root.join(path.replace("NAME", name));
        std::fs::create_dir_all(path.parent().unwrap_or(root)).map_err(|e| format!("{}: {e}", path.display()))?;
        std::fs::write(&path, text.replace("NAME", name).replace("ABI_PIN", &abi_pin(option_env!("GIT_SHA")))).map_err(|e| format!("{}: {e}", path.display()))?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    #[test]
    fn writes_a_module_repo_and_refuses_bad_names_and_existing_dirs() {
        let dir = std::env::temp_dir().join(format!("atlibs-new-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        super::scaffold(&dir, "my-mod").unwrap();
        let toml = std::fs::read_to_string(dir.join("my-mod/modules/my-mod/Cargo.toml")).unwrap();
        assert!(toml.contains(r#"name = "my-mod""#), "{toml}");
        // built from a checkout: the abi at that commit
        let head = std::process::Command::new("git").args(["rev-parse", "HEAD"]).output().unwrap().stdout;
        let pin = format!(r#"rev = "{}""#, String::from_utf8(head).unwrap().trim());
        assert!(std::fs::read_to_string(dir.join("my-mod/Cargo.toml")).unwrap().contains(&pin), "{pin}");
        assert!(super::scaffold(&dir, "my-mod").unwrap_err().contains("already exists"));
        assert!(super::scaffold(&dir, "My_Mod").is_err());
        std::fs::remove_dir_all(&dir).unwrap();
    }

    #[test]
    fn without_a_commit_the_abi_is_the_version_tag() {
        assert_eq!(super::abi_pin(Some("abc")), r#"rev = "abc""#);
        assert_eq!(super::abi_pin(None), format!(r#"tag = "v{}""#, env!("CARGO_PKG_VERSION")));
        // the tag is a release's: this crate and the npm package share one version
        let npm: serde_json::Value = serde_json::from_str(include_str!("../../../javascript/package.json")).unwrap();
        assert_eq!(npm["version"], env!("CARGO_PKG_VERSION"), "rust/cli version != javascript/package.json version");
    }
}