// atlibs new : 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) -> Result { 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 `: writes .// (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\":\"\"}}')"); Ok(()) } /// Writes `//`. 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"); } }