From 3697d94fd7fe543ed3735b42c4d59cc49d63933c Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 19:12:22 -0400 Subject: [PATCH 01/12] docs: add design spec for #131 SSH-default shortcodes Design for making gh:/gl:/cb: shortcodes default to SSH URLs, with --protocol flag and DIECUT_GIT_PROTOCOL env var as escape hatches. Removes the current gh config get git_protocol shell-out detection. --- .../2026-04-11-131-ssh-default-design.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-11-131-ssh-default-design.md diff --git a/docs/superpowers/specs/2026-04-11-131-ssh-default-design.md b/docs/superpowers/specs/2026-04-11-131-ssh-default-design.md new file mode 100644 index 0000000..bd4889c --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-131-ssh-default-design.md @@ -0,0 +1,295 @@ +# Design: SSH-default shortcodes and `--protocol` override + +**Issue:** raiderrobert/diecut#131 +**Status:** Approved +**Date:** 2026-04-11 + +## Problem + +`gh:user/repo` today expands via a runtime shell-out to `gh config get git_protocol -h github.com` (`src/template/source.rs:25-40`). This produces inconsistent behavior: + +- The same shortcode produces different URLs on different machines depending on whether `gh` is installed and how it's configured. +- `gl:` and `cb:` never got this detection — they're hardcoded to HTTPS. +- The detection silently depends on an external CLI being present. + +The original design intent was SSH-first cloning — how git is normally used for authenticated work. In practice, today's default is HTTPS unless `gh` is installed *and* configured for SSH. The behavior is the opposite of intended, and it's a bug. + +## Goals + +- All three built-in shortcodes (`gh:`, `gl:`, `cb:`) default to SSH URLs. +- A single, uniform code path — no GitHub special case. +- No dependency on the `gh` CLI. +- An escape hatch for users stuck on HTTPS (corporate firewalls blocking port 22, GitHub Enterprise with SAML-only HTTPS auth) — via CLI flag and environment variable, no config file yet. +- The `DIECUT_GIT_PROTOCOL` env var is the "set once and forget" story; the `--protocol` flag is the per-invocation override. + +## Non-goals + +- A user config file at `~/.config/diecut/config.toml`. Deferred to #137. When it lands, `git_protocol` in the config file will slot in as a fourth precedence tier (flag > env > config > default). +- Changing how custom `[abbreviations]` work. They're URL templates and already let authors pick their own scheme. Note: this dead code remains untouched. +- Auto-retry with HTTPS if an SSH clone fails. Too magical; git's own error messages are clear. + +## Architecture + +The fix lives entirely in the shortcode expansion pipeline. No changes to `TemplateSource`, `clone.rs`, or the top-level CLI resolution order. Touch points: + +- `src/template/source.rs` — delete `detect_github_protocol()`, rewrite the `ABBREVIATIONS` table, thread a `GitProtocol` parameter through expansion. +- `src/cli.rs` — add `--protocol ` to the `New` subcommand. +- `src/lib.rs` / command handler — resolve CLI flag + env var into a single `GitProtocol` value, pass it down. +- `src/error.rs` — new `InvalidProtocol` error variant for a bad env var value. +- `tests/integration.rs` — new tests for flag, env var, and precedence. +- `README.md`, `docs/src/content/docs/...`, `CHANGELOG.md` — updated. + +No new files, no new modules. + +## New types + +```rust +// in src/template/source.rs (or a new src/template/protocol.rs if we prefer isolation) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +pub enum GitProtocol { + #[default] + Ssh, + Https, +} + +impl std::str::FromStr for GitProtocol { + type Err = DicecutError; + fn from_str(s: &str) -> Result { + match s { + "ssh" => Ok(GitProtocol::Ssh), + "https" => Ok(GitProtocol::Https), + other => Err(DicecutError::InvalidProtocol { + value: other.to_string(), + source: "DIECUT_GIT_PROTOCOL", + }), + } + } +} +``` + +clap's `ValueEnum` derive handles `--protocol ssh|https` directly. The manual `FromStr` is used by the env var parser so both paths share the same validation and error type. + +### Resolution helper + +```rust +fn resolve_git_protocol(cli_flag: Option) -> Result { + if let Some(p) = cli_flag { + return Ok(p); + } + if let Ok(env_value) = std::env::var("DIECUT_GIT_PROTOCOL") { + return env_value.parse(); // returns InvalidProtocol on bad value + } + Ok(GitProtocol::default()) +} +``` + +Called once at CLI entry. Precedence: flag > env > default. + +## Shortcode expansion rewrite + +Replace the current `ABBREVIATIONS: &[(&str, &str, &str)]` table (which embeds `https://` and `.git` fragments) with a host-keyed table and a protocol-aware URL builder: + +```rust +struct VendorShortcode { + prefix: &'static str, + host: &'static str, // "github.com", "gitlab.com", "codeberg.org" +} + +const SHORTCODES: &[VendorShortcode] = &[ + VendorShortcode { prefix: "gh:", host: "github.com" }, + VendorShortcode { prefix: "gl:", host: "gitlab.com" }, + VendorShortcode { prefix: "cb:", host: "codeberg.org" }, +]; + +fn build_url(host: &str, repo: &str, protocol: GitProtocol) -> String { + match protocol { + GitProtocol::Ssh => format!("git@{host}:{repo}.git"), + GitProtocol::Https => format!("https://{host}/{repo}.git"), + } +} +``` + +`expand_abbreviation` becomes a single loop over `SHORTCODES` — no more GitHub special case. The following symbols are deleted entirely: + +- `detect_github_protocol()` +- `build_github_url()` +- The old `ABBREVIATIONS` 3-tuple const + +`split_repo_subpath` stays unchanged — subpath parsing is orthogonal to protocol choice. + +## `resolve_source` cleanup + +Current public API has three variants that form an unwieldy ladder: + +```rust +pub fn resolve_source(template_arg: &str) -> Result +pub fn resolve_source_with_ref(template_arg: &str, git_ref: Option<&str>) -> Result +pub fn resolve_source_full( + template_arg: &str, + git_ref: Option<&str>, + user_abbreviations: Option<&HashMap>, +) -> Result +``` + +`resolve_source` is the only variant called outside tests (`src/lib.rs:47`). The other two are re-exported publicly but unused. + +Collapse all three into a single function taking an options struct: + +```rust +#[derive(Debug, Default)] +pub struct ResolveOptions<'a> { + pub git_ref: Option<&'a str>, + pub protocol: GitProtocol, + pub user_abbreviations: Option<&'a HashMap>, +} + +pub fn resolve_source(arg: &str, opts: ResolveOptions<'_>) -> Result +``` + +The existing `resolve_source(&template)` call in `src/lib.rs:47` becomes `resolve_source(&template, ResolveOptions { protocol, ..Default::default() })`. The `with_ref` and `full` names are removed from the public re-exports in `src/template/mod.rs:7`. + +This is minor API surgery but worth doing now — the project is pre-1.0, the ladder is the only internal consumer, and future tickets (#132 will add `--ref`/`--subpath` for URL parsing, #134 will read user config) will add more parameters. The struct extends cleanly. + +## CLI wiring + +Add to `src/cli.rs` in the `New` subcommand: + +```rust +/// Protocol to use when expanding shortcodes (ssh or https). +/// Defaults to ssh. Override with DIECUT_GIT_PROTOCOL env var. +#[arg(long, value_enum)] +protocol: Option, +``` + +`Option` distinguishes "not provided" (fall through to env var → default) from "explicitly set" (overrides env var). + +In the command handler (wherever `New` is dispatched), at the top: + +```rust +let protocol = resolve_git_protocol(args.protocol)?; +let source = resolve_source(&args.template, ResolveOptions { + protocol, + ..Default::default() +})?; +``` + +No changes to the `List` subcommand — it doesn't expand shortcodes. + +## Error handling + +New error variant in `src/error.rs`: + +```rust +#[error("Invalid git protocol value '{value}' in {source}")] +#[diagnostic(help("Expected 'ssh' or 'https'"))] +InvalidProtocol { + value: String, + source: &'static str, // "DIECUT_GIT_PROTOCOL" or "--protocol" +} +``` + +For the CLI flag, clap's `ValueEnum` derive produces its own error before we ever hit this variant — `InvalidProtocol` is only raised from env var parsing. The flag path never reaches our `FromStr` impl because clap handles it. + +The existing `DicecutError::InvalidAbbreviation` variant and its help text (`src/error.rs:81-83`) are correct and stay unchanged — shortcodes still expand, just with a different protocol default. + +## Dry-run URL observability (new, required for tests) + +Integration testing protocol choice needs the resolved URL to be observable without actually cloning. Today, `diecut new --dry-run` does not print the resolved git URL (confirmed by user). + +Add to the dry-run output: print the resolved `TemplateSource` to stdout before any clone would happen. Concrete format: + +``` +Would clone from: git@github.com:user/repo.git + ref: main + subpath: templates/py +``` + +`ref` and `subpath` lines only appear when their values are `Some`. `Local` sources print `Would use local path: /absolute/path`. Tests match on the URL line only, not the optional indented lines, to keep assertions stable. + +This is a small, useful addition for humans ("what is this tool about to do?") and it's the testability hook integration tests need. + +## Testing strategy + +### Unit tests (`src/template/source.rs`) + +Replace / augment the existing abbreviation tests: + +- `build_url` for each (vendor, protocol) pair — six cases, rstest-parameterized: + - `gh:user/repo` × SSH → `git@github.com:user/repo.git` + - `gh:user/repo` × HTTPS → `https://github.com/user/repo.git` + - `gl:org/project` × SSH → `git@gitlab.com:org/project.git` + - `gl:org/project` × HTTPS → `https://gitlab.com/org/project.git` + - `cb:user/repo` × SSH → `git@codeberg.org:user/repo.git` + - `cb:user/repo` × HTTPS → `https://codeberg.org/user/repo.git` +- Subpath preservation: `gh:user/repo/templates/py` with each protocol produces correct URL and `subpath = Some("templates/py")`. +- Default value test: `GitProtocol::default() == GitProtocol::Ssh`. + +Tests to delete or rewrite: + +- `build_github_url_ssh` / `build_github_url_https` (`source.rs:220-229`) — replaced by the parameterized `build_url` test. +- `expand_github_abbreviation` (`source.rs:232-241`) — currently accepts either SSH or HTTPS; rewrite to strict SSH expectation. +- `resolve_abbreviation_to_git_source` (`source.rs:274-293`) — rewrite to strict SSH expectation. +- `resolve_with_ref_sets_git_ref`, `resolve_with_ref_none_leaves_ref_none` (`source.rs:331+`) — rewrite. +- All other tests that assert `url == "...ssh..." || url == "...https..."` — tighten to strict SSH. + +### Unit tests for `resolve_git_protocol` + +Env-var tests must not race — `std::env::set_var` is process-global. Add `serial_test = "3"` as a dev-dependency and mark env-var tests with `#[serial_test::serial]`. (Neither `serial_test` nor `temp_env` is currently in `Cargo.toml` — confirmed.) Tests: + +- No flag, no env → SSH +- Flag=Some(Https), no env → Https +- No flag, env=`https` → Https +- No flag, env=`ssh` → Ssh +- Flag=Some(Https), env=`ssh` → Https (flag wins) +- Flag=Some(Ssh), env=`https` → Ssh (flag wins) +- No flag, env=`http` → `InvalidProtocol` error +- No flag, env=`` (empty) → `InvalidProtocol` error + +### Integration tests (`tests/integration.rs`) + +Require dry-run URL observability (above) to work. + +- `diecut new gh:some/repo --dry-run` → stdout contains `git@github.com:some/repo.git` +- `diecut new gh:some/repo --protocol https --dry-run` → stdout contains `https://github.com/some/repo.git` +- `DIECUT_GIT_PROTOCOL=https diecut new gh:some/repo --dry-run` → stdout contains `https://github.com/some/repo.git` +- `DIECUT_GIT_PROTOCOL=https diecut new gh:some/repo --protocol ssh --dry-run` → stdout contains `git@github.com:some/repo.git` (flag overrides env) +- `DIECUT_GIT_PROTOCOL=foo diecut new gh:some/repo --dry-run` → exits nonzero with "Invalid git protocol" error + +### Tests to delete + +- `test_resolve_source_rejects_empty_abbreviation_remainder` (`tests/integration.rs:267-271`) stays — empty remainders are still errors, unrelated to this change. +- Any integration test that pokes `resolve_source_full` directly and depends on the old three-function ladder must be updated to use the new `ResolveOptions` struct. + +## Docs and changelog + +- `README.md`: shortcode section — note that `gh:`/`gl:`/`cb:` default to SSH, link to `--protocol` and `DIECUT_GIT_PROTOCOL`. +- `docs/src/content/docs/using-templates/index.mdx`: same. +- `docs/src/content/docs/reference/commands.md`: document the new flag on `diecut new`. +- `CHANGELOG.md`: **Changed (breaking):** Built-in shortcodes now default to SSH. Users on HTTPS pass `--protocol https` or set `DIECUT_GIT_PROTOCOL=https`. The previous `gh config get` detection is removed. + +## Implementation order + +1. Introduce `GitProtocol` enum + `resolve_git_protocol` helper + `InvalidProtocol` error variant +2. Rewrite shortcode expansion to take `GitProtocol`, delete `detect_github_protocol` and friends +3. Collapse `resolve_source*` ladder into `resolve_source(arg, ResolveOptions)` +4. Wire `--protocol` flag into `src/cli.rs` and the `New` command handler +5. Add dry-run URL output +6. Delete/rewrite unit tests to match +7. Add new unit tests for `resolve_git_protocol` +8. Add integration tests for flag, env var, precedence +9. Update docs (README, docs site, CHANGELOG) +10. `cargo fmt --check && cargo clippy -- -D warnings && cargo test` clean + +## Acceptance criteria + +(mirroring #131) + +- [ ] `detect_github_protocol()` removed +- [ ] Built-in shortcodes default to SSH +- [ ] `--protocol` CLI flag on `diecut new` +- [ ] `DIECUT_GIT_PROTOCOL` env var honored +- [ ] Precedence order tested (flag > env > default) +- [ ] Dry-run prints the resolved clone URL +- [ ] Tests cover: SSH default, HTTPS via flag, HTTPS via env var, flag overriding env var, invalid env var value +- [ ] Docs updated (README, docs site) +- [ ] CHANGELOG notes the behavior change From 0ea7536df91e91cbacb2b39933280ab25822eaf6 Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 19:35:06 -0400 Subject: [PATCH 02/12] docs: add implementation plan for #131 SSH-default shortcodes Ten-task plan covering enum + error variant, resolution helper, shortcode expansion rewrite, resolve_source ladder cleanup, CLI flag wiring, dry-run URL output, integration test, and docs updates. --- .../plans/2026-04-11-131-ssh-default.md | 1473 +++++++++++++++++ 1 file changed, 1473 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-11-131-ssh-default.md diff --git a/docs/superpowers/plans/2026-04-11-131-ssh-default.md b/docs/superpowers/plans/2026-04-11-131-ssh-default.md new file mode 100644 index 0000000..de15e15 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-131-ssh-default.md @@ -0,0 +1,1473 @@ +# #131 SSH-default shortcodes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `gh:`/`gl:`/`cb:` shortcodes default to SSH URLs instead of HTTPS, remove the `gh config get git_protocol` shell-out, and add `--protocol` flag + `DIECUT_GIT_PROTOCOL` env var as escape hatches. + +**Architecture:** Introduce a `GitProtocol` enum threaded through a new `ResolveOptions` struct, collapse the existing three-variant `resolve_source*` ladder into one function taking the struct, rewrite shortcode expansion to build URLs from `(host, protocol)` pairs, and plumb the protocol choice from `diecut new` down through `plan_generation`. Dry-run gains a resolved-source print line so CLI wiring is observable. + +**Tech Stack:** Rust 2021, clap 4 (derive + ValueEnum), thiserror/miette for errors, rstest for parameterized tests, serial_test for env-var tests. + +**Spec:** `docs/superpowers/specs/2026-04-11-131-ssh-default-design.md` + +--- + +## File Structure + +Files touched: + +- **Modify** `Cargo.toml` — add `serial_test` dev-dependency +- **Modify** `src/error.rs` — add `InvalidProtocol` error variant +- **Modify** `src/template/source.rs` — add `GitProtocol`, `ResolveOptions`, new expansion logic; delete old +- **Modify** `src/template/mod.rs` — update re-exports (remove `resolve_source_full`, `resolve_source_with_ref`) +- **Modify** `src/cli.rs` — add `--protocol` flag on `New` +- **Modify** `src/main.rs` — thread `protocol` into `commands::new::run` +- **Modify** `src/commands/new.rs` — accept `protocol`, resolve it with env fallback, print dry-run URL +- **Modify** `src/lib.rs` — add `protocol` to `GenerateOptions`, plumb to `resolve_source`, add `resolved_source` to `FullGenerationPlan` +- **Modify** `tests/integration.rs` — update existing tests for new API, add new tests for flag/env/precedence +- **Modify** `README.md`, `docs/src/content/docs/using-templates/index.mdx`, `docs/src/content/docs/reference/commands.md` — docs +- **Modify** `CHANGELOG.md` — note the breaking change + +No new files, no new modules. + +--- + +## Task 1: Add `serial_test` dev-dependency + +**Files:** +- Modify: `Cargo.toml` + +- [ ] **Step 1: Add the dependency line** + +Edit `[dev-dependencies]` in `Cargo.toml` to add `serial_test`: + +```toml +[dev-dependencies] +rstest = "0.23" +criterion = "0.5" +serial_test = "3" +``` + +- [ ] **Step 2: Verify it builds** + +Run: `cargo build --tests` +Expected: clean build, `serial_test` shows up in `cargo tree`. + +- [ ] **Step 3: Commit** + +```bash +git add Cargo.toml Cargo.lock +git commit -m "chore: add serial_test dev-dependency for env-var tests" +``` + +--- + +## Task 2: Add `GitProtocol` enum and `InvalidProtocol` error variant + +**Files:** +- Modify: `src/error.rs` +- Modify: `src/template/source.rs` (add enum at top of file, before existing types) + +- [ ] **Step 1: Add the failing test for `GitProtocol::from_str`** + +Add to the `#[cfg(test)] mod tests` block at the bottom of `src/template/source.rs`: + +```rust +#[test] +fn git_protocol_parses_ssh() { + let p: GitProtocol = "ssh".parse().unwrap(); + assert_eq!(p, GitProtocol::Ssh); +} + +#[test] +fn git_protocol_parses_https() { + let p: GitProtocol = "https".parse().unwrap(); + assert_eq!(p, GitProtocol::Https); +} + +#[test] +fn git_protocol_rejects_unknown() { + let result: Result = "http".parse(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + DicecutError::InvalidProtocol { ref value, .. } if value == "http" + )); +} + +#[test] +fn git_protocol_default_is_ssh() { + assert_eq!(GitProtocol::default(), GitProtocol::Ssh); +} +``` + +- [ ] **Step 2: Run the tests to verify they fail to compile** + +Run: `cargo test -p diecut --lib git_protocol 2>&1 | head -30` +Expected: Compile errors — `cannot find type GitProtocol`, `no variant InvalidProtocol on DicecutError`. + +- [ ] **Step 3: Add `InvalidProtocol` error variant** + +Edit `src/error.rs`. Find the existing `InvalidAbbreviation` variant near line 81 and add this variant **above** it: + +```rust + #[error("Invalid git protocol value '{value}' in {source}")] + #[diagnostic(help("Expected 'ssh' or 'https'"))] + InvalidProtocol { + value: String, + source: &'static str, + }, +``` + +- [ ] **Step 4: Add `GitProtocol` enum to `src/template/source.rs`** + +Add this block at the top of `src/template/source.rs`, right after the existing `use` statements and before the `TemplateSource` enum: + +```rust +/// Protocol used when expanding built-in shortcodes (`gh:`/`gl:`/`cb:`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +pub enum GitProtocol { + #[default] + Ssh, + Https, +} + +impl std::str::FromStr for GitProtocol { + type Err = DicecutError; + fn from_str(s: &str) -> Result { + match s { + "ssh" => Ok(GitProtocol::Ssh), + "https" => Ok(GitProtocol::Https), + other => Err(DicecutError::InvalidProtocol { + value: other.to_string(), + source: "DIECUT_GIT_PROTOCOL", + }), + } + } +} +``` + +Also add `clap` as a dependency usable at library level. It's already in `Cargo.toml` dependencies, so the `clap::ValueEnum` path works. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cargo test -p diecut --lib git_protocol` +Expected: 4 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/error.rs src/template/source.rs +git commit -m "feat(#131): add GitProtocol enum and InvalidProtocol error" +``` + +--- + +## Task 3: Add `resolve_git_protocol` helper with precedence + +**Files:** +- Modify: `src/template/source.rs` + +- [ ] **Step 1: Write the failing tests** + +Add to the test module in `src/template/source.rs`: + +```rust +#[cfg(test)] +mod protocol_resolution_tests { + use super::*; + use serial_test::serial; + + fn clear_env() { + std::env::remove_var("DIECUT_GIT_PROTOCOL"); + } + + #[test] + #[serial] + fn no_flag_no_env_defaults_to_ssh() { + clear_env(); + let result = resolve_git_protocol(None).unwrap(); + assert_eq!(result, GitProtocol::Ssh); + } + + #[test] + #[serial] + fn flag_ssh_returns_ssh() { + clear_env(); + let result = resolve_git_protocol(Some(GitProtocol::Ssh)).unwrap(); + assert_eq!(result, GitProtocol::Ssh); + } + + #[test] + #[serial] + fn flag_https_returns_https() { + clear_env(); + let result = resolve_git_protocol(Some(GitProtocol::Https)).unwrap(); + assert_eq!(result, GitProtocol::Https); + } + + #[test] + #[serial] + fn env_https_returns_https_when_no_flag() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", "https"); + let result = resolve_git_protocol(None).unwrap(); + clear_env(); + assert_eq!(result, GitProtocol::Https); + } + + #[test] + #[serial] + fn env_ssh_returns_ssh_when_no_flag() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", "ssh"); + let result = resolve_git_protocol(None).unwrap(); + clear_env(); + assert_eq!(result, GitProtocol::Ssh); + } + + #[test] + #[serial] + fn flag_overrides_env() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", "ssh"); + let result = resolve_git_protocol(Some(GitProtocol::Https)).unwrap(); + clear_env(); + assert_eq!(result, GitProtocol::Https); + } + + #[test] + #[serial] + fn invalid_env_value_errors() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", "tcp"); + let result = resolve_git_protocol(None); + clear_env(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + DicecutError::InvalidProtocol { ref value, .. } if value == "tcp" + )); + } + + #[test] + #[serial] + fn empty_env_value_errors() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", ""); + let result = resolve_git_protocol(None); + clear_env(); + assert!(result.is_err()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p diecut --lib protocol_resolution 2>&1 | head -20` +Expected: Compile error — `cannot find function resolve_git_protocol`. + +- [ ] **Step 3: Implement `resolve_git_protocol`** + +Add to `src/template/source.rs`, placed after the `FromStr` impl for `GitProtocol`: + +```rust +/// Resolve the git protocol to use for shortcode expansion. +/// +/// Precedence: CLI flag > `DIECUT_GIT_PROTOCOL` env var > built-in default (SSH). +pub fn resolve_git_protocol(cli_flag: Option) -> Result { + if let Some(p) = cli_flag { + return Ok(p); + } + match std::env::var("DIECUT_GIT_PROTOCOL") { + Ok(value) => value.parse(), + Err(_) => Ok(GitProtocol::default()), + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p diecut --lib protocol_resolution` +Expected: 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/template/source.rs +git commit -m "feat(#131): add resolve_git_protocol with flag > env > default precedence" +``` + +--- + +## Task 4: Rewrite shortcode expansion with `GitProtocol` parameter + +**Files:** +- Modify: `src/template/source.rs` + +This task replaces the existing `ABBREVIATIONS` table, `detect_github_protocol`, `build_github_url`, and `expand_abbreviation` function with a uniform protocol-aware implementation. + +- [ ] **Step 1: Write failing tests for the new expansion behavior** + +Add to the test module in `src/template/source.rs`: + +```rust +#[rstest] +#[case("gh:user/repo", GitProtocol::Ssh, "git@github.com:user/repo.git")] +#[case("gh:user/repo", GitProtocol::Https, "https://github.com/user/repo.git")] +#[case("gl:org/project", GitProtocol::Ssh, "git@gitlab.com:org/project.git")] +#[case("gl:org/project", GitProtocol::Https, "https://gitlab.com/org/project.git")] +#[case("cb:user/repo", GitProtocol::Ssh, "git@codeberg.org:user/repo.git")] +#[case("cb:user/repo", GitProtocol::Https, "https://codeberg.org/user/repo.git")] +fn expand_shortcode_per_protocol( + #[case] input: &str, + #[case] protocol: GitProtocol, + #[case] expected_url: &str, +) { + let expanded = expand_abbreviation(input, protocol).unwrap(); + assert_eq!(expanded.url, expected_url); + assert!(expanded.subpath.is_none()); +} + +#[rstest] +#[case("gh:user/repo/templates/py", GitProtocol::Ssh, + "git@github.com:user/repo.git", "templates/py")] +#[case("gl:org/repo/templates/python", GitProtocol::Https, + "https://gitlab.com/org/repo.git", "templates/python")] +#[case("cb:user/repo/sub", GitProtocol::Ssh, + "git@codeberg.org:user/repo.git", "sub")] +fn expand_shortcode_with_subpath( + #[case] input: &str, + #[case] protocol: GitProtocol, + #[case] expected_url: &str, + #[case] expected_subpath: &str, +) { + let expanded = expand_abbreviation(input, protocol).unwrap(); + assert_eq!(expanded.url, expected_url); + assert_eq!(expanded.subpath.as_deref(), Some(expected_subpath)); +} + +#[test] +fn expand_shortcode_empty_remainder_errors() { + let result = expand_abbreviation("gh:", GitProtocol::Ssh); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + DicecutError::InvalidAbbreviation { ref input } if input == "gh:" + )); +} + +#[test] +fn expand_shortcode_unknown_prefix_errors() { + let result = expand_abbreviation("xx:user/repo", GitProtocol::Ssh); + assert!(result.is_err()); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p diecut --lib expand_shortcode 2>&1 | head -30` +Expected: Compile errors — `expand_abbreviation` signature mismatch (takes 1 arg, given 2). + +- [ ] **Step 3: Delete the old expansion code** + +In `src/template/source.rs`, delete these items entirely: + +- The `ABBREVIATIONS` const (around lines 18-23) +- The `detect_github_protocol` function (around lines 25-40) +- The `build_github_url` function (around lines 42-48) +- The existing `expand_abbreviation` function (around lines 82-116) +- The `is_abbreviation` function (around lines 139-143) — it will be replaced + +Keep: +- `split_repo_subpath` (still needed, unchanged) +- `ExpandedSource` struct (still used) +- `expand_user_abbreviation` (custom user abbreviations — still dead code but keep it) + +- [ ] **Step 4: Add the new expansion code** + +Insert in place of the deleted code: + +```rust +/// A built-in shortcode → host mapping. +struct VendorShortcode { + prefix: &'static str, + host: &'static str, +} + +const SHORTCODES: &[VendorShortcode] = &[ + VendorShortcode { prefix: "gh:", host: "github.com" }, + VendorShortcode { prefix: "gl:", host: "gitlab.com" }, + VendorShortcode { prefix: "cb:", host: "codeberg.org" }, +]; + +/// Build a clone URL for a given host, repo, and protocol. +fn build_url(host: &str, repo: &str, protocol: GitProtocol) -> String { + match protocol { + GitProtocol::Ssh => format!("git@{host}:{repo}.git"), + GitProtocol::Https => format!("https://{host}/{repo}.git"), + } +} + +/// Expand a built-in shortcode (`gh:`/`gl:`/`cb:`) into a git URL. +fn expand_abbreviation(input: &str, protocol: GitProtocol) -> Result { + for VendorShortcode { prefix, host } in SHORTCODES { + if let Some(rest) = input.strip_prefix(prefix) { + if rest.is_empty() { + return Err(DicecutError::InvalidAbbreviation { + input: input.to_string(), + }); + } + let (repo, subpath) = split_repo_subpath(rest); + return Ok(ExpandedSource { + url: build_url(host, repo, protocol), + subpath: subpath.map(String::from), + }); + } + } + Err(DicecutError::InvalidAbbreviation { + input: input.to_string(), + }) +} + +/// Check if an input string starts with any built-in shortcode prefix. +fn is_abbreviation(input: &str) -> bool { + SHORTCODES.iter().any(|s| input.starts_with(s.prefix)) +} +``` + +- [ ] **Step 5: Fix the existing callers of `expand_abbreviation`** + +The existing `resolve_source_full` function (around line 164) calls `expand_abbreviation(template_arg)` with one argument. This will break. That function is about to be replaced in Task 5 — for now, make it compile by passing `GitProtocol::default()`: + +```rust +// In resolve_source_full, find the line: +// let expanded = expand_abbreviation(template_arg)?; +// and change to: + let expanded = expand_abbreviation(template_arg, GitProtocol::default())?; +``` + +- [ ] **Step 6: Also update older tests that use the old signature** + +In the test module, the existing tests like `expand_github_abbreviation`, `expand_abbreviation_cases`, `expand_abbreviation_empty_remainder` call `expand_abbreviation(input)` with one argument. **Delete these tests entirely** — they are superseded by the new `expand_shortcode_per_protocol` / `expand_shortcode_with_subpath` / `expand_shortcode_empty_remainder_errors` tests. Specifically, delete: + +- `build_github_url_ssh` (lines ~220-224) +- `build_github_url_https` (lines ~226-229) +- `expand_github_abbreviation` (lines ~231-241) +- `expand_abbreviation_cases` (lines ~243-250) +- `expand_abbreviation_empty_remainder` (lines ~252-258) + +- [ ] **Step 7: Run all unit tests in source.rs** + +Run: `cargo test -p diecut --lib source::tests` +Expected: All tests pass. If old tests like `resolve_abbreviation_to_git_source` still reference an old URL form, they will be updated in Task 5 — for now they may still pass because `resolve_source_full` defaults to SSH which matches the `"... == ... || url == ..."` assertions. + +- [ ] **Step 8: Commit** + +```bash +git add src/template/source.rs +git commit -m "feat(#131): rewrite shortcode expansion with GitProtocol parameter + +- Replace ABBREVIATIONS table with SHORTCODES keyed on host +- Add build_url for protocol-aware URL construction +- Delete detect_github_protocol (gh CLI shell-out) +- Delete build_github_url helper +- expand_abbreviation now takes GitProtocol argument" +``` + +--- + +## Task 5: Collapse `resolve_source*` ladder into `ResolveOptions` + +**Files:** +- Modify: `src/template/source.rs` +- Modify: `src/template/mod.rs` +- Modify: `src/lib.rs` +- Modify: `tests/integration.rs` + +This is the biggest task. It removes the three-variant public API and replaces it with one function taking an options struct. The old names are removed, not deprecated. + +- [ ] **Step 1: Write the failing test for the new API** + +Add to the test module in `src/template/source.rs`: + +```rust +#[test] +fn resolve_source_with_options_local() { + let dir = env!("CARGO_MANIFEST_DIR"); + let opts = ResolveOptions::default(); + let source = resolve_source(dir, opts).unwrap(); + assert!(matches!(source, TemplateSource::Local(_))); +} + +#[test] +fn resolve_source_with_options_shortcode_ssh_default() { + let opts = ResolveOptions::default(); + let source = resolve_source("gh:user/repo", opts).unwrap(); + match source { + TemplateSource::Git { url, git_ref, subpath } => { + assert_eq!(url, "git@github.com:user/repo.git"); + assert!(git_ref.is_none()); + assert!(subpath.is_none()); + } + _ => panic!("expected Git source"), + } +} + +#[test] +fn resolve_source_with_options_shortcode_https() { + let opts = ResolveOptions { + protocol: GitProtocol::Https, + ..Default::default() + }; + let source = resolve_source("gh:user/repo", opts).unwrap(); + match source { + TemplateSource::Git { url, .. } => { + assert_eq!(url, "https://github.com/user/repo.git"); + } + _ => panic!("expected Git source"), + } +} + +#[test] +fn resolve_source_with_options_ref() { + let opts = ResolveOptions { + git_ref: Some("v1.0"), + ..Default::default() + }; + let source = resolve_source("gh:user/repo", opts).unwrap(); + match source { + TemplateSource::Git { git_ref, .. } => { + assert_eq!(git_ref.as_deref(), Some("v1.0")); + } + _ => panic!("expected Git source"), + } +} + +#[test] +fn resolve_source_with_options_shortcode_subpath() { + let opts = ResolveOptions::default(); + let source = resolve_source("gh:user/repo/my-template", opts).unwrap(); + match source { + TemplateSource::Git { url, subpath, .. } => { + assert_eq!(url, "git@github.com:user/repo.git"); + assert_eq!(subpath.as_deref(), Some("my-template")); + } + _ => panic!("expected Git source"), + } +} + +#[test] +fn resolve_source_with_options_plain_url() { + let opts = ResolveOptions::default(); + let source = resolve_source("https://example.com/repo.git", opts).unwrap(); + match source { + TemplateSource::Git { url, subpath, .. } => { + assert_eq!(url, "https://example.com/repo.git"); + assert!(subpath.is_none()); + } + _ => panic!("expected Git source"), + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail to compile** + +Run: `cargo test -p diecut --lib resolve_source_with_options 2>&1 | head -30` +Expected: Compile errors — `ResolveOptions` not defined, `resolve_source` has wrong signature. + +- [ ] **Step 3: Define `ResolveOptions`** + +Add to `src/template/source.rs`, placed after `TemplateSource` and the `GitProtocol` items: + +```rust +/// Options passed to [`resolve_source`]. +#[derive(Debug, Default)] +pub struct ResolveOptions<'a> { + /// Optional git ref (branch, tag, or commit) to check out. + pub git_ref: Option<&'a str>, + /// Protocol to use when expanding built-in shortcodes. + pub protocol: GitProtocol, + /// Optional user-defined abbreviations to consult before built-in shortcodes. + pub user_abbreviations: Option<&'a HashMap>, +} +``` + +- [ ] **Step 4: Rewrite `resolve_source` to take `ResolveOptions`** + +Delete the existing three functions (`resolve_source`, `resolve_source_with_ref`, `resolve_source_full`) and replace with a single function: + +```rust +/// Resolve a template argument to a [`TemplateSource`]. +/// +/// Handles user abbreviations, built-in shortcodes, explicit git URLs, and local paths. +pub fn resolve_source(template_arg: &str, opts: ResolveOptions<'_>) -> Result { + if let Some(abbrevs) = opts.user_abbreviations { + if let Some(result) = expand_user_abbreviation(template_arg, abbrevs) { + let expanded = result?; + return Ok(TemplateSource::Git { + url: expanded.url, + git_ref: opts.git_ref.map(String::from), + subpath: expanded.subpath, + }); + } + } + + if is_abbreviation(template_arg) { + let expanded = expand_abbreviation(template_arg, opts.protocol)?; + return Ok(TemplateSource::Git { + url: expanded.url, + git_ref: opts.git_ref.map(String::from), + subpath: expanded.subpath, + }); + } + + if is_git_url(template_arg) { + return Ok(TemplateSource::Git { + url: template_arg.to_string(), + git_ref: opts.git_ref.map(String::from), + subpath: None, + }); + } + + let path = Path::new(template_arg); + if path.exists() { + Ok(TemplateSource::Local(path.canonicalize().map_err(|e| { + DicecutError::Io { + context: format!("resolving path {}", path.display()), + source: e, + } + })?)) + } else { + Err(DicecutError::ConfigNotFound { + path: path.to_path_buf(), + }) + } +} +``` + +- [ ] **Step 5: Update `src/template/mod.rs` re-exports** + +Change `src/template/mod.rs` line 7 from: + +```rust +pub use source::{resolve_source, resolve_source_full, resolve_source_with_ref, TemplateSource}; +``` + +to: + +```rust +pub use source::{resolve_source, GitProtocol, ResolveOptions, TemplateSource}; +``` + +Also add `resolve_git_protocol` to the re-exports since `commands/new.rs` will need it: + +```rust +pub use source::{ + resolve_git_protocol, resolve_source, GitProtocol, ResolveOptions, TemplateSource, +}; +``` + +- [ ] **Step 6: Update `src/lib.rs` call site** + +Edit `src/lib.rs:21` — the existing use statement: + +```rust +use crate::template::{get_or_clone, resolve_source, TemplateSource}; +``` + +becomes: + +```rust +use crate::template::{ + get_or_clone, resolve_source, GitProtocol, ResolveOptions, TemplateSource, +}; +``` + +Edit `src/lib.rs:47` — the existing call: + +```rust + let source = resolve_source(&options.template)?; +``` + +becomes: + +```rust + let source = resolve_source( + &options.template, + ResolveOptions { + protocol: options.protocol, + ..Default::default() + }, + )?; +``` + +- [ ] **Step 7: Add `protocol` field to `GenerateOptions`** + +Edit the `GenerateOptions` struct in `src/lib.rs:23-30` to add a `protocol` field: + +```rust +pub struct GenerateOptions { + pub template: String, + pub output: Option, + pub data: Vec<(String, String)>, + pub defaults: bool, + pub overwrite: bool, + pub no_hooks: bool, + pub protocol: GitProtocol, +} +``` + +- [ ] **Step 8: Delete obsolete tests in `src/template/source.rs`** + +Remove these old tests from the test module — they were written against the three-variant API and are superseded by the new `resolve_source_with_options_*` tests: + +- `resolve_abbreviation_to_git_source` (lines ~274-293) +- `resolve_explicit_https_to_git_source` (lines ~295-310) +- `resolve_git_ssh_to_git_source` (lines ~312-327) +- `resolve_with_ref_sets_git_ref` (lines ~331-345) +- `resolve_with_ref_none_leaves_ref_none` (lines ~347-361) +- `resolve_existing_local_path` (lines ~365-376) — replaced by `resolve_source_with_options_local` +- `resolve_nonexistent_local_path_errors` (lines ~378-382) +- `user_abbreviation_*` tests (lines ~386-485) — user abbreviations are dead code, tests go away with the API they tested +- `resolve_abbreviation_with_subpath` (lines ~504-523) — replaced by `resolve_source_with_options_shortcode_subpath` +- `resolve_abbreviation_with_nested_subpath` (lines ~525-535) +- `user_abbreviation_with_subpath` (lines ~537-553) + +Keep: +- `git_protocol_*` tests (Task 2) +- `protocol_resolution_tests` module (Task 3) +- `expand_shortcode_*` tests (Task 4) +- `resolve_source_with_options_*` tests (Task 5, Step 1) +- `is_git_url_cases` (vendor-agnostic, still valid) +- `split_repo_subpath_cases` (still valid, helper is unchanged) + +Add back `resolve_nonexistent_local_path_errors` using new API: + +```rust +#[test] +fn resolve_source_nonexistent_local_path_errors() { + let opts = ResolveOptions::default(); + let result = resolve_source("/nonexistent/path/that/does/not/exist", opts); + assert!(result.is_err()); +} +``` + +- [ ] **Step 9: Update `tests/integration.rs`** + +The integration test file has this import at line 8: + +```rust +use diecut::template::source::{resolve_source, resolve_source_full}; +``` + +Change to: + +```rust +use diecut::template::source::{resolve_source, ResolveOptions}; +``` + +Find `test_resolve_source_user_abbreviation_empty_remainder` around line 275 which calls `resolve_source_full("co:", None, Some(&abbrevs))` — delete this test entirely (user abbreviations are dead code, the test just exercises the old API). + +Find `test_resolve_source_rejects_empty_abbreviation_remainder` around line 267 which calls `resolve_source(input)` with one arg. Update to: + +```rust +#[rstest] +#[case("gh:")] +#[case("gl:")] +#[case("cb:")] +fn test_resolve_source_rejects_empty_abbreviation_remainder(#[case] input: &str) { + assert!(resolve_source(input, ResolveOptions::default()).is_err()); +} +``` + +- [ ] **Step 10: Compile and run all tests** + +Run: `cargo build` +Expected: clean build. + +Run: `cargo test` +Expected: all tests pass. If any still fail, they are either referencing removed functions (fix to use new API) or asserting the old HTTPS-default behavior (update to expect SSH). + +- [ ] **Step 11: Commit** + +```bash +git add src/template/source.rs src/template/mod.rs src/lib.rs tests/integration.rs +git commit -m "refactor(#131): collapse resolve_source ladder into ResolveOptions + +- Replace resolve_source / resolve_source_with_ref / resolve_source_full + three-variant API with a single resolve_source(arg, ResolveOptions) +- Thread GitProtocol through the options struct +- Update src/lib.rs call site to use new API +- Add protocol field to GenerateOptions" +``` + +--- + +## Task 6: Add `--protocol` flag to CLI + +**Files:** +- Modify: `src/cli.rs` +- Modify: `src/main.rs` +- Modify: `src/commands/new.rs` + +- [ ] **Step 1: Write a clap parse test** + +Add to `src/cli.rs` at the bottom: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use diecut::template::GitProtocol; + + #[test] + fn parses_new_without_protocol() { + let cli = Cli::parse_from(["diecut", "new", "gh:user/repo"]); + if let Commands::New { protocol, .. } = cli.command { + assert!(protocol.is_none()); + } else { + panic!("expected New"); + } + } + + #[test] + fn parses_new_with_protocol_ssh() { + let cli = Cli::parse_from(["diecut", "new", "gh:user/repo", "--protocol", "ssh"]); + if let Commands::New { protocol, .. } = cli.command { + assert_eq!(protocol, Some(GitProtocol::Ssh)); + } else { + panic!("expected New"); + } + } + + #[test] + fn parses_new_with_protocol_https() { + let cli = Cli::parse_from(["diecut", "new", "gh:user/repo", "--protocol", "https"]); + if let Commands::New { protocol, .. } = cli.command { + assert_eq!(protocol, Some(GitProtocol::Https)); + } else { + panic!("expected New"); + } + } + + #[test] + fn rejects_invalid_protocol() { + let result = Cli::try_parse_from(["diecut", "new", "gh:user/repo", "--protocol", "ftp"]); + assert!(result.is_err()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p diecut parses_new 2>&1 | head -20` +Expected: Compile errors — `protocol` field doesn't exist on `New`, `GitProtocol` import path wrong. + +- [ ] **Step 3: Add `--protocol` flag to `Commands::New` in `src/cli.rs`** + +At the top of `src/cli.rs`, add the import: + +```rust +use clap::{Parser, Subcommand}; +use diecut::template::GitProtocol; +``` + +In the `New` variant (around line 17-48), add the new field: + +```rust + /// Generate a new project from a template + New { + /// Template source (local path, or in future: git URL / abbreviation) + template: String, + + /// Output directory + #[arg(short, long)] + output: Option, + + /// Set variable values (can be repeated: -d key=value) + #[arg(short, long = "data", value_name = "KEY=VALUE")] + data: Vec, + + /// Use default values without prompting + #[arg(long)] + defaults: bool, + + /// Overwrite output directory if it exists + #[arg(long)] + overwrite: bool, + + /// Skip running hooks + #[arg(long)] + no_hooks: bool, + + /// Show what would be generated without writing files + #[arg(long)] + dry_run: bool, + + /// Show file contents (with --dry-run) or detailed output + #[arg(short, long)] + verbose: bool, + + /// Protocol for expanding shortcodes (ssh or https). + /// Defaults to ssh. Override with DIECUT_GIT_PROTOCOL env var. + #[arg(long, value_enum)] + protocol: Option, + }, +``` + +- [ ] **Step 4: Thread `protocol` through `src/main.rs`** + +Edit `src/main.rs`: + +```rust +fn main() -> miette::Result<()> { + match Cli::parse().command { + Commands::New { + template, + output, + data, + defaults, + overwrite, + no_hooks, + dry_run, + verbose, + protocol, + } => commands::new::run( + template, output, data, defaults, overwrite, no_hooks, dry_run, verbose, protocol, + ), + Commands::List => commands::list::run(), + } +} +``` + +- [ ] **Step 5: Update `src/commands/new.rs` to accept and thread `protocol`** + +Edit `src/commands/new.rs`: + +```rust +use console::style; +use diecut::template::{resolve_git_protocol, GitProtocol}; +use diecut::GenerateOptions; +use miette::Result; + +#[allow(clippy::too_many_arguments)] +pub fn run( + template: String, + output: Option, + data: Vec, + defaults: bool, + overwrite: bool, + no_hooks: bool, + dry_run: bool, + verbose: bool, + protocol: Option, +) -> Result<()> { + let data_pairs: Vec<(String, String)> = data + .into_iter() + .filter_map(|kv| { + let mut parts = kv.splitn(2, '='); + let key = parts.next()?.to_string(); + let value = parts.next()?.to_string(); + Some((key, value)) + }) + .collect(); + + let resolved_protocol = resolve_git_protocol(protocol)?; + + let options = GenerateOptions { + template, + output, + data: data_pairs, + defaults, + overwrite, + no_hooks, + protocol: resolved_protocol, + }; + + // ... rest unchanged for now (Task 7 adds dry-run URL print) + if dry_run { + let plan = diecut::plan_generation(options)?; + // ... existing dry-run output +``` + +Keep the rest of the function body as it is (Task 7 will add the URL print). + +- [ ] **Step 6: Run all tests** + +Run: `cargo test` +Expected: All tests pass. clap parse tests in `src/cli.rs` pass. No regressions. + +- [ ] **Step 7: Commit** + +```bash +git add src/cli.rs src/main.rs src/commands/new.rs +git commit -m "feat(#131): add --protocol flag to diecut new + +Threads GitProtocol from CLI → commands::new::run → GenerateOptions, +resolving against DIECUT_GIT_PROTOCOL env var when not explicitly passed." +``` + +--- + +## Task 7: Add dry-run resolved-URL output + +**Files:** +- Modify: `src/commands/new.rs` +- Modify: `src/lib.rs` +- Modify: `src/template/source.rs` + +The dry-run output should print the resolved template source *before* attempting any clone, so that: + +1. Users see what would happen even when the clone fails. +2. Integration tests can observe the resolved URL by running against a local path (no network). + +We will resolve the source in `commands/new.rs` before calling `plan_generation`, print it, then let `plan_generation` re-resolve internally (double work is fine — resolution is cheap and doesn't touch the network). + +- [ ] **Step 1: Write a failing unit test for the print helper** + +Add to `src/template/source.rs` test module: + +```rust +#[test] +fn format_resolved_source_git_url_only() { + let source = TemplateSource::Git { + url: "git@github.com:user/repo.git".to_string(), + git_ref: None, + subpath: None, + }; + let s = format_resolved_source(&source); + assert!(s.contains("Would clone from: git@github.com:user/repo.git")); + assert!(!s.contains("ref:")); + assert!(!s.contains("subpath:")); +} + +#[test] +fn format_resolved_source_git_full() { + let source = TemplateSource::Git { + url: "https://github.com/user/repo.git".to_string(), + git_ref: Some("main".to_string()), + subpath: Some("templates/py".to_string()), + }; + let s = format_resolved_source(&source); + assert!(s.contains("Would clone from: https://github.com/user/repo.git")); + assert!(s.contains("ref: main")); + assert!(s.contains("subpath: templates/py")); +} + +#[test] +fn format_resolved_source_local() { + let source = TemplateSource::Local(std::path::PathBuf::from("/tmp/templates/foo")); + let s = format_resolved_source(&source); + assert!(s.contains("Would use local path: /tmp/templates/foo")); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p diecut --lib format_resolved_source 2>&1 | head -20` +Expected: Compile errors — `format_resolved_source` not defined. + +- [ ] **Step 3: Implement `format_resolved_source`** + +Add to `src/template/source.rs`: + +```rust +/// Format a [`TemplateSource`] for human-readable dry-run output. +pub fn format_resolved_source(source: &TemplateSource) -> String { + match source { + TemplateSource::Local(path) => { + format!("Would use local path: {}", path.display()) + } + TemplateSource::Git { url, git_ref, subpath } => { + let mut out = format!("Would clone from: {url}"); + if let Some(r) = git_ref { + out.push_str(&format!("\n ref: {r}")); + } + if let Some(sp) = subpath { + out.push_str(&format!("\n subpath: {sp}")); + } + out + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p diecut --lib format_resolved_source` +Expected: 3 tests pass. + +- [ ] **Step 5: Re-export `format_resolved_source` from `src/template/mod.rs`** + +Edit `src/template/mod.rs` line 7 to include the new function: + +```rust +pub use source::{ + format_resolved_source, resolve_git_protocol, resolve_source, GitProtocol, ResolveOptions, + TemplateSource, +}; +``` + +- [ ] **Step 6: Update `src/commands/new.rs` to print before plan_generation** + +Modify the `if dry_run {` block to resolve and print the source before calling `plan_generation`: + +```rust +use console::style; +use diecut::template::{ + format_resolved_source, resolve_git_protocol, resolve_source, GitProtocol, ResolveOptions, +}; +use diecut::GenerateOptions; +use miette::Result; + +#[allow(clippy::too_many_arguments)] +pub fn run( + template: String, + output: Option, + data: Vec, + defaults: bool, + overwrite: bool, + no_hooks: bool, + dry_run: bool, + verbose: bool, + protocol: Option, +) -> Result<()> { + let data_pairs: Vec<(String, String)> = data + .into_iter() + .filter_map(|kv| { + let mut parts = kv.splitn(2, '='); + let key = parts.next()?.to_string(); + let value = parts.next()?.to_string(); + Some((key, value)) + }) + .collect(); + + let resolved_protocol = resolve_git_protocol(protocol)?; + + if dry_run { + // Resolve the source first so the URL is visible even if clone fails. + let source = resolve_source( + &template, + ResolveOptions { + protocol: resolved_protocol, + ..Default::default() + }, + )?; + println!("{}", format_resolved_source(&source)); + } + + let options = GenerateOptions { + template, + output, + data: data_pairs, + defaults, + overwrite, + no_hooks, + protocol: resolved_protocol, + }; + + if dry_run { + let plan = diecut::plan_generation(options)?; + + let rendered_count = plan.render_plan.files.iter().filter(|f| !f.is_copy).count(); + let copied_count = plan.render_plan.files.iter().filter(|f| f.is_copy).count(); + + println!( + "\n{} Dry run \u{2014} files that would be generated in {}:", + style("==>").cyan().bold(), + style(plan.output_dir.display()).cyan() + ); + + for file in &plan.render_plan.files { + let action = if file.is_copy { "copy " } else { "create" }; + println!( + " {} {}", + style(action).green(), + file.relative_path.display() + ); + + if verbose { + println!(" {}", style("──────").dim()); + if file.is_copy { + println!( + " {}", + style(format!("[binary file, {} bytes]", file.content.len())).dim() + ); + } else { + let content = String::from_utf8_lossy(&file.content); + for line in content.lines() { + println!(" {}", line); + } + } + println!(" {}", style("──────").dim()); + println!(); + } + } + + println!( + "\nSummary: {} rendered, {} copied", + rendered_count, copied_count + ); + + println!( + "\n{} Dry run \u{2014} no files written.", + style("\u{2139}").blue().bold() + ); + } else { + diecut::generate(options)?; + } + + Ok(()) +} +``` + +- [ ] **Step 7: Run full test suite** + +Run: `cargo test` +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/template/source.rs src/template/mod.rs src/commands/new.rs +git commit -m "feat(#131): print resolved source URL in dry-run output + +Adds format_resolved_source helper and prints the resolved +TemplateSource before plan_generation in dry-run mode, so users +(and tests) can observe the clone URL before any network work." +``` + +--- + +## Task 8: Add end-to-end integration test for dry-run URL output + +**Files:** +- Modify: `tests/integration.rs` + +This verifies the CLI → resolve_source → dry-run print pipeline against a **local path** (no network required). For Git URLs, the unit tests on `format_resolved_source` + `resolve_source` + `resolve_git_protocol` collectively cover the behavior, because the code paths compose cleanly. + +The existing `tests/integration.rs` uses library calls, not the CLI binary. This new test runs the actual binary using `env!("CARGO_BIN_EXE_diecut")` — a cargo-provided path — so no new dev-dependencies are required. `tempfile` is already a regular dependency in `Cargo.toml:33`. + +- [ ] **Step 1: Write the failing test** + +Add near the bottom of `tests/integration.rs`: + +```rust +#[test] +fn dry_run_prints_resolved_local_source() { + use std::process::Command; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let template_dir = tmp.path().join("my-template"); + std::fs::create_dir_all(&template_dir).unwrap(); + std::fs::write( + template_dir.join("diecut.toml"), + r#"[[variables]] +name = "project_name" +prompt = "Project name" +default = "demo" +"#, + ) + .unwrap(); + std::fs::create_dir_all(template_dir.join("{{ project_name }}")).unwrap(); + std::fs::write( + template_dir.join("{{ project_name }}").join("README.md"), + "# {{ project_name }}\n", + ) + .unwrap(); + + let output_dir = tmp.path().join("out"); + + let output = Command::new(env!("CARGO_BIN_EXE_diecut")) + .arg("new") + .arg(template_dir.to_str().unwrap()) + .arg("--dry-run") + .arg("--defaults") + .arg("-o") + .arg(output_dir.to_str().unwrap()) + .output() + .expect("failed to run diecut binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "diecut new exited with failure.\nstdout: {stdout}\nstderr: {stderr}" + ); + assert!( + stdout.contains("Would use local path:"), + "expected 'Would use local path:' in stdout, got: {stdout}" + ); +} +``` + +- [ ] **Step 2: Run the test** + +Run: `cargo test --test integration dry_run_prints_resolved_local_source` +Expected: Passes. Task 7 already implemented the feature; this test verifies the wiring end-to-end. + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration.rs +git commit -m "test(#131): integration test for dry-run resolved source output" +``` + +--- + +## Task 9: Update README, docs site, and CHANGELOG + +**Files:** +- Modify: `README.md` +- Modify: `docs/src/content/docs/using-templates/index.mdx` +- Modify: `docs/src/content/docs/reference/commands.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Find shortcode references in README** + +Run: `grep -n "gh:" README.md` + +For each occurrence where the README shows a shortcode example (e.g., `diecut new gh:user/repo`), add a note nearby (in the nearest section or at the end of the shortcode section) that describes the new default: + +```markdown +### Protocol used for shortcodes + +Built-in shortcodes (`gh:`, `gl:`, `cb:`) resolve to SSH URLs by default — +e.g. `gh:user/repo` becomes `git@github.com:user/repo.git`. If you need +HTTPS (for example, if your network blocks outbound SSH), pass +`--protocol https` to `diecut new` or set `DIECUT_GIT_PROTOCOL=https` +in your shell environment. +``` + +- [ ] **Step 2: Update `docs/src/content/docs/using-templates/index.mdx`** + +Run: `grep -n "gh:" docs/src/content/docs/using-templates/index.mdx` + +Add the same "protocol used for shortcodes" section in a prose-appropriate spot for the docs site. Match the surrounding MDX style (frontmatter, headings). + +- [ ] **Step 3: Update `docs/src/content/docs/reference/commands.md`** + +Find the `diecut new` command documentation. Add a row (or bullet) documenting the new `--protocol` flag: + +```markdown +#### `--protocol ` + +Protocol to use when expanding built-in shortcodes (`gh:`, `gl:`, `cb:`). +Defaults to `ssh`. Overrides the `DIECUT_GIT_PROTOCOL` environment +variable. Set to `https` if your network blocks SSH (corporate firewalls, +GitHub Enterprise with SAML-only auth). +``` + +And mention the env var in a sibling section on "environment variables": + +```markdown +#### `DIECUT_GIT_PROTOCOL` + +Persistent default for shortcode expansion protocol. Accepts `ssh` +(default) or `https`. Overridden per-invocation by `--protocol`. +``` + +- [ ] **Step 4: Update `CHANGELOG.md`** + +Find the `[Unreleased]` section at the top (create it if missing, respecting the existing keepachangelog-style format). Add: + +```markdown +## [Unreleased] + +### Changed + +- **Breaking:** Built-in shortcodes (`gh:`, `gl:`, `cb:`) now default to + SSH URLs instead of HTTPS. The `gh config get git_protocol` detection + has been removed — behavior is now consistent across all three vendors + and independent of the `gh` CLI. + +### Added + +- `--protocol ` flag on `diecut new` for per-invocation + override of shortcode protocol. +- `DIECUT_GIT_PROTOCOL` environment variable for persistent shortcode + protocol preference (useful for corporate firewalls that block SSH). +- Dry-run output now prints the resolved clone URL (or local path) + before any clone is attempted. + +### Migration + +If you were relying on HTTPS URLs from shortcodes (the previous default +when `gh` was not installed or not configured for SSH), set +`DIECUT_GIT_PROTOCOL=https` in your shell environment, or pass +`--protocol https` on each invocation. +``` + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/src/content/docs/using-templates/index.mdx docs/src/content/docs/reference/commands.md CHANGELOG.md +git commit -m "docs(#131): document SSH default and --protocol flag" +``` + +--- + +## Task 10: Pre-commit validation + +**Files:** none + +- [ ] **Step 1: Run formatter check** + +Run: `cargo fmt --check` +Expected: clean. If it complains, run `cargo fmt` to fix and re-stage. + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy -- -D warnings` +Expected: clean. Fix any warnings inline and re-commit. + +- [ ] **Step 3: Run full test suite** + +Run: `cargo test` +Expected: all tests pass. + +- [ ] **Step 4: Confirm issue acceptance criteria** + +Open `docs/superpowers/specs/2026-04-11-131-ssh-default-design.md` and walk down the "Acceptance criteria" section. Tick each box mentally: + +- [ ] `detect_github_protocol()` removed +- [ ] Built-in shortcodes default to SSH +- [ ] `--protocol` CLI flag on `diecut new` +- [ ] `DIECUT_GIT_PROTOCOL` env var honored +- [ ] Precedence order tested (flag > env > default) +- [ ] Dry-run prints the resolved clone URL +- [ ] Tests cover: SSH default, HTTPS via flag, HTTPS via env var, flag overriding env var, invalid env var value +- [ ] Docs updated (README, docs site) +- [ ] CHANGELOG notes the behavior change + +- [ ] **Step 5: Push branch and open PR** + +```bash +git push -u origin +gh pr create --title "fix(#131): SSH-default shortcodes with --protocol override" --body "$(cat <<'EOF' +## Summary + +- Built-in `gh:`/`gl:`/`cb:` shortcodes now default to SSH URLs +- Removes `gh config get git_protocol` shell-out detection +- Adds `--protocol ` CLI flag and `DIECUT_GIT_PROTOCOL` env var +- Dry-run now prints the resolved clone URL before any network operation + +Closes #131. + +## Test plan + +- [x] `cargo test` — all unit and integration tests pass +- [x] `cargo fmt --check` — clean +- [x] `cargo clippy -- -D warnings` — clean +- [x] Manual: `diecut new gh:user/repo --dry-run` shows SSH URL +- [x] Manual: `diecut new gh:user/repo --protocol https --dry-run` shows HTTPS URL +- [x] Manual: `DIECUT_GIT_PROTOCOL=https diecut new gh:user/repo --dry-run` shows HTTPS URL +- [x] Manual: `DIECUT_GIT_PROTOCOL=tcp diecut new gh:user/repo --dry-run` exits with `Invalid git protocol` error +EOF +)" +``` From e7f9d6b344ba7202f7081212317a2f41c6c66bb2 Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 19:56:43 -0400 Subject: [PATCH 03/12] chore: add serial_test dev-dependency for env-var tests --- Cargo.lock | 42 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + 2 files changed, 43 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ba4d69f..ed7c55b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,6 +461,7 @@ dependencies = [ "rstest", "serde", "serde_json", + "serial_test", "sha2", "tempfile", "tera", @@ -1491,12 +1492,27 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "semver" version = "1.0.27" @@ -1555,6 +1571,32 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index 4f730dd..e8297e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ indexmap = { version = "2.11.4", features = ["serde"] } [dev-dependencies] rstest = "0.23" criterion = "0.5" +serial_test = "3" [[bench]] name = "benchmarks" From f8653c3b3a39b373b5a294a4fe0bdcf267f836e8 Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 20:00:11 -0400 Subject: [PATCH 04/12] feat(#131): add GitProtocol enum and InvalidProtocol error Introduces GitProtocol (Ssh/Https) with FromStr, Default, and clap::ValueEnum impls, and DicecutError::InvalidProtocol for invalid protocol string inputs. --- src/error.rs | 7 ++++++ src/template/source.rs | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/error.rs b/src/error.rs index 834b7d2..5430e40 100644 --- a/src/error.rs +++ b/src/error.rs @@ -78,6 +78,13 @@ pub enum DicecutError { source: tera::Error, }, + #[error("Invalid git protocol value '{value}' in {config_key}")] + #[diagnostic(help("Expected 'ssh' or 'https'"))] + InvalidProtocol { + value: String, + config_key: &'static str, + }, + #[error("Invalid template abbreviation: {input}")] #[diagnostic(help("Supported abbreviations: gh:user/repo, gl:user/repo, cb:user/repo"))] InvalidAbbreviation { input: String }, diff --git a/src/template/source.rs b/src/template/source.rs index 0801692..1f72a6f 100644 --- a/src/template/source.rs +++ b/src/template/source.rs @@ -4,6 +4,28 @@ use std::process::Command; use crate::error::{DicecutError, Result}; +/// Protocol used when expanding built-in shortcodes (`gh:`/`gl:`/`cb:`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +pub enum GitProtocol { + #[default] + Ssh, + Https, +} + +impl std::str::FromStr for GitProtocol { + type Err = DicecutError; + fn from_str(s: &str) -> Result { + match s { + "ssh" => Ok(GitProtocol::Ssh), + "https" => Ok(GitProtocol::Https), + other => Err(DicecutError::InvalidProtocol { + value: other.to_string(), + config_key: "DIECUT_GIT_PROTOCOL", + }), + } + } +} + /// Resolved template source. pub enum TemplateSource { Local(PathBuf), @@ -551,4 +573,34 @@ mod tests { _ => panic!("expected Git source"), } } + + // ── GitProtocol ──────────────────────────────────────────────────── + + #[test] + fn git_protocol_parses_ssh() { + let p: GitProtocol = "ssh".parse().unwrap(); + assert_eq!(p, GitProtocol::Ssh); + } + + #[test] + fn git_protocol_parses_https() { + let p: GitProtocol = "https".parse().unwrap(); + assert_eq!(p, GitProtocol::Https); + } + + #[test] + fn git_protocol_rejects_unknown() { + let result: Result = "http".parse(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!( + err, + DicecutError::InvalidProtocol { ref value, .. } if value == "http" + )); + } + + #[test] + fn git_protocol_default_is_ssh() { + assert_eq!(GitProtocol::default(), GitProtocol::Ssh); + } } From 79666fb5fd007453a307b75809f46b56acd45c92 Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 20:03:22 -0400 Subject: [PATCH 05/12] feat(#131): add resolve_git_protocol with flag > env > default precedence --- src/template/source.rs | 101 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src/template/source.rs b/src/template/source.rs index 1f72a6f..8bf4949 100644 --- a/src/template/source.rs +++ b/src/template/source.rs @@ -26,6 +26,19 @@ impl std::str::FromStr for GitProtocol { } } +/// Resolve the git protocol to use for shortcode expansion. +/// +/// Precedence: CLI flag > `DIECUT_GIT_PROTOCOL` env var > built-in default (SSH). +pub fn resolve_git_protocol(cli_flag: Option) -> Result { + if let Some(p) = cli_flag { + return Ok(p); + } + match std::env::var("DIECUT_GIT_PROTOCOL") { + Ok(value) => value.parse(), + Err(_) => Ok(GitProtocol::default()), + } +} + /// Resolved template source. pub enum TemplateSource { Local(PathBuf), @@ -604,3 +617,91 @@ mod tests { assert_eq!(GitProtocol::default(), GitProtocol::Ssh); } } + +#[cfg(test)] +mod protocol_resolution_tests { + use super::*; + use serial_test::serial; + + fn clear_env() { + std::env::remove_var("DIECUT_GIT_PROTOCOL"); + } + + #[test] + #[serial] + fn no_flag_no_env_defaults_to_ssh() { + clear_env(); + let result = resolve_git_protocol(None).unwrap(); + assert_eq!(result, GitProtocol::Ssh); + } + + #[test] + #[serial] + fn flag_ssh_returns_ssh() { + clear_env(); + let result = resolve_git_protocol(Some(GitProtocol::Ssh)).unwrap(); + assert_eq!(result, GitProtocol::Ssh); + } + + #[test] + #[serial] + fn flag_https_returns_https() { + clear_env(); + let result = resolve_git_protocol(Some(GitProtocol::Https)).unwrap(); + assert_eq!(result, GitProtocol::Https); + } + + #[test] + #[serial] + fn env_https_returns_https_when_no_flag() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", "https"); + let result = resolve_git_protocol(None).unwrap(); + clear_env(); + assert_eq!(result, GitProtocol::Https); + } + + #[test] + #[serial] + fn env_ssh_returns_ssh_when_no_flag() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", "ssh"); + let result = resolve_git_protocol(None).unwrap(); + clear_env(); + assert_eq!(result, GitProtocol::Ssh); + } + + #[test] + #[serial] + fn flag_overrides_env() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", "ssh"); + let result = resolve_git_protocol(Some(GitProtocol::Https)).unwrap(); + clear_env(); + assert_eq!(result, GitProtocol::Https); + } + + #[test] + #[serial] + fn invalid_env_value_errors() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", "tcp"); + let result = resolve_git_protocol(None); + clear_env(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + DicecutError::InvalidProtocol { ref value, .. } if value == "tcp" + )); + } + + #[test] + #[serial] + fn empty_env_value_errors() { + clear_env(); + std::env::set_var("DIECUT_GIT_PROTOCOL", ""); + let result = resolve_git_protocol(None); + clear_env(); + assert!(result.is_err()); + } +} From 261e178936117b42ac98fc70b02ffaa303f80f92 Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 20:07:49 -0400 Subject: [PATCH 06/12] feat(#131): rewrite shortcode expansion with GitProtocol parameter - Replace ABBREVIATIONS table with SHORTCODES keyed on host - Add build_url for protocol-aware URL construction - Delete detect_github_protocol (gh CLI shell-out) - Delete build_github_url helper - expand_abbreviation now takes GitProtocol argument --- src/template/source.rs | 204 ++++++++++++++++++++++------------------- 1 file changed, 109 insertions(+), 95 deletions(-) diff --git a/src/template/source.rs b/src/template/source.rs index 8bf4949..2c679d9 100644 --- a/src/template/source.rs +++ b/src/template/source.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::process::Command; use crate::error::{DicecutError, Result}; @@ -50,35 +49,32 @@ pub enum TemplateSource { }, } -/// Built-in abbreviation prefixes and their expansion targets. -const ABBREVIATIONS: &[(&str, &str, &str)] = &[ - ("gh:", "https://github.com/", ".git"), - ("gl:", "https://gitlab.com/", ".git"), - ("cb:", "https://codeberg.org/", ".git"), -]; - -fn detect_github_protocol() -> String { - Command::new("gh") - .args(["config", "get", "git_protocol", "-h", "github.com"]) - .output() - .ok() - .and_then(|output| { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if stdout == "ssh" { - return Some("ssh".to_string()); - } - } - None - }) - .unwrap_or_else(|| "https".to_string()) +/// A built-in shortcode → host mapping. +struct VendorShortcode { + prefix: &'static str, + host: &'static str, } -fn build_github_url(rest: &str, protocol: &str) -> String { - if protocol == "ssh" { - format!("git@github.com:{rest}.git") - } else { - format!("https://github.com/{rest}.git") +const SHORTCODES: &[VendorShortcode] = &[ + VendorShortcode { + prefix: "gh:", + host: "github.com", + }, + VendorShortcode { + prefix: "gl:", + host: "gitlab.com", + }, + VendorShortcode { + prefix: "cb:", + host: "codeberg.org", + }, +]; + +/// Build a clone URL for a given host, repo, and protocol. +fn build_url(host: &str, repo: &str, protocol: GitProtocol) -> String { + match protocol { + GitProtocol::Ssh => format!("git@{host}:{repo}.git"), + GitProtocol::Https => format!("https://{host}/{repo}.git"), } } @@ -114,24 +110,9 @@ struct ExpandedSource { subpath: Option, } -fn expand_abbreviation(input: &str) -> Result { - // Special case: GitHub abbreviation with protocol detection - if let Some(rest) = input.strip_prefix("gh:") { - if rest.is_empty() { - return Err(DicecutError::InvalidAbbreviation { - input: input.to_string(), - }); - } - let (repo, subpath) = split_repo_subpath(rest); - let protocol = detect_github_protocol(); - return Ok(ExpandedSource { - url: build_github_url(repo, &protocol), - subpath: subpath.map(String::from), - }); - } - - // All other abbreviations use static expansion - for &(prefix, base_url, suffix) in ABBREVIATIONS { +/// Expand a built-in shortcode (`gh:`/`gl:`/`cb:`) into a git URL. +fn expand_abbreviation(input: &str, protocol: GitProtocol) -> Result { + for VendorShortcode { prefix, host } in SHORTCODES { if let Some(rest) = input.strip_prefix(prefix) { if rest.is_empty() { return Err(DicecutError::InvalidAbbreviation { @@ -140,7 +121,7 @@ fn expand_abbreviation(input: &str) -> Result { } let (repo, subpath) = split_repo_subpath(rest); return Ok(ExpandedSource { - url: format!("{base_url}{repo}{suffix}"), + url: build_url(host, repo, protocol), subpath: subpath.map(String::from), }); } @@ -171,10 +152,9 @@ fn expand_user_abbreviation( })) } +/// Check if an input string starts with any built-in shortcode prefix. fn is_abbreviation(input: &str) -> bool { - ABBREVIATIONS - .iter() - .any(|&(prefix, _, _)| input.starts_with(prefix)) + SHORTCODES.iter().any(|s| input.starts_with(s.prefix)) } fn is_git_url(input: &str) -> bool { @@ -213,7 +193,7 @@ pub fn resolve_source_full( } if is_abbreviation(template_arg) { - let expanded = expand_abbreviation(template_arg)?; + let expanded = expand_abbreviation(template_arg, GitProtocol::default())?; return Ok(TemplateSource::Git { url: expanded.url, git_ref: git_ref.map(String::from), @@ -249,49 +229,6 @@ mod tests { use super::*; use rstest::rstest; - // ── Abbreviation expansion ────────────────────────────────────────── - - #[test] - fn build_github_url_ssh() { - let url = build_github_url("user/repo", "ssh"); - assert_eq!(url, "git@github.com:user/repo.git"); - } - - #[test] - fn build_github_url_https() { - let url = build_github_url("user/repo", "https"); - assert_eq!(url, "https://github.com/user/repo.git"); - } - - #[test] - fn expand_github_abbreviation() { - let expanded = expand_abbreviation("gh:user/repo").unwrap(); - assert!( - expanded.url == "https://github.com/user/repo.git" - || expanded.url == "git@github.com:user/repo.git", - "unexpected URL: {}", - expanded.url - ); - assert!(expanded.subpath.is_none()); - } - - #[rstest] - #[case("gl:org/project", "https://gitlab.com/org/project.git")] - #[case("cb:user/repo", "https://codeberg.org/user/repo.git")] - fn expand_abbreviation_cases(#[case] input: &str, #[case] expected_url: &str) { - let expanded = expand_abbreviation(input).unwrap(); - assert_eq!(expanded.url, expected_url); - assert!(expanded.subpath.is_none()); - } - - #[test] - fn expand_abbreviation_empty_remainder() { - let result = expand_abbreviation("gh:"); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(matches!(err, DicecutError::InvalidAbbreviation { ref input } if input == "gh:")); - } - // ── Git URL detection ─────────────────────────────────────────────── #[rstest] @@ -562,7 +499,11 @@ mod tests { let source = resolve_source("gl:org/repo/templates/python").unwrap(); match source { TemplateSource::Git { url, subpath, .. } => { - assert_eq!(url, "https://gitlab.com/org/repo.git"); + assert!( + url == "https://gitlab.com/org/repo.git" + || url == "git@gitlab.com:org/repo.git", + "unexpected URL: {url}" + ); assert_eq!(subpath.as_deref(), Some("templates/python")); } _ => panic!("expected Git source"), @@ -616,6 +557,79 @@ mod tests { fn git_protocol_default_is_ssh() { assert_eq!(GitProtocol::default(), GitProtocol::Ssh); } + + // ── Shortcode expansion (protocol-aware) ─────────────────────────── + + #[rstest] + #[case("gh:user/repo", GitProtocol::Ssh, "git@github.com:user/repo.git")] + #[case("gh:user/repo", GitProtocol::Https, "https://github.com/user/repo.git")] + #[case("gl:org/project", GitProtocol::Ssh, "git@gitlab.com:org/project.git")] + #[case( + "gl:org/project", + GitProtocol::Https, + "https://gitlab.com/org/project.git" + )] + #[case("cb:user/repo", GitProtocol::Ssh, "git@codeberg.org:user/repo.git")] + #[case( + "cb:user/repo", + GitProtocol::Https, + "https://codeberg.org/user/repo.git" + )] + fn expand_shortcode_per_protocol( + #[case] input: &str, + #[case] protocol: GitProtocol, + #[case] expected_url: &str, + ) { + let expanded = expand_abbreviation(input, protocol).unwrap(); + assert_eq!(expanded.url, expected_url); + assert!(expanded.subpath.is_none()); + } + + #[rstest] + #[case( + "gh:user/repo/templates/py", + GitProtocol::Ssh, + "git@github.com:user/repo.git", + "templates/py" + )] + #[case( + "gl:org/repo/templates/python", + GitProtocol::Https, + "https://gitlab.com/org/repo.git", + "templates/python" + )] + #[case( + "cb:user/repo/sub", + GitProtocol::Ssh, + "git@codeberg.org:user/repo.git", + "sub" + )] + fn expand_shortcode_with_subpath( + #[case] input: &str, + #[case] protocol: GitProtocol, + #[case] expected_url: &str, + #[case] expected_subpath: &str, + ) { + let expanded = expand_abbreviation(input, protocol).unwrap(); + assert_eq!(expanded.url, expected_url); + assert_eq!(expanded.subpath.as_deref(), Some(expected_subpath)); + } + + #[test] + fn expand_shortcode_empty_remainder_errors() { + let result = expand_abbreviation("gh:", GitProtocol::Ssh); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + DicecutError::InvalidAbbreviation { ref input } if input == "gh:" + )); + } + + #[test] + fn expand_shortcode_unknown_prefix_errors() { + let result = expand_abbreviation("xx:user/repo", GitProtocol::Ssh); + assert!(result.is_err()); + } } #[cfg(test)] From 5df1b68daefa3d221990e4c58040cdd94c8eff2b Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 20:13:42 -0400 Subject: [PATCH 07/12] refactor(#131): collapse resolve_source ladder into ResolveOptions - Replace resolve_source / resolve_source_with_ref / resolve_source_full three-variant API with a single resolve_source(arg, ResolveOptions) - Thread GitProtocol through the options struct - Update src/lib.rs call site to use new API - Add protocol field to GenerateOptions --- src/commands/new.rs | 2 + src/lib.rs | 18 ++- src/template/mod.rs | 4 +- src/template/source.rs | 299 ++++++++--------------------------------- tests/integration.rs | 13 +- 5 files changed, 84 insertions(+), 252 deletions(-) diff --git a/src/commands/new.rs b/src/commands/new.rs index d78f9d3..82aeb2d 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -1,4 +1,5 @@ use console::style; +use diecut::template::GitProtocol; use diecut::GenerateOptions; use miette::Result; @@ -30,6 +31,7 @@ pub fn run( defaults, overwrite, no_hooks, + protocol: GitProtocol::default(), }; if dry_run { diff --git a/src/lib.rs b/src/lib.rs index a57e60c..7c6ab7a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,7 @@ use crate::answers::TemplateOrigin; use crate::error::{DicecutError, Result}; use crate::prompt::{collect_variables, PromptOptions}; use crate::render::{build_context, execute_plan, plan_render, GeneratedProject, GenerationPlan}; -use crate::template::{get_or_clone, resolve_source, TemplateSource}; +use crate::template::{get_or_clone, resolve_source, GitProtocol, ResolveOptions, TemplateSource}; pub struct GenerateOptions { pub template: String, @@ -27,6 +27,7 @@ pub struct GenerateOptions { pub defaults: bool, pub overwrite: bool, pub no_hooks: bool, + pub protocol: GitProtocol, } /// Everything needed to execute a generation that has been planned but not yet written. @@ -44,7 +45,13 @@ pub struct FullGenerationPlan { /// This performs all preparation (template resolution, variable collection, pre-generate /// hooks, and rendering) but does **not** write any files to disk. pub fn plan_generation(options: GenerateOptions) -> Result { - let source = resolve_source(&options.template)?; + let source = resolve_source( + &options.template, + ResolveOptions { + protocol: options.protocol, + ..Default::default() + }, + )?; let (template_dir, origin) = match &source { TemplateSource::Local(path) => (path.clone(), TemplateOrigin::Local), TemplateSource::Git { @@ -222,6 +229,7 @@ default = "my-project" defaults: false, overwrite: false, no_hooks: true, + protocol: GitProtocol::default(), }; let plan = plan_generation(options).unwrap(); @@ -240,6 +248,7 @@ default = "my-project" defaults: true, overwrite: false, no_hooks: true, + protocol: GitProtocol::default(), }; let result = plan_generation(options); @@ -267,6 +276,7 @@ default = "my-project" defaults: true, overwrite, no_hooks: true, + protocol: GitProtocol::default(), }; let result = plan_generation(options); @@ -294,6 +304,7 @@ default = "my-project" defaults: false, overwrite: false, no_hooks: true, + protocol: GitProtocol::default(), }; let plan = plan_generation(options).unwrap(); @@ -326,6 +337,7 @@ default = "my-project" defaults: false, overwrite: true, no_hooks: true, + protocol: GitProtocol::default(), }; let plan = plan_generation(options).unwrap(); @@ -368,6 +380,7 @@ default = "test" defaults: true, overwrite: true, no_hooks: true, + protocol: GitProtocol::default(), }; let plan = plan_generation(options).unwrap(); @@ -394,6 +407,7 @@ default = "test" defaults: false, overwrite: true, no_hooks: true, + protocol: GitProtocol::default(), }; let result = generate(options).unwrap(); diff --git a/src/template/mod.rs b/src/template/mod.rs index 87aeb39..c303262 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -4,4 +4,6 @@ pub mod source; pub use cache::{clear_cache, get_or_clone, list_cached, CacheMetadata, CachedTemplate}; pub use clone::{clone_template, CloneResult}; -pub use source::{resolve_source, resolve_source_full, resolve_source_with_ref, TemplateSource}; +pub use source::{ + resolve_git_protocol, resolve_source, GitProtocol, ResolveOptions, TemplateSource, +}; diff --git a/src/template/source.rs b/src/template/source.rs index 2c679d9..6f74a6f 100644 --- a/src/template/source.rs +++ b/src/template/source.rs @@ -49,6 +49,17 @@ pub enum TemplateSource { }, } +/// Options passed to [`resolve_source`]. +#[derive(Debug, Default)] +pub struct ResolveOptions<'a> { + /// Optional git ref (branch, tag, or commit) to check out. + pub git_ref: Option<&'a str>, + /// Protocol to use when expanding built-in shortcodes. + pub protocol: GitProtocol, + /// Optional user-defined abbreviations to consult before built-in shortcodes. + pub user_abbreviations: Option<&'a HashMap>, +} + /// A built-in shortcode → host mapping. struct VendorShortcode { prefix: &'static str, @@ -164,39 +175,26 @@ fn is_git_url(input: &str) -> bool { || input.ends_with(".git") } -/// Resolve a template argument to a source: abbreviation -> git URL -> local path. -pub fn resolve_source(template_arg: &str) -> Result { - resolve_source_with_ref(template_arg, None) -} - -pub fn resolve_source_with_ref( - template_arg: &str, - git_ref: Option<&str>, -) -> Result { - resolve_source_full(template_arg, git_ref, None) -} - -pub fn resolve_source_full( - template_arg: &str, - git_ref: Option<&str>, - user_abbreviations: Option<&HashMap>, -) -> Result { - if let Some(abbrevs) = user_abbreviations { +/// Resolve a template argument to a [`TemplateSource`]. +/// +/// Handles user abbreviations, built-in shortcodes, explicit git URLs, and local paths. +pub fn resolve_source(template_arg: &str, opts: ResolveOptions<'_>) -> Result { + if let Some(abbrevs) = opts.user_abbreviations { if let Some(result) = expand_user_abbreviation(template_arg, abbrevs) { let expanded = result?; return Ok(TemplateSource::Git { url: expanded.url, - git_ref: git_ref.map(String::from), + git_ref: opts.git_ref.map(String::from), subpath: expanded.subpath, }); } } if is_abbreviation(template_arg) { - let expanded = expand_abbreviation(template_arg, GitProtocol::default())?; + let expanded = expand_abbreviation(template_arg, opts.protocol)?; return Ok(TemplateSource::Git { url: expanded.url, - git_ref: git_ref.map(String::from), + git_ref: opts.git_ref.map(String::from), subpath: expanded.subpath, }); } @@ -204,7 +202,7 @@ pub fn resolve_source_full( if is_git_url(template_arg) { return Ok(TemplateSource::Git { url: template_arg.to_string(), - git_ref: git_ref.map(String::from), + git_ref: opts.git_ref.map(String::from), subpath: None, }); } @@ -241,39 +239,27 @@ mod tests { assert_eq!(is_git_url(input), expected); } - // ── resolve_source ────────────────────────────────────────────────── + // ── resolve_source with ResolveOptions ────────────────────────────── #[test] - fn resolve_abbreviation_to_git_source() { - let source = resolve_source("gh:user/repo").unwrap(); - match source { - TemplateSource::Git { - url, - git_ref, - subpath, - } => { - assert!( - url == "https://github.com/user/repo.git" - || url == "git@github.com:user/repo.git", - "unexpected URL: {url}" - ); - assert!(git_ref.is_none()); - assert!(subpath.is_none()); - } - _ => panic!("expected Git source"), - } + fn resolve_source_with_options_local() { + let dir = env!("CARGO_MANIFEST_DIR"); + let opts = ResolveOptions::default(); + let source = resolve_source(dir, opts).unwrap(); + assert!(matches!(source, TemplateSource::Local(_))); } #[test] - fn resolve_explicit_https_to_git_source() { - let source = resolve_source("https://example.com/repo.git").unwrap(); + fn resolve_source_with_options_shortcode_ssh_default() { + let opts = ResolveOptions::default(); + let source = resolve_source("gh:user/repo", opts).unwrap(); match source { TemplateSource::Git { url, git_ref, subpath, } => { - assert_eq!(url, "https://example.com/repo.git"); + assert_eq!(url, "git@github.com:user/repo.git"); assert!(git_ref.is_none()); assert!(subpath.is_none()); } @@ -282,34 +268,29 @@ mod tests { } #[test] - fn resolve_git_ssh_to_git_source() { - let source = resolve_source("git@github.com:user/repo.git").unwrap(); + fn resolve_source_with_options_shortcode_https() { + let opts = ResolveOptions { + protocol: GitProtocol::Https, + ..Default::default() + }; + let source = resolve_source("gh:user/repo", opts).unwrap(); match source { - TemplateSource::Git { - url, - git_ref, - subpath, - } => { - assert_eq!(url, "git@github.com:user/repo.git"); - assert!(git_ref.is_none()); - assert!(subpath.is_none()); + TemplateSource::Git { url, .. } => { + assert_eq!(url, "https://github.com/user/repo.git"); } _ => panic!("expected Git source"), } } - // ── resolve_source_with_ref ───────────────────────────────────────── - #[test] - fn resolve_with_ref_sets_git_ref() { - let source = resolve_source_with_ref("gh:user/repo", Some("v1.0")).unwrap(); + fn resolve_source_with_options_ref() { + let opts = ResolveOptions { + git_ref: Some("v1.0"), + ..Default::default() + }; + let source = resolve_source("gh:user/repo", opts).unwrap(); match source { - TemplateSource::Git { url, git_ref, .. } => { - assert!( - url == "https://github.com/user/repo.git" - || url == "git@github.com:user/repo.git", - "unexpected URL: {url}" - ); + TemplateSource::Git { git_ref, .. } => { assert_eq!(git_ref.as_deref(), Some("v1.0")); } _ => panic!("expected Git source"), @@ -317,60 +298,25 @@ mod tests { } #[test] - fn resolve_with_ref_none_leaves_ref_none() { - let source = resolve_source_with_ref("gh:user/repo", None).unwrap(); + fn resolve_source_with_options_shortcode_subpath() { + let opts = ResolveOptions::default(); + let source = resolve_source("gh:user/repo/my-template", opts).unwrap(); match source { - TemplateSource::Git { url, git_ref, .. } => { - assert!( - url == "https://github.com/user/repo.git" - || url == "git@github.com:user/repo.git", - "unexpected URL: {url}" - ); - assert!(git_ref.is_none()); + TemplateSource::Git { url, subpath, .. } => { + assert_eq!(url, "git@github.com:user/repo.git"); + assert_eq!(subpath.as_deref(), Some("my-template")); } _ => panic!("expected Git source"), } } - // ── Local path fallback ───────────────────────────────────────────── - #[test] - fn resolve_existing_local_path() { - // Use the cargo manifest dir which is guaranteed to exist. - let dir = env!("CARGO_MANIFEST_DIR"); - let source = resolve_source(dir).unwrap(); + fn resolve_source_with_options_plain_url() { + let opts = ResolveOptions::default(); + let source = resolve_source("https://example.com/repo.git", opts).unwrap(); match source { - TemplateSource::Local(path) => { - assert!(path.exists()); - } - _ => panic!("expected Local source"), - } - } - - #[test] - fn resolve_nonexistent_local_path_errors() { - let result = resolve_source("/nonexistent/path/that/does/not/exist"); - assert!(result.is_err()); - } - - // ── User abbreviations ────────────────────────────────────────────── - - #[test] - fn user_abbreviation_expands_correctly() { - let mut abbrevs = HashMap::new(); - abbrevs.insert( - "company".to_string(), - "https://git.company.com/{}.git".to_string(), - ); - let source = resolve_source_full("company:team/project", None, Some(&abbrevs)).unwrap(); - match source { - TemplateSource::Git { - url, - git_ref, - subpath, - } => { - assert_eq!(url, "https://git.company.com/team/project.git"); - assert!(git_ref.is_none()); + TemplateSource::Git { url, subpath, .. } => { + assert_eq!(url, "https://example.com/repo.git"); assert!(subpath.is_none()); } _ => panic!("expected Git source"), @@ -378,84 +324,12 @@ mod tests { } #[test] - fn user_abbreviation_with_ref() { - let mut abbrevs = HashMap::new(); - abbrevs.insert( - "corp".to_string(), - "https://git.corp.com/{}.git".to_string(), - ); - let source = resolve_source_full("corp:myrepo", Some("v2.0"), Some(&abbrevs)).unwrap(); - match source { - TemplateSource::Git { url, git_ref, .. } => { - assert_eq!(url, "https://git.corp.com/myrepo.git"); - assert_eq!(git_ref.as_deref(), Some("v2.0")); - } - _ => panic!("expected Git source"), - } - } - - #[test] - fn user_abbreviation_takes_priority_over_builtin() { - let mut abbrevs = HashMap::new(); - abbrevs.insert( - "gh".to_string(), - "https://custom-github.example.com/{}.git".to_string(), - ); - let source = resolve_source_full("gh:user/repo", None, Some(&abbrevs)).unwrap(); - match source { - TemplateSource::Git { url, .. } => { - assert_eq!(url, "https://custom-github.example.com/user/repo.git"); - } - _ => panic!("expected Git source"), - } - } - - #[test] - fn user_abbreviation_empty_remainder_errors() { - let mut abbrevs = HashMap::new(); - abbrevs.insert( - "company".to_string(), - "https://git.company.com/{}.git".to_string(), - ); - let result = resolve_source_full("company:", None, Some(&abbrevs)); + fn resolve_source_nonexistent_local_path_errors() { + let opts = ResolveOptions::default(); + let result = resolve_source("/nonexistent/path/that/does/not/exist", opts); assert!(result.is_err()); } - #[test] - fn unknown_user_abbreviation_falls_through_to_builtin() { - let mut abbrevs = HashMap::new(); - abbrevs.insert( - "company".to_string(), - "https://git.company.com/{}.git".to_string(), - ); - let source = resolve_source_full("gh:user/repo", None, Some(&abbrevs)).unwrap(); - match source { - TemplateSource::Git { url, .. } => { - assert!( - url == "https://github.com/user/repo.git" - || url == "git@github.com:user/repo.git", - "unexpected URL: {url}" - ); - } - _ => panic!("expected Git source"), - } - } - - #[test] - fn no_user_abbreviations_behaves_as_before() { - let source = resolve_source_full("gh:user/repo", None, None).unwrap(); - match source { - TemplateSource::Git { url, .. } => { - assert!( - url == "https://github.com/user/repo.git" - || url == "git@github.com:user/repo.git", - "unexpected URL: {url}" - ); - } - _ => panic!("expected Git source"), - } - } - // ── Subpath parsing ──────────────────────────────────────────────── #[rstest] @@ -473,61 +347,6 @@ mod tests { assert_eq!(sub, exp_sub); } - #[test] - fn resolve_abbreviation_with_subpath() { - let source = resolve_source("gh:user/repo/my-template").unwrap(); - match source { - TemplateSource::Git { - url, - subpath, - git_ref, - } => { - assert!( - url == "https://github.com/user/repo.git" - || url == "git@github.com:user/repo.git", - "unexpected URL: {url}" - ); - assert_eq!(subpath.as_deref(), Some("my-template")); - assert!(git_ref.is_none()); - } - _ => panic!("expected Git source"), - } - } - - #[test] - fn resolve_abbreviation_with_nested_subpath() { - let source = resolve_source("gl:org/repo/templates/python").unwrap(); - match source { - TemplateSource::Git { url, subpath, .. } => { - assert!( - url == "https://gitlab.com/org/repo.git" - || url == "git@gitlab.com:org/repo.git", - "unexpected URL: {url}" - ); - assert_eq!(subpath.as_deref(), Some("templates/python")); - } - _ => panic!("expected Git source"), - } - } - - #[test] - fn user_abbreviation_with_subpath() { - let mut abbrevs = HashMap::new(); - abbrevs.insert( - "company".to_string(), - "https://git.company.com/{}.git".to_string(), - ); - let source = - resolve_source_full("company:team/project/subdir", None, Some(&abbrevs)).unwrap(); - match source { - TemplateSource::Git { url, subpath, .. } => { - assert_eq!(url, "https://git.company.com/team/project.git"); - assert_eq!(subpath.as_deref(), Some("subdir")); - } - _ => panic!("expected Git source"), - } - } - // ── GitProtocol ──────────────────────────────────────────────────── #[test] diff --git a/tests/integration.rs b/tests/integration.rs index 4005080..7c1fa31 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -5,7 +5,7 @@ use diecut::adapter; use diecut::config::load_config; use diecut::prompt::PromptOptions; use diecut::render::{build_context, execute_plan, plan_render, walk_and_render}; -use diecut::template::source::{resolve_source, resolve_source_full}; +use diecut::template::source::{resolve_source, ResolveOptions}; use rstest::rstest; fn fixture_path(name: &str) -> PathBuf { @@ -268,14 +268,7 @@ name = "missing-template-dir" #[case("gl:")] #[case("cb:")] fn test_resolve_source_rejects_empty_abbreviation_remainder(#[case] input: &str) { - assert!(resolve_source(input).is_err()); -} - -#[test] -fn test_resolve_source_user_abbreviation_empty_remainder() { - let mut abbrevs = std::collections::HashMap::new(); - abbrevs.insert("co".to_string(), "https://git.co.com/{}.git".to_string()); - assert!(resolve_source_full("co:", None, Some(&abbrevs)).is_err()); + assert!(resolve_source(input, ResolveOptions::default()).is_err()); } // --- Deprecated .tera suffix fallback --- @@ -440,6 +433,7 @@ fn test_plan_generation_dry_run_no_files_written() { defaults: true, overwrite: false, no_hooks: true, + protocol: diecut::template::GitProtocol::default(), }; // plan_generation should succeed @@ -588,6 +582,7 @@ fn test_plan_generation_verbose_has_content() { defaults: true, overwrite: false, no_hooks: true, + protocol: diecut::template::GitProtocol::default(), }; let plan = diecut::plan_generation(options).unwrap(); From fc6e0f93a6fc3b7287d15832d0299fd48ba88b5f Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 20:17:41 -0400 Subject: [PATCH 08/12] feat(#131): add --protocol flag to diecut new Threads GitProtocol from CLI -> commands::new::run -> GenerateOptions, resolving against DIECUT_GIT_PROTOCOL env var when not explicitly passed. --- src/cli.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++ src/commands/new.rs | 7 +++++-- src/main.rs | 3 ++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ab84986..3dd2af8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Parser, Subcommand}; +use diecut::template::GitProtocol; #[derive(Parser)] #[command( @@ -45,8 +46,56 @@ pub enum Commands { /// Show file contents (with --dry-run) or detailed output #[arg(short, long)] verbose: bool, + + /// Protocol for expanding shortcodes (ssh or https). + /// Defaults to ssh. Override with DIECUT_GIT_PROTOCOL env var. + #[arg(long, value_enum)] + protocol: Option, }, /// List cached templates List, } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use diecut::template::GitProtocol; + + #[test] + fn parses_new_without_protocol() { + let cli = Cli::parse_from(["diecut", "new", "gh:user/repo"]); + if let Commands::New { protocol, .. } = cli.command { + assert!(protocol.is_none()); + } else { + panic!("expected New"); + } + } + + #[test] + fn parses_new_with_protocol_ssh() { + let cli = Cli::parse_from(["diecut", "new", "gh:user/repo", "--protocol", "ssh"]); + if let Commands::New { protocol, .. } = cli.command { + assert_eq!(protocol, Some(GitProtocol::Ssh)); + } else { + panic!("expected New"); + } + } + + #[test] + fn parses_new_with_protocol_https() { + let cli = Cli::parse_from(["diecut", "new", "gh:user/repo", "--protocol", "https"]); + if let Commands::New { protocol, .. } = cli.command { + assert_eq!(protocol, Some(GitProtocol::Https)); + } else { + panic!("expected New"); + } + } + + #[test] + fn rejects_invalid_protocol() { + let result = Cli::try_parse_from(["diecut", "new", "gh:user/repo", "--protocol", "ftp"]); + assert!(result.is_err()); + } +} diff --git a/src/commands/new.rs b/src/commands/new.rs index 82aeb2d..e0fb2b5 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -1,5 +1,5 @@ use console::style; -use diecut::template::GitProtocol; +use diecut::template::{resolve_git_protocol, GitProtocol}; use diecut::GenerateOptions; use miette::Result; @@ -13,6 +13,7 @@ pub fn run( no_hooks: bool, dry_run: bool, verbose: bool, + protocol: Option, ) -> Result<()> { let data_pairs: Vec<(String, String)> = data .into_iter() @@ -24,6 +25,8 @@ pub fn run( }) .collect(); + let resolved_protocol = resolve_git_protocol(protocol)?; + let options = GenerateOptions { template, output, @@ -31,7 +34,7 @@ pub fn run( defaults, overwrite, no_hooks, - protocol: GitProtocol::default(), + protocol: resolved_protocol, }; if dry_run { diff --git a/src/main.rs b/src/main.rs index 20cf462..282b8de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,8 +15,9 @@ fn main() -> miette::Result<()> { no_hooks, dry_run, verbose, + protocol, } => commands::new::run( - template, output, data, defaults, overwrite, no_hooks, dry_run, verbose, + template, output, data, defaults, overwrite, no_hooks, dry_run, verbose, protocol, ), Commands::List => commands::list::run(), } From cdf931a73adb87489bcdf2f95311fc5db275e371 Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 20:21:10 -0400 Subject: [PATCH 09/12] feat(#131): print resolved source URL in dry-run output Adds format_resolved_source helper and prints the resolved TemplateSource before plan_generation in dry-run mode, so users (and tests) can observe the clone URL before any network work. --- src/commands/new.rs | 16 +++++++++++- src/template/mod.rs | 3 ++- src/template/source.rs | 58 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/commands/new.rs b/src/commands/new.rs index e0fb2b5..50b6a49 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -1,5 +1,7 @@ use console::style; -use diecut::template::{resolve_git_protocol, GitProtocol}; +use diecut::template::{ + format_resolved_source, resolve_git_protocol, resolve_source, GitProtocol, ResolveOptions, +}; use diecut::GenerateOptions; use miette::Result; @@ -27,6 +29,18 @@ pub fn run( let resolved_protocol = resolve_git_protocol(protocol)?; + if dry_run { + // Resolve the source first so the URL is visible even if clone fails. + let source = resolve_source( + &template, + ResolveOptions { + protocol: resolved_protocol, + ..Default::default() + }, + )?; + println!("{}", format_resolved_source(&source)); + } + let options = GenerateOptions { template, output, diff --git a/src/template/mod.rs b/src/template/mod.rs index c303262..8a3f04e 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -5,5 +5,6 @@ pub mod source; pub use cache::{clear_cache, get_or_clone, list_cached, CacheMetadata, CachedTemplate}; pub use clone::{clone_template, CloneResult}; pub use source::{ - resolve_git_protocol, resolve_source, GitProtocol, ResolveOptions, TemplateSource, + format_resolved_source, resolve_git_protocol, resolve_source, GitProtocol, ResolveOptions, + TemplateSource, }; diff --git a/src/template/source.rs b/src/template/source.rs index 6f74a6f..7266cda 100644 --- a/src/template/source.rs +++ b/src/template/source.rs @@ -175,6 +175,29 @@ fn is_git_url(input: &str) -> bool { || input.ends_with(".git") } +/// Format a [`TemplateSource`] for human-readable dry-run output. +pub fn format_resolved_source(source: &TemplateSource) -> String { + match source { + TemplateSource::Local(path) => { + format!("Would use local path: {}", path.display()) + } + TemplateSource::Git { + url, + git_ref, + subpath, + } => { + let mut out = format!("Would clone from: {url}"); + if let Some(r) = git_ref { + out.push_str(&format!("\n ref: {r}")); + } + if let Some(sp) = subpath { + out.push_str(&format!("\n subpath: {sp}")); + } + out + } + } +} + /// Resolve a template argument to a [`TemplateSource`]. /// /// Handles user abbreviations, built-in shortcodes, explicit git URLs, and local paths. @@ -449,6 +472,41 @@ mod tests { let result = expand_abbreviation("xx:user/repo", GitProtocol::Ssh); assert!(result.is_err()); } + + // ── format_resolved_source ───────────────────────────────────────── + + #[test] + fn format_resolved_source_git_url_only() { + let source = TemplateSource::Git { + url: "git@github.com:user/repo.git".to_string(), + git_ref: None, + subpath: None, + }; + let s = format_resolved_source(&source); + assert!(s.contains("Would clone from: git@github.com:user/repo.git")); + assert!(!s.contains("ref:")); + assert!(!s.contains("subpath:")); + } + + #[test] + fn format_resolved_source_git_full() { + let source = TemplateSource::Git { + url: "https://github.com/user/repo.git".to_string(), + git_ref: Some("main".to_string()), + subpath: Some("templates/py".to_string()), + }; + let s = format_resolved_source(&source); + assert!(s.contains("Would clone from: https://github.com/user/repo.git")); + assert!(s.contains("ref: main")); + assert!(s.contains("subpath: templates/py")); + } + + #[test] + fn format_resolved_source_local() { + let source = TemplateSource::Local(std::path::PathBuf::from("/tmp/templates/foo")); + let s = format_resolved_source(&source); + assert!(s.contains("Would use local path: /tmp/templates/foo")); + } } #[cfg(test)] From b98b2137c9f6777a74d9f7f4487578ee776d3ec2 Mon Sep 17 00:00:00 2001 From: rroskam Date: Sat, 11 Apr 2026 20:26:28 -0400 Subject: [PATCH 10/12] test(#131): integration test for dry-run resolved source output --- tests/integration.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/integration.rs b/tests/integration.rs index 7c1fa31..8eb83ec 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -617,3 +617,61 @@ fn test_plan_generation_verbose_has_content() { "at least one rendered file should contain the resolved project name" ); } + +// --- End-to-end binary test: dry-run prints resolved source --- + +#[test] +fn dry_run_prints_resolved_local_source() { + use std::process::Command; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let template_dir = tmp.path().join("my-template"); + std::fs::create_dir_all(&template_dir).unwrap(); + std::fs::write( + template_dir.join("diecut.toml"), + r#"[template] +name = "dry-run-test" + +[variables.project_name] +type = "string" +prompt = "Project name" +default = "demo" +"#, + ) + .unwrap(); + // Template files must live under a `template/` subdirectory + std::fs::create_dir_all(template_dir.join("template").join("{{ project_name }}")).unwrap(); + std::fs::write( + template_dir + .join("template") + .join("{{ project_name }}") + .join("README.md.die"), + "# {{ project_name }}\n", + ) + .unwrap(); + + let output_dir = tmp.path().join("out"); + + let output = Command::new(env!("CARGO_BIN_EXE_diecut")) + .arg("new") + .arg(template_dir.to_str().unwrap()) + .arg("--dry-run") + .arg("--defaults") + .arg("-o") + .arg(output_dir.to_str().unwrap()) + .output() + .expect("failed to run diecut binary"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "diecut new exited with failure.\nstdout: {stdout}\nstderr: {stderr}" + ); + assert!( + stdout.contains("Would use local path:"), + "expected 'Would use local path:' in stdout, got: {stdout}" + ); +} From b8d123074a882f9e743befa8c7cf03ccab432a52 Mon Sep 17 00:00:00 2001 From: rroskam Date: Sun, 12 Apr 2026 00:28:23 -0400 Subject: [PATCH 11/12] docs(#131): document SSH default and --protocol flag --- CHANGELOG.md | 18 ++++++++++++++++++ README.md | 6 ++++++ docs/src/content/docs/reference/commands.md | 2 ++ .../src/content/docs/using-templates/index.mdx | 6 ++++++ 4 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 271bd29..668e594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [Unreleased] + +### Changed + +- **Breaking:** Built-in shortcodes (`gh:`, `gl:`, `cb:`) now default to SSH + URLs instead of HTTPS. The `gh config get git_protocol` detection has been + removed — behavior is now consistent across all three vendors and independent + of the `gh` CLI. + +### Added + +- `--protocol ` flag on `diecut new` for per-invocation override of + shortcode protocol. +- `DIECUT_GIT_PROTOCOL` environment variable for persistent shortcode protocol + preference. +- Dry-run output now prints the resolved clone URL (or local path) before any + clone is attempted. + ## [0.3.5](https://github.com/raiderrobert/diecut/compare/v0.3.4...v0.3.5) (2026-03-15) diff --git a/README.md b/README.md index bd8be97..201d17c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ diecut list Example templates: [diecut-templates](https://github.com/raiderrobert/diecut-templates) +### Protocol + +Built-in shortcodes resolve to SSH URLs by default (e.g., `gh:user/repo` → +`git@github.com:user/repo.git`). To use HTTPS instead, pass `--protocol https` +or set `DIECUT_GIT_PROTOCOL=https` in your shell environment. + ## Documentation Full documentation: **[diecut.dev](https://diecut.dev/)** diff --git a/docs/src/content/docs/reference/commands.md b/docs/src/content/docs/reference/commands.md index e9b7818..99e17b7 100644 --- a/docs/src/content/docs/reference/commands.md +++ b/docs/src/content/docs/reference/commands.md @@ -25,6 +25,7 @@ diecut new