# Implementation plan Step-by-step scaffold. Each step ends with runnable, testable code — no step requires the next one to be useful. Work through them in order; later steps build on earlier ones. Cross-cutting reminders (apply throughout): - Most behavior should be testable offline; flag anything that genuinely needs a network call - Documentation comments and inline comments throughout — don't defer these to a cleanup pass - Sample data for tests: `cyrusae` (GitHub), `atdot.fyi` (Tangled), `entangle` (repo) - Windows is a stated target but an untested risk; note any platform-specific assumptions as they appear --- ## Step 1: Project skeleton **Goal**: `cargo run -- --help` works and lists all four subcommands. - `cargo new entangle --bin` - Add all dependencies to `Cargo.toml`: - `clap` (with `derive` feature) - `dialoguer` - `indicatif` - `owo-colors` - `serde` + `serde_json` - `dirs` - `gix` - Define `clap` command/subcommand structure: `setup`, `set`, `init`, `shove` - Stub each subcommand handler to print `"not yet implemented"` and return `Ok(())` - `entangle` with no subcommand prints available commands (not an error) - Sketch and document `src/` module layout — decide now so later steps slot in cleanly. Suggested structure: ```text src/ main.rs # clap wiring, dispatch cli.rs # clap type definitions config.rs # Config struct, load/save validate.rs # sanitization and validation urls.rs # URL construction git.rs # gix wrappers (repo detection, remotes, push) remote.rs # SSH ls-refs, RemoteCheckResult commands/ setup.rs set.rs init.rs shove.rs ``` **Runnable check**: `cargo build` succeeds. `cargo run -- --help`, `cargo run -- init`, etc. all produce output without panicking. **Tests**: `cargo test` passes (compilation only at this point). --- ## Step 2: Config — data model and file I/O **Goal**: Can read and write a config file programmatically. No interactive prompts yet. - Define `Config` struct with `serde` derives: ```rust pub struct Config { pub github_username: String, pub tangled_username: String, pub origin_preference: OriginPreference, // enum: GitHub | Tangled } ``` - Implement `Config::path() -> PathBuf` using `dirs::config_dir()` - Implement `Config::load() -> Result` — reads from `{config_dir}/entangle/config.json`. Return distinct error variants for: - File not found (no config yet) - File exists but isn't readable (permissions) - Valid UTF-8/JSON but empty object `{}` - Valid JSON but missing one or more required fields (one error variant per field, so callers can say "run `entangle set gh-user`") - Corrupted/unparseable JSON (single catch-all: "config is unreadable, re-run `entangle setup`") - Implement `Config::save(&self) -> Result<(), ConfigError>` — writes to same path, creating `{config_dir}/entangle/` if needed. Errors for: can't create directory, can't write file. **Tests**: - Unit: serialize a well-formed `Config`, deserialize it back, assert equality - Unit: each malformed-config error case (empty file, `{}`, missing `github_username`, missing `tangled_username`, missing `origin_preference`, truncated JSON) - Unit: `save()` creates the directory if it doesn't exist - Unit: `Config::path()` returns a path ending in `entangle/config.json` (platform-agnostic assertion) - Integration: round-trip — `save()` then `load()` returns the same struct **📋 Decision baked in**: unique errors per missing field (not a single "config invalid"), because missing-field errors are actionable and should tell the user which `entangle set` command to run. Parse failures get a single catch-all since the file is unreadable regardless of why. **⚠️ Windows note**: `dirs::config_dir()` returns `AppData\Roaming` on Windows vs. `~/.config` on Unix. Document this in `config.rs`. No action needed yet, but flag it. --- ## Step 3: Input validation module **Goal**: Centralized, fully-tested sanitization and validation for all user input. Pure functions, no I/O, no UI. - Implement sanitization (applied first, before any validation): - Lowercase all input - Strip leading/trailing whitespace - Remove single and double quotes silently - Reject any input containing spaces, backticks, or shell metacharacters (`$`, `;`, `|`, `&`, `>`, `<`, `` ` ``) — loud error, not silent strip - Implement `validate_github_username(input: &str) -> Result` - Sanitize first, then validate: ≤39 chars, alphanumeric + hyphens only, no leading/trailing/consecutive hyphens - Implement `validate_tangled_username(input: &str) -> Result` - Sanitize first, then validate against ATProto regex: `/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?$/` - Implement `validate_repo_name(input: &str) -> Result` - Sanitize first, then validate: 1–100 chars, lowercase alphanumeric + hyphens, no consecutive/leading/trailing hyphens, no periods **Tests** (exhaustive boundary cases from TESTING.md): - GitHub: valid at exactly 39 chars; invalid at 40; consecutive hyphens; leading hyphen; trailing hyphen; mixed case input lowercased before validation - Tangled: `a.b` (minimal valid); `atdot.fyi` (valid); `user.tngl.sh` (valid with subdomain); no TLD (invalid); underscore in domain (invalid) - Repo: 1 char (valid); 100 chars (valid); 101 chars (invalid); period (invalid); consecutive hyphens (invalid); leading hyphen (invalid) - Sanitization: `"CyrusAE"` → `cyrusae` (quotes stripped, then lowercased); backtick → loud error; `$VAR` → loud error - **Order**: confirm sanitization happens before validation (test with `"CyrusAE"` — should pass, not fail on uppercase) **📋 Decision point**: What is the error message style for validation failures? Decide here so it's consistent across all commands. Suggested form: `"'{input}' is not a valid GitHub username: {reason}."` — specific, includes the offending input, states the reason. --- ## Step 4: `entangle set` **Goal**: Can set individual config values from the command line. Config is read, updated, and written correctly. - Wire `entangle set gh-user ` → sanitize/validate → load config (or create empty if missing) → update field → save - Wire `entangle set tngl-user ` → same pattern - Wire `entangle set origin ` → validate option → update → save - `clap` handles missing-argument errors (e.g., `entangle set gh-user` with no value); confirm the error messages are informative - Re-setting an already-set value silently overwrites with the same data — no special behavior **Tests**: - Unit: each `set` variant updates the correct field and leaves others unchanged - Unit: invalid GitHub username → validation error, config not written - Unit: invalid Tangled username → validation error, config not written - Unit: `set origin bogus` → error, config not written - Unit: `set` on a config missing other fields → updates just the one field, doesn't clobber the rest - Integration: `entangle set gh-user cyrusae` → config file on disk has `"github_username": "cyrusae"` **📋 Decision point**: Should `entangle set` print a confirmation of what it just set? (e.g., `"GitHub username set to: cyrusae"`) — decide on the progress message convention here, since `set` is the simplest case. Recommendation: yes, always confirm, because silent success is hard to debug. --- ## Step 5: `entangle setup` **Goal**: Interactive first-time (and repeat) setup works end-to-end. - Use `dialoguer` to prompt for GitHub username, Tangled username, and origin preference in order - Validate each input before accepting; re-prompt with error message on invalid input (don't just reject and exit) - If a field is already set in the config, use a "currently set to X. Change? [Y/n]" prompt — skip if user says no, prompt for new value if yes - Write config only after all prompts complete (no partial saves) - Handle Ctrl+C gracefully: `dialoguer` propagates this as an error; catch it and exit cleanly without writing a partial config **Tests**: - Unit: prompt logic correctly detects pre-existing config fields - Integration: `entangle setup` with piped input (dialoguer supports non-TTY input for testing) — valid inputs → config written - Integration: invalid input in first prompt → re-prompt, valid on retry → config written - Integration: `entangle setup` with already-valid config → correct "already set to X" prompts appear - Integration: Ctrl+C mid-setup → no config written (or previously valid config unchanged if one existed) **📋 Decision baked in**: On Ctrl+C, do not save partial config. A partial config is worse than no config — the user can always re-run `entangle setup`. --- ## Step 6: URL construction **Goal**: Given a config and repo name(s), build correct SSH URLs. Pure functions, no network. - Implement `build_github_url(username: &str, repo: &str) -> String` - Output: `git@github.com:{username}/{repo}.git` - Implement `build_tangled_url(username: &str, repo: &str) -> String` - Output: `git@tangled.org:{username}/{repo}` (no `.git`) - Implement `resolve_urls(config: &Config, repo: &str, alias: Option<&str>) -> (String, String)` - Returns `(origin_url, mirror_url)` based on `origin_preference` - If `alias` is provided, it becomes the repo name on the non-origin forge - If no alias, same repo name is used for both **Tests**: - Unit: `build_github_url("cyrusae", "entangle")` → `"git@github.com:cyrusae/entangle.git"` - Unit: `build_tangled_url("atdot.fyi", "entangle")` → `"git@tangled.org:atdot.fyi/entangle"` - Unit: `resolve_urls` with GitHub as origin → GitHub URL is first (origin), Tangled is second (mirror) - Unit: `resolve_urls` with Tangled as origin → Tangled URL is first, GitHub is second - Unit: `resolve_urls` with alias present → alias used for mirror forge, original name used for origin forge - Unit: `resolve_urls` with no alias → same name on both forges --- ## Step 7: Remote validation **Goal**: Can check whether an SSH remote actually exists, with three distinct error paths. - Implement `RemoteCheckResult` enum: `Ok`, `NotFound`, `AuthFailure`, `NetworkError(String)` - Implement `check_remote(url: &str) -> RemoteCheckResult` using `gix` SSH ls-refs - Map `gix` error types to the three named cases; document the mapping in comments - Include timeout handling — network errors should not hang indefinitely - Implement `validate_remotes(origin_url: &str, mirror_url: &str) -> Result<(), RemoteError>` - Checks origin first, then mirror - On `NetworkError`: surfaces the offline override prompt ("Couldn't reach remote — accept anyway? [y/N]") - On `NotFound` or `AuthFailure`: hard stop, distinct error messages **Tests**: - Unit (offline): test each `RemoteCheckResult` variant by injecting mock transport or pointing at localhost - Unit: `validate_remotes` fails on origin before trying mirror - Unit: network error triggers override prompt logic (test the logic path, not the actual prompt) - Integration (online, skippable with a feature flag or `#[ignore]`): real ls-refs against `github.com/cyrusae/entangle` - Integration: non-routable address → `NetworkError` path (e.g., `git@192.0.2.1:user/repo`) **📋 Decision point**: How do `gix` errors surface in user-facing messages? Options: (a) expose `gix` error text directly, (b) always map to one of the three named cases with `entangle`-authored messages. Recommendation: (b) — map everything to named cases, include `gix` error detail only in a debug/verbose mode. Decide verbosity levels here if not already settled. --- ## Step 8: `entangle init` — git detection and local setup **Goal**: `entangle init` can validate config, collect repo name(s), detect git status of the current directory, and initialize if needed. No remote logic yet. - Check config exists and is valid → refer to `entangle setup` if not (step 1 of init flow) - Parse CLI args or prompt interactively for repo name and optional alias (steps 2–3) - Validate repo name(s) using the validation module (step 4) - Check if the current directory is a git repo using `gix` - If not: initialize it with `gix`, print `"Folder is not a git repository, initializing..."` - Check for `.gitignore` and `README.md`; print suggestions for any that are missing - Build URLs (step 5) — needed later but construct them here so they're available for the remote steps **Tests**: - Unit: git-repo detection on a non-repo temp directory - Unit: git-repo detection on an existing repo - Unit: `.gitignore` detection (present / absent) - Unit: `README.md` detection (present / absent) - Integration: `entangle init` in a fresh temp directory → git repo initialized, appropriate messages printed - Integration (idempotency): run `entangle init` twice in the same fresh directory → second run sees the initialized repo, does not re-initialize --- ## Step 9: `entangle init` — remote inspection and overwrite prompt **Goal**: `entangle init` reads existing remotes and handles the overwrite prompt flow correctly. - Read existing remotes from the repo using `gix` - Early exit with a success message if both GitHub and Tangled push URLs are already present (step 8.1) - Detect if `origin` exists but doesn't match the generated origin URL (step 8.2) - Implement the two-level overwrite prompt using `dialoguer`: ``` An origin remote already exists: git@gitlab.com:someone/something.git Replace it with git@github.com:cyrusae/entangle.git? [Y/n] → No → Add push URLs to existing origin anyway? [Y/n] → No → abort (clean exit, no changes made) ``` - Output for the "proceed anyway" path should explicitly state what state the user is left in **Tests**: - Unit: remote inspection with no remotes configured - Unit: remote inspection with origin matching expected URL - Unit: remote inspection with origin not matching (GitLab URL, etc.) - Unit: remote inspection with both GitHub + Tangled already set → early exit - Unit: remote inspection with remotes but none named `origin` - Integration: overwrite prompt, replace=yes → continues normally - Integration: overwrite prompt, replace=no, proceed=yes → continues with warning message - Integration: overwrite prompt, replace=no, proceed=no → clean abort, no changes **📋 Decision point**: What exactly does the "proceed anyway" success output say? Draft it here. Suggested: `"Heads up: origin fetch URL is git@gitlab.com:…, not a GitHub or Tangled remote. Push URLs have been added — your pushes will go to both forges, but pulls will come from the existing origin."` Finalize wording before moving to step 10. --- ## Step 10: `entangle init` — remote configuration and confirmation **Goal**: `entangle init` wires up push remotes in the correct order and confirms the final state. - Add non-default-forge push URL via `gix` (e.g., Tangled if GitHub is origin) - Add default-forge push URL via `gix` (order matters — default is last) - Print the final `git remote -v`-equivalent output so the user can verify - Print the `entangle shove` suggestion **Tests**: - Unit: push URL order is correct (non-default first, default last) - Unit: `gix` remote state after the operation matches expected (origin fetch URL + two push URLs) - Integration: full end-to-end `entangle init` in a fresh repo with no existing remotes → `git remote -v` output matches expected format - Integration (idempotency): `entangle init` on a repo that already has both push URLs configured → early exit at step 9, no writes - Integration: `entangle init` on a repo with a matching origin but no push URLs → adds push URLs correctly --- ## Step 11: `entangle shove` **Goal**: `entangle shove` pushes all branches and tags to both remotes. - Verify the current directory is a git repo; error if not - Run `git push origin --all` then `git push origin --tags` via `gix` - Since `origin` has two push URLs configured, one command reaches both forges - Informative error messages if either push fails (surface the underlying error) - `entangle shove` with unexpected arguments → error with explanation (clap handles this) **Tests**: - Integration: `entangle shove` in a directory that isn't a git repo → informative error - Integration: `entangle shove` in a repo with no commits → informative error (nothing to push) - Integration: `entangle shove` with unexpected arguments → error with explanation - Integration (online, `#[ignore]`): `entangle shove` in a configured repo → both remotes receive the push --- ## Step 12: Output polish **Goal**: Consistent, readable terminal output across all commands. No new functionality — audit and harmonize what exists. - Audit all output strings across all commands; write out the style guide that emerges: - Progress messages (what's happening now) - Success messages (what just happened) - Warning messages (something's off but not fatal) - Error messages (fatal; always suggest what to do next) - Apply `owo-colors` consistently across all four categories - Add `indicatif` spinners to: remote validation (`check_remote` calls), push operations (`entangle shove`) - Ensure no command exits silently on success — always confirm what happened **Tests**: - Visual review of all command outputs in a real terminal - Integration: no command produces output that references internal types or `gix` error strings (unless in a future debug/verbose mode) --- ## Step 13: Hardening and edge cases **Goal**: Systematic coverage of the remaining TESTING.md cases not addressed in earlier steps. Work through each section of TESTING.md and confirm coverage: - **Config and file I/O**: permission-denied on config directory; unreadable config file; all partial-config variants - **Input boundary cases**: all max/min-length inputs; `a.b` Tangled address; shell metacharacters - **Argument parsing**: `entangle init` with 3+ args; `entangle set` subcommands missing their values; `entangle` alone - **Config state weirdness**: config with one field missing; Ctrl+C mid-`setup` on existing config - **Git state mismatches**: GitLab origin; remotes present but none named `origin`; multiple unrecognized remotes - **Case handling**: confirm sanitization-before-validation end-to-end (not just unit tested) - **Windows**: test config path construction; test that no hardcoded `/` separators sneak in For each case, either confirm it's already covered by an earlier step's tests or add a new test here. **📋 Final documentation pass**: Once hardening is done, update DESIGN.md and TESTING.md to reflect anything discovered during implementation that changed the approach. Note any cases deferred to post-MVP. --- ## Step 14: Release packaging and CI/CD refinement **Goal**: Ship a proper v1.0.0 to crates.io, set up automated release tooling, and harden the CI pipeline for ongoing maintenance. This step happens after the codebase is feature-complete and stabilized on a `main` branch, once development has moved to a dedicated branch workflow. ### Branch strategy (prerequisite) Before this step, establish the long-term branch model: - `main` — stable, always releasable; protected branch - `dev` (or `next`) — integration branch; PRs target this first - Feature branches off `dev`; merge to `dev`; promote `dev` → `main` for releases CI should run on both `main` and `dev` (and all PRs targeting either). ### Cargo.toml metadata Fill in the fields required by crates.io before first publish: ```toml [package] description = "Easy setup for mirroring GitHub repos to Tangled.org" license = "MIT" # or "MIT OR Apache-2.0" — decide before publishing repository = "https://github.com/cyrusae/entangle" homepage = "https://github.com/cyrusae/entangle" documentation = "https://docs.rs/entangle" keywords = ["git", "tangled", "mirror", "forge", "atproto"] categories = ["command-line-utilities", "development-tools"] readme = "README.md" ``` Run `cargo publish --dry-run` to catch any missing fields before the real publish. ### release-plz [release-plz](https://release-plz.dev) automates: version bumps from Conventional Commits, CHANGELOG.md updates, GitHub releases, and crates.io publishing — all triggered by merging to `main`. Setup steps: 1. Add the `release-plz` GitHub Actions workflow (see release-plz docs for the standard snippet) 2. Generate a crates.io API token; add it as `CARGO_REGISTRY_TOKEN` in repo secrets 3. Grant the workflow permission to push tags and create GitHub releases (`contents: write`, `pull-requests: write`) 4. Configure `.release-plz.toml` if non-default behavior is needed (e.g., to control which workspace members are published) Because chainlink already writes `CHANGELOG.md` entries per-issue, the changelog release-plz generates will build on that foundation — review the format expectations and align if needed. ### CI/CD pipeline refinements Additions to the GitHub Actions workflow once release infra is in place: - **`cargo deny`**: license compatibility and known-vulnerability audit on every PR (`cargo-deny` action, `deny.toml`) - **Coverage reporting**: `cargo llvm-cov` → upload to Codecov or similar; add a badge to README - **Minimum Supported Rust Version (MSRV)**: decide on a floor (e.g., stable-2 releases); add an `msrv` job that builds on that version - **Release job**: triggered only on version tags (`v*`); runs `cargo publish` with `CARGO_REGISTRY_TOKEN` - **`cargo doc`**: build and publish docs to GitHub Pages on every `main` push ### Pre-publish checklist - [ ] `cargo publish --dry-run` passes - [ ] All public items have documentation comments - [ ] README covers installation (`cargo install entangle`), quickstart, and all four subcommands - [ ] CHANGELOG.md has a clean v1.0.0 entry - [ ] License file present and correct - [ ] No `todo!()`, `unimplemented!()`, or `#[allow(dead_code)]` remaining - [ ] Windows tested (CI matrix green) - [ ] `cargo deny check` passes (no GPL dependencies, no known CVEs) **Tests**: No new unit/integration tests in this step — the test suite already exists. The "test" here is that CI is fully green on the release commit and `cargo publish --dry-run` reports no errors.