diff --git a/Cargo.lock b/Cargo.lock index a760b54..a024a83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1495,6 +1495,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -1951,6 +1960,47 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typenum" version = "1.20.1" diff --git a/cougr-cli/Cargo.toml b/cougr-cli/Cargo.toml new file mode 100644 index 0000000..68fb22d --- /dev/null +++ b/cougr-cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "cougr-cli" +version = "0.1.0" +edition = "2021" +description = "The Cougr CLI — scaffold projects and add pieces from the canonical example catalog" +license = "MIT" +repository = "https://github.com/salazarsebas/Cougr" +homepage = "https://github.com/salazarsebas/Cougr" +keywords = ["cougr", "stellar", "soroban", "cli", "scaffolding"] +categories = ["command-line-utilities", "game-engines"] + +[[bin]] +name = "cougr" +path = "src/main.rs" + +[dependencies] +# Argument parsing +clap = { version = "4.5.4", features = ["derive"] } +# TOML for the embedded pieces manifest +toml = "0.8.12" +# Serialization for the manifest structs +serde = { version = "1.0.202", features = ["derive"] } +# Coloured terminal output +anstream = "0.6.14" +# Error handling +anyhow = "1.0.86" + +[dev-dependencies] +tempfile = "3.10.1" diff --git a/cougr-cli/pieces.toml b/cougr-cli/pieces.toml new file mode 100644 index 0000000..22dff6f --- /dev/null +++ b/cougr-cli/pieces.toml @@ -0,0 +1,405 @@ +# Cougr piece catalog — embedded in the binary at compile time. +# +# Each [[piece]] entry describes a self-contained component/system pair (or +# standards-layer module) that `cougr add ` copies into the caller's +# project as owned, editable source. +# +# Fields +# ────── +# name – unique identifier used as the CLI argument (e.g. "session-auth") +# description – one-line summary shown by `cougr add --list` +# maturity – "stable" | "beta" | "experimental" +# source – canonical example or module the piece is derived from +# +# [[piece.files]] +# target – destination path relative to the project's src/ directory +# content – verbatim source that will be written to that file +# +# [[piece.cargo_deps]] +# line – a dependency line to append to [dependencies] in Cargo.toml if absent +# dev – true means append to [dev-dependencies] instead + +# ───────────────────────────────────────────────────────────────────────────── +# 1. session-auth +# ───────────────────────────────────────────────────────────────────────────── +[[piece]] +name = "session-auth" +description = "Session-key auth: approve once, play frictionlessly, renew on expiry (SessionManager lifecycle)" +maturity = "beta" +source = "examples/session_arena" + +[[piece.files]] +target = "session_auth/mod.rs" +content = ''' +//! session_auth — session-key authorisation piece. +//! +//! Copied from examples/session_arena by `cougr add session-auth`. +//! +//! Flow: `approve_session` once → `tap` many times without wallet prompts → +//! `renew_session` before expiry → `fallback_tap` when session is stale. +//! +//! Add to your contract: +//! 1. Import the piece: `use crate::session_auth::SessionAuth;` +//! 2. Call `SessionAuth::approve_session` from your `#[contractimpl]` impl. +//! 3. Call `SessionAuth::tap` / `SessionAuth::fallback_tap` in your game actions. + +#![allow(unused_imports)] + +use cougr_core::accounts::{ + GameAction, ReplayProtection, SessionBuilder, SessionStorage, SignedIntent, +}; +use cougr_core::impl_component; +use cougr_core::session::{ActiveSession, SessionManager}; +use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Bytes, Env, Symbol}; + +// ── Score component ────────────────────────────────────────────────────────── + +/// Simple per-player tap counter stored in instance storage. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SessionScore { + pub taps: u32, +} + +impl_component!(SessionScore, "sess_score", Table, { taps: u32 }); + +// ── Session helpers ─────────────────────────────────────────────────────────── + +/// Thin helper that wraps the `SessionManager` lifecycle for a single action +/// called "tap". Rename the action and score type to suit your game. +pub struct SessionAuth; + +impl SessionAuth { + /// One-time owner approval that creates a scoped session key. + pub fn approve_session( + env: &Env, + owner: &Address, + max_ops: u32, + expires_in: u64, + ) -> ActiveSession { + owner.require_auth(); + let scope = SessionBuilder::new(env) + .allow_action(symbol_short!("tap")) + .max_operations(max_ops) + .expires_in(expires_in) + .build_scope(); + let key = SessionManager::approve(env, owner, scope).expect("session approved"); + let status = + SessionManager::status(env, owner, &key.key_id).expect("session status"); + ActiveSession::from_status(&status, key.scope.expires_at) + } + + /// Gameplay action authorised via the active session (no wallet prompt). + pub fn tap(env: &Env, owner: &Address, key_id: &BytesN<32>) -> u32 { + let session = SessionStorage::load(env, owner, key_id).expect("session missing"); + let action = GameAction { + system_name: symbol_short!("tap"), + data: Bytes::new(env), + }; + SessionManager::execute_action( + env, + owner, + &session, + action, + env.ledger().timestamp().saturating_add(60), + ) + .expect("session tap"); + Self::increment_score(env, owner) + } + + /// Extend session lifetime (owner must re-approve via wallet). + pub fn renew_session( + env: &Env, + owner: &Address, + key_id: &BytesN<32>, + expires_in: u64, + ) -> ActiveSession { + owner.require_auth(); + let new_expires = env.ledger().timestamp().saturating_add(expires_in); + let key = + SessionManager::renew(env, owner, key_id, new_expires).expect("renewed"); + let status = + SessionManager::status(env, owner, &key.key_id).expect("session status"); + ActiveSession::from_status(&status, key.scope.expires_at) + } + + /// Tap using session first, falling back to direct owner auth when expired. + pub fn fallback_tap(env: &Env, owner: &Address, key_id: &BytesN<32>) -> u32 { + let session = SessionStorage::load(env, owner, key_id).expect("session missing"); + let action = GameAction { + system_name: symbol_short!("tap"), + data: Bytes::new(env), + }; + let session_intent = SignedIntent::session( + env, + owner.clone(), + key_id, + action.clone(), + session.next_nonce, + env.ledger().timestamp().saturating_add(60), + ); + let direct_intent = SignedIntent::direct( + env, + owner.clone(), + action, + ReplayProtection::next_account_nonce(env, owner), + env.ledger().timestamp().saturating_add(60), + ); + SessionManager::fallback_execute(env, &session_intent, &direct_intent) + .expect("fallback tap"); + Self::increment_score(env, owner) + } + + /// Read the current tap count for a player. + pub fn score(env: &Env, owner: &Address) -> u32 { + let key = (Symbol::new(env, "score"), owner.clone()); + env.storage() + .instance() + .get(&key) + .map(|s: SessionScore| s.taps) + .unwrap_or(0) + } + + fn increment_score(env: &Env, owner: &Address) -> u32 { + let key = (Symbol::new(env, "score"), owner.clone()); + let mut score: SessionScore = env + .storage() + .instance() + .get(&key) + .unwrap_or(SessionScore { taps: 0 }); + score.taps = score.taps.saturating_add(1); + env.storage().instance().set(&key, &score); + score.taps + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cougr_core::test::GameHarness; + // GameHarness is only available with the testutils feature; skip otherwise. + #[allow(unused_imports)] + use soroban_sdk::{testutils::Address as _, Address, Env}; +} +''' + +[[piece.cargo_deps]] +line = 'cougr-core = "1.1.0"' +dev = false + +[[piece.cargo_deps]] +line = 'cougr-core = { version = "1.1.0", features = ["testutils"] }' +dev = true + +# ───────────────────────────────────────────────────────────────────────────── +# 2. hidden-hand +# ───────────────────────────────────────────────────────────────────────────── +[[piece]] +name = "hidden-hand" +description = "Hidden-card ZK proof flow: init a table, verify deal proofs via HiddenHandBuilder (Experimental)" +maturity = "experimental" +source = "examples/hidden_hand" + +[[piece.files]] +target = "hidden_hand/mod.rs" +content = ''' +//! hidden_hand — ZK hidden-card circuit piece. +//! +//! Copied from examples/hidden_hand by `cougr add hidden-hand`. +//! +//! Uses `cougr_core::circuits::hidden_cards` to build a Groth16 verification +//! spec for private card deals. Both `init_table` and `verify_deal` are +//! stand-alone functions you can call from your `#[contractimpl]` impl. +//! +//! ⚠️ Maturity: Experimental — the circuit API may change between minor versions. + +use cougr_core::circuits::hidden_cards; +use cougr_core::zk::Groth16Proof; +use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Symbol}; + +// ── Table configuration ─────────────────────────────────────────────────────── + +/// Stores the deck/hand sizes that were used to generate the circuit spec. +#[contracttype] +#[derive(Clone, Debug)] +pub struct HiddenTableConfig { + pub deck_size: u32, + pub hand_size: u32, +} + +// ── Functions ───────────────────────────────────────────────────────────────── + +/// Store deck / hand dimensions and validate the circuit spec. +pub fn init_table(env: &Env, deck_size: u32, hand_size: u32) -> HiddenTableConfig { + let _spec = hidden_cards(env, deck_size, hand_size).expect("valid hidden-cards spec"); + let config = HiddenTableConfig { deck_size, hand_size }; + env.storage().instance().set(&table_key(env), &config); + config +} + +/// Verify a deal proof against the stored table configuration. +/// +/// Returns `true` if the proof is valid for the given deck root, hand +/// commitment, and player identity. +pub fn verify_deal( + env: &Env, + player: &Address, + deck_root: &BytesN<32>, + hand_commitment: &BytesN<32>, + proof: &Groth16Proof, +) -> bool { + let config: HiddenTableConfig = env + .storage() + .instance() + .get(&table_key(env)) + .expect("table not initialized — call init_table first"); + let spec = + hidden_cards(env, config.deck_size, config.hand_size).expect("circuit spec"); + spec.verify_hidden_hand(env, proof, deck_root, hand_commitment, player_id(player)) + .unwrap_or(false) +} + +// ── Internals ───────────────────────────────────────────────────────────────── + +fn table_key(_env: &Env) -> Symbol { + symbol_short!("hh_table") +} + +/// Cheap deterministic player id derived from the first 4 address bytes. +pub fn player_id(player: &Address) -> u32 { + let bytes = player.to_string().to_bytes(); + let mut id = 0u32; + for i in 0..bytes.len().min(4) { + id = id.wrapping_add(u32::from(bytes.get(i).unwrap_or(0))); + } + id +} + +#[cfg(test)] +mod tests { + use super::*; + use cougr_core::circuits::{hidden_cards, test_fixtures, CircuitId}; + use cougr_core::test::GameHarness; + #[allow(unused_imports)] + use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; +} +''' + +[[piece.cargo_deps]] +line = 'cougr-core = "1.1.0"' +dev = false + +[[piece.cargo_deps]] +line = 'cougr-core = { version = "1.1.0", features = ["testutils"] }' +dev = true + +# ───────────────────────────────────────────────────────────────────────────── +# 3. standards/pausable +# ───────────────────────────────────────────────────────────────────────────── +[[piece]] +name = "standards/pausable" +description = "Emergency pause/unpause guard backed by persistent storage (Pausable from cougr_core::ops)" +maturity = "stable" +source = "src/standards/pausable.rs" + +[[piece.files]] +target = "standards/pausable.rs" +content = ''' +//! standards/pausable — emergency pause guard piece. +//! +//! Copied from src/standards/pausable.rs by `cougr add standards/pausable`. +//! +//! Usage in your contract: +//! +//! ```rust,ignore +//! use crate::standards::pausable::ContractPausable; +//! +//! // In your #[contractimpl]: +//! pub fn pause(env: Env, caller: Address) { +//! caller.require_auth(); +//! ContractPausable::pause(&env, &caller).expect("pause failed"); +//! } +//! +//! pub fn some_action(env: Env) { +//! ContractPausable::require_not_paused(&env).expect("contract is paused"); +//! // … game logic … +//! } +//! ``` + +use cougr_core::ops::{Pausable, PausedEvent, StandardsError, UnpausedEvent}; +use soroban_sdk::{symbol_short, Address, Env}; + +/// Contract-level pause guard using a well-known storage key. +/// +/// Wrap your own logic around this type — add role checks, emit events, or +/// extend with admin-only gating as your game requires. +pub struct ContractPausable; + +impl ContractPausable { + fn pausable(env: &Env) -> Pausable { + Pausable::new(symbol_short!("contract")) + } + + pub fn is_paused(env: &Env) -> bool { + Self::pausable(env).is_paused(env) + } + + pub fn require_not_paused(env: &Env) -> Result<(), StandardsError> { + Self::pausable(env).require_not_paused(env) + } + + pub fn require_paused(env: &Env) -> Result<(), StandardsError> { + Self::pausable(env).require_paused(env) + } + + pub fn pause(env: &Env, caller: &Address) -> Result { + Self::pausable(env).pause(env, caller) + } + + pub fn unpause(env: &Env, caller: &Address) -> Result { + Self::pausable(env).unpause(env, caller) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + #[test] + fn pause_and_unpause_round_trip() { + let env = Env::default(); + env.mock_all_auths(); + let caller = Address::generate(&env); + + assert!(!ContractPausable::is_paused(&env)); + assert!(ContractPausable::require_not_paused(&env).is_ok()); + + ContractPausable::pause(&env, &caller).unwrap(); + assert!(ContractPausable::is_paused(&env)); + assert!(ContractPausable::require_not_paused(&env).is_err()); + assert!(ContractPausable::require_paused(&env).is_ok()); + + ContractPausable::unpause(&env, &caller).unwrap(); + assert!(!ContractPausable::is_paused(&env)); + } + + #[test] + fn double_pause_returns_error() { + let env = Env::default(); + env.mock_all_auths(); + let caller = Address::generate(&env); + + ContractPausable::pause(&env, &caller).unwrap(); + assert!(ContractPausable::pause(&env, &caller).is_err()); + } +} +''' + +[[piece.cargo_deps]] +line = 'cougr-core = "1.1.0"' +dev = false + +[[piece.cargo_deps]] +line = 'soroban-sdk = { version = "25.1.0", features = ["testutils"] }' +dev = true diff --git a/cougr-cli/src/add.rs b/cougr-cli/src/add.rs new file mode 100644 index 0000000..0ba5f2e --- /dev/null +++ b/cougr-cli/src/add.rs @@ -0,0 +1,351 @@ +//! Implementation of `cougr add [--list] []`. +//! +//! ## Behaviour +//! +//! * `cougr add --list` — prints all available pieces with one-line descriptions. +//! * `cougr add ` — copies a piece's files into `src/`, appends `mod` +//! declarations to `lib.rs`, and reports what was written. +//! +//! ## Conflict handling +//! +//! If any target file already exists the command refuses to overwrite it, +//! prints what would have been written, and exits with a non-zero status. +//! This follows the shadcn/ui principle: you own the code, so the tool must +//! never silently clobber local edits. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::catalog::{self, Piece, PieceFile}; + +// ── Public entry points ─────────────────────────────────────────────────────── + +/// Run `cougr add --list`. +/// +/// Prints all pieces in the catalog, one per line, with maturity tag. +pub fn run_list() -> anyhow::Result<()> { + let pieces = catalog::load(); + println!("Available pieces ({}):\n", pieces.len()); + for p in &pieces { + println!( + " {:<30} [{}] {}", + p.name, + maturity_label(&p.maturity), + p.description + ); + } + println!(); + println!("Add a piece to the current project:"); + println!(" cougr add "); + Ok(()) +} + +/// Run `cougr add ` in `project_root`. +/// +/// * Resolves the piece by name. +/// * Checks for conflicts before writing anything. +/// * On conflict, prints a diff-style preview and returns an error. +/// * On success, writes all files and updates `lib.rs`. +pub fn run_add(piece_name: &str, project_root: &Path) -> anyhow::Result<()> { + let pieces = catalog::load(); + let piece = catalog::find(&pieces, piece_name).ok_or_else(|| { + anyhow::anyhow!( + "Unknown piece '{piece_name}'.\nRun `cougr add --list` to see available pieces." + ) + })?; + + let src_dir = project_root.join("src"); + if !src_dir.exists() { + anyhow::bail!( + "No 'src/' directory found in '{}'.\n\ + Make sure you are running this command from the root of a Cougr project.", + project_root.display() + ); + } + + // ── 1. Conflict check ────────────────────────────────────────────────── + let conflicts: Vec<&PieceFile> = piece + .files + .iter() + .filter(|f| src_dir.join(&f.target).exists()) + .collect(); + + if !conflicts.is_empty() { + eprintln!("error: the following files already exist and will not be overwritten:\n"); + for f in &conflicts { + let path = src_dir.join(&f.target); + eprintln!(" {}", path.display()); + } + eprintln!("\nTo preview what would be written, inspect the cougr-cli pieces.toml:"); + for f in &conflicts { + eprintln!( + "\n # target: src/{}\n{}", + f.target, + indent(&f.content, " ") + ); + } + anyhow::bail!("Aborting: resolve conflicts before running `cougr add` again."); + } + + // ── 2. Write files ───────────────────────────────────────────────────── + let mut written: Vec = Vec::new(); + for file in &piece.files { + let dest = src_dir.join(&file.target); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&dest, file.content.as_bytes())?; + written.push(dest); + } + + // ── 3. Wire mod declarations into lib.rs ─────────────────────────────── + let lib_rs = src_dir.join("lib.rs"); + if lib_rs.exists() { + wire_mod_declarations(&lib_rs, piece)?; + } + + // ── 4. Report ────────────────────────────────────────────────────────── + println!("✓ Added piece '{}' [{}]", piece.name, piece.maturity); + println!(); + for path in &written { + println!(" wrote {}", path.display()); + } + if lib_rs.exists() { + println!(" updated {}", lib_rs.display()); + } + + if !piece.cargo_deps.is_empty() { + println!(); + println!("Ensure these lines are present in your Cargo.toml:\n"); + let (deps, dev_deps): (Vec<_>, Vec<_>) = + piece.cargo_deps.iter().partition(|d| !d.dev); + if !deps.is_empty() { + println!("[dependencies]"); + for d in &deps { + println!(" {}", d.line); + } + } + if !dev_deps.is_empty() { + println!("[dev-dependencies]"); + for d in &dev_deps { + println!(" {}", d.line); + } + } + println!(); + println!("(cougr add does not modify Cargo.toml automatically to avoid"); + println!(" conflicting with workspace-level dependency management.)"); + } + + if piece.maturity == "experimental" { + println!(); + println!( + "⚠ Note: '{}' is Experimental — the API may change in minor versions.", + piece.name + ); + } else if piece.maturity == "beta" { + println!(); + println!( + "ℹ Note: '{}' is Beta — the API is stable but may see refinements.", + piece.name + ); + } + + Ok(()) +} + +// ── Mod declaration wiring ──────────────────────────────────────────────────── + +/// Append `pub mod ;` (or `pub mod { pub mod ; }`) to +/// `lib.rs` for each file in the piece, without clobbering existing content. +/// +/// Skips a declaration if an identical `mod ` line already appears in +/// the file — this is the idempotency guard for running `cougr add` twice. +fn wire_mod_declarations(lib_rs: &Path, piece: &Piece) -> anyhow::Result<()> { + let original = fs::read_to_string(lib_rs)?; + let mut additions: Vec = Vec::new(); + + for file in &piece.files { + // e.g. "session_auth/mod.rs" → top-level module "session_auth" + // "standards/pausable.rs" → sub-module: need "pub mod standards { pub mod pausable; }" + let parts: Vec<&str> = file.target.trim_end_matches(".rs").split('/').collect(); + + match parts.as_slice() { + // "mod.rs" at top level: "session_auth/mod.rs" → pub mod session_auth; + [module, "mod"] => { + let decl = format!("pub mod {module};"); + if !original.contains(&decl) { + additions.push(decl); + } + } + // single-level file: "session_auth.rs" → pub mod session_auth; + [module] => { + let decl = format!("pub mod {module};"); + if !original.contains(&decl) { + additions.push(decl); + } + } + // two-level: "standards/pausable.rs" → pub mod standards { pub mod pausable; } + // We don't create nested module blocks automatically because the + // parent module might be a directory mod. Instead we add both + // declarations and leave it for the user to place them correctly. + [parent, child] => { + let parent_decl = format!("pub mod {parent};"); + let child_decl = format!("pub mod {child};"); + if !original.contains(&child_decl) { + // Write a comment block so the user knows exactly what to add. + let note = format!( + "// TODO(cougr add): wire the following inside src/{parent}/mod.rs:\n\ + // {child_decl}\n\ + // Then add to lib.rs:\n\ + // {parent_decl}" + ); + if !original.contains(¬e) { + additions.push(note); + } + } + } + _ => { + // Deeper nesting — just leave a comment. + let note = format!("// TODO(cougr add): manually wire module for {}", file.target); + additions.push(note); + } + } + } + + if additions.is_empty() { + return Ok(()); + } + + let block = format!( + "\n// ── Added by `cougr add {}` ──\n{}\n", + piece.name, + additions.join("\n") + ); + let mut content = original; + content.push_str(&block); + fs::write(lib_rs, content.as_bytes())?; + Ok(()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn maturity_label(maturity: &str) -> &str { + match maturity { + "stable" => "stable ", + "beta" => "beta ", + "experimental" => "experimental", + other => other, + } +} + +fn indent(s: &str, prefix: &str) -> String { + s.lines() + .map(|l| format!("{prefix}{l}")) + .collect::>() + .join("\n") +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn make_project(dir: &TempDir) -> PathBuf { + let src = dir.path().join("src"); + fs::create_dir_all(&src).unwrap(); + fs::write(src.join("lib.rs"), "#![no_std]\n\n").unwrap(); + dir.path().to_path_buf() + } + + #[test] + fn add_session_auth_creates_file() { + let tmp = TempDir::new().unwrap(); + let root = make_project(&tmp); + run_add("session-auth", &root).expect("add should succeed"); + + let dest = root.join("src").join("session_auth").join("mod.rs"); + assert!(dest.exists(), "session_auth/mod.rs should be created"); + let content = fs::read_to_string(&dest).unwrap(); + assert!( + content.contains("SessionAuth"), + "file should contain SessionAuth" + ); + } + + #[test] + fn add_hidden_hand_creates_file() { + let tmp = TempDir::new().unwrap(); + let root = make_project(&tmp); + run_add("hidden-hand", &root).expect("add should succeed"); + + let dest = root.join("src").join("hidden_hand").join("mod.rs"); + assert!(dest.exists(), "hidden_hand/mod.rs should be created"); + } + + #[test] + fn add_standards_pausable_creates_file() { + let tmp = TempDir::new().unwrap(); + let root = make_project(&tmp); + run_add("standards/pausable", &root).expect("add should succeed"); + + let dest = root.join("src").join("standards").join("pausable.rs"); + assert!(dest.exists(), "standards/pausable.rs should be created"); + } + + #[test] + fn add_updates_lib_rs_with_mod_declaration() { + let tmp = TempDir::new().unwrap(); + let root = make_project(&tmp); + run_add("session-auth", &root).expect("add should succeed"); + + let lib = fs::read_to_string(root.join("src").join("lib.rs")).unwrap(); + assert!( + lib.contains("pub mod session_auth;"), + "lib.rs should contain mod declaration; got:\n{lib}" + ); + } + + #[test] + fn double_add_is_refused() { + let tmp = TempDir::new().unwrap(); + let root = make_project(&tmp); + run_add("session-auth", &root).expect("first add should succeed"); + + let result = run_add("session-auth", &root); + assert!( + result.is_err(), + "second add should fail due to conflict detection" + ); + } + + #[test] + fn add_unknown_piece_gives_helpful_error() { + let tmp = TempDir::new().unwrap(); + let root = make_project(&tmp); + let err = run_add("not-a-real-piece", &root).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Unknown piece"), + "error should mention unknown piece; got: {msg}" + ); + assert!( + msg.contains("--list"), + "error should suggest --list; got: {msg}" + ); + } + + #[test] + fn add_without_src_dir_gives_helpful_error() { + let tmp = TempDir::new().unwrap(); + // No src/ directory created + let err = run_add("session-auth", tmp.path()).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("src/"), + "error should mention missing src/; got: {msg}" + ); + } +} diff --git a/cougr-cli/src/catalog.rs b/cougr-cli/src/catalog.rs new file mode 100644 index 0000000..796affa --- /dev/null +++ b/cougr-cli/src/catalog.rs @@ -0,0 +1,123 @@ +//! Piece catalog — loaded from the `pieces.toml` file embedded in the binary +//! at compile time via `include_str!`. +//! +//! The catalog is parsed once on first access and is immutable for the lifetime +//! of the process. + +use serde::Deserialize; + +/// Raw TOML source embedded at compile time. +const CATALOG_TOML: &str = include_str!("../pieces.toml"); + +// ── Data model ──────────────────────────────────────────────────────────────── + +/// A single file that will be written into the user's project. +#[derive(Debug, Deserialize)] +pub struct PieceFile { + /// Destination path relative to the project's `src/` directory. + pub target: String, + /// Verbatim file content. + pub content: String, +} + +/// A dependency line to append to `Cargo.toml`. +#[derive(Debug, Deserialize)] +pub struct PieceDep { + /// The exact line to append (e.g. `cougr-core = "1.1.0"`). + pub line: String, + /// If `true`, append to `[dev-dependencies]`; otherwise `[dependencies]`. + #[serde(default)] + pub dev: bool, +} + +/// A single piece in the catalog. +#[derive(Debug, Deserialize)] +pub struct Piece { + /// Unique CLI identifier — also used as the argument to `cougr add`. + pub name: String, + /// One-line description shown by `cougr add --list`. + pub description: String, + /// Maturity tier: "stable" | "beta" | "experimental". + pub maturity: String, + /// Canonical source path (informational only). + #[allow(dead_code)] + pub source: String, + /// Files to write into `src/`. + #[serde(default)] + pub files: Vec, + /// Dependency lines to ensure are present in `Cargo.toml`. + #[serde(default)] + pub cargo_deps: Vec, +} + +/// The full catalog deserialized from `pieces.toml`. +#[derive(Debug, Deserialize)] +struct CatalogToml { + piece: Vec, +} + +/// Load and parse the embedded catalog. +/// +/// Panics at startup if `pieces.toml` is malformed — this is intentional; +/// a broken catalog is a compile-time/packaging bug, not a user error. +pub fn load() -> Vec { + let raw: CatalogToml = + toml::from_str(CATALOG_TOML).expect("pieces.toml is malformed; this is a packaging bug"); + raw.piece +} + +/// Look up a piece by name (case-insensitive for user convenience). +pub fn find<'a>(catalog: &'a [Piece], name: &str) -> Option<&'a Piece> { + let needle = name.to_ascii_lowercase(); + catalog + .iter() + .find(|p| p.name.to_ascii_lowercase() == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_loads_successfully() { + let pieces = load(); + assert!(!pieces.is_empty(), "catalog must contain at least one piece"); + } + + #[test] + fn catalog_has_required_pieces() { + let pieces = load(); + let names: Vec<&str> = pieces.iter().map(|p| p.name.as_str()).collect(); + assert!( + names.contains(&"session-auth"), + "catalog must contain session-auth" + ); + assert!( + names.contains(&"hidden-hand"), + "catalog must contain hidden-hand" + ); + assert!( + names.contains(&"standards/pausable"), + "catalog must contain standards/pausable" + ); + } + + #[test] + fn every_piece_has_at_least_one_file() { + for piece in load() { + assert!( + !piece.files.is_empty(), + "piece '{}' has no files", + piece.name + ); + } + } + + #[test] + fn find_is_case_insensitive() { + let pieces = load(); + assert!(find(&pieces, "SESSION-AUTH").is_some()); + assert!(find(&pieces, "hidden-hand").is_some()); + assert!(find(&pieces, "does-not-exist").is_none()); + } +} diff --git a/cougr-cli/src/main.rs b/cougr-cli/src/main.rs new file mode 100644 index 0000000..9994415 --- /dev/null +++ b/cougr-cli/src/main.rs @@ -0,0 +1,87 @@ +//! cougr — the Cougr CLI. +//! +//! # Commands +//! +//! * `cougr add --list` — list all available pieces. +//! * `cougr add ` — copy a piece into the current project's `src/`. + +mod add; +mod catalog; + +use std::path::PathBuf; +use std::process; + +use clap::{Parser, Subcommand}; + +/// The Cougr CLI — scaffold projects and add pieces from the canonical example catalog. +#[derive(Parser, Debug)] +#[command( + name = "cougr", + version = env!("CARGO_PKG_VERSION"), + about = "The Cougr CLI for Soroban game development", + long_about = None, +)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Add a reusable piece to the current project (shadcn-style: owned source). + /// + /// Run `cougr add --list` to see all available pieces. + Add { + /// Name of the piece to add (e.g. "session-auth", "hidden-hand", + /// "standards/pausable"). Omit to use --list. + piece: Option, + + /// List all available pieces with descriptions and maturity tiers. + #[arg(long, short = 'l')] + list: bool, + + /// Project root directory (defaults to the current working directory). + #[arg(long, short = 'p', default_value = ".")] + project: PathBuf, + }, +} + +fn main() { + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Add { + piece, + list, + project, + } => { + if list { + add::run_list() + } else if let Some(name) = piece { + let root = if project == PathBuf::from(".") { + std::env::current_dir() + .expect("cannot determine current directory") + } else { + project + }; + add::run_add(&name, &root) + } else { + // Neither --list nor a piece name was given — print help. + eprintln!( + "error: specify a piece name or use --list.\n\n\ + Examples:\n\ + \tcougr add --list\n\ + \tcougr add session-auth\n\ + \tcougr add hidden-hand\n\ + \tcougr add standards/pausable" + ); + process::exit(1); + } + } + }; + + if let Err(e) = result { + eprintln!("error: {e}"); + process::exit(1); + } +}