|
| 1 | +# gobby-core Development Guide |
| 2 | + |
| 3 | +Technical internals for developers and agents working in the `gobby-core` crate (`crates/gcore/`). |
| 4 | + |
| 5 | +## What gobby-core Is |
| 6 | + |
| 7 | +`gobby-core` is a small, dependency-light shared-primitives crate consumed by every Gobby CLI binary (`gcode`, `gsqz`, `gloc`, `ghook`). It exists so the binaries don't reimplement the same project-discovery and daemon-addressing logic four times — and so a behavior change (e.g. how the daemon URL is normalized) propagates with one PR instead of four. |
| 8 | + |
| 9 | +It has no CLI. It has no public state. It's a library — that's the whole shape. |
| 10 | + |
| 11 | +## Module Map |
| 12 | + |
| 13 | +`crates/gcore/src/`: |
| 14 | + |
| 15 | +| Module | Responsibility | |
| 16 | +|--------|----------------| |
| 17 | +| `project` | Walk up from a starting directory to find a `.gobby/` directory containing `project.json` or `gcode.json`. Read the `id` (or legacy `project_id`) field from `project.json`. | |
| 18 | +| `bootstrap` | Read `~/.gobby/bootstrap.yaml` to get the daemon's listen endpoint (`bind_host`, `daemon_port`). Falls back to `127.0.0.1:60887` when the file is missing or malformed. | |
| 19 | +| `daemon_url` | Compose a dial URL from a `DaemonEndpoint`, normalizing wildcard listen addresses (`0.0.0.0`, `::`, `::0`) to `127.0.0.1`. | |
| 20 | + |
| 21 | +Roughly 250 lines of source total. Adding a fourth module should require justification. |
| 22 | + |
| 23 | +## Public API |
| 24 | + |
| 25 | +### `project` |
| 26 | + |
| 27 | +```rust |
| 28 | +pub fn find_project_root(start: &Path) -> Option<PathBuf>; |
| 29 | +pub fn read_project_id(project_root: &Path) -> anyhow::Result<String>; |
| 30 | +``` |
| 31 | + |
| 32 | +`find_project_root` walks up from `start` looking for a `.gobby/project.json` (Gobby-managed) or `.gobby/gcode.json` (gcode-standalone). Returns the directory *containing* `.gobby/`, not `.gobby/` itself. Returns `None` when neither marker is found before hitting the filesystem root. |
| 33 | + |
| 34 | +`read_project_id` reads `<root>/.gobby/project.json` and extracts the `id` field, falling back to the legacy `project_id` key. Errors if the file is missing, malformed, or the field isn't present. |
| 35 | + |
| 36 | +```rust |
| 37 | +let cwd = std::env::current_dir()?; |
| 38 | +if let Some(root) = gobby_core::project::find_project_root(&cwd) { |
| 39 | + let id = gobby_core::project::read_project_id(&root)?; |
| 40 | + println!("project {id} at {}", root.display()); |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +### `bootstrap` |
| 45 | + |
| 46 | +```rust |
| 47 | +pub const DEFAULT_DAEMON_PORT: u16 = 60887; |
| 48 | +pub const DEFAULT_BIND_HOST: &str = "127.0.0.1"; |
| 49 | + |
| 50 | +pub struct DaemonEndpoint { pub host: String, pub port: u16 } |
| 51 | + |
| 52 | +pub fn bootstrap_path() -> Option<PathBuf>; |
| 53 | +pub fn read_daemon_endpoint() -> DaemonEndpoint; |
| 54 | +pub fn read_daemon_endpoint_at(path: &Path) -> DaemonEndpoint; |
| 55 | +``` |
| 56 | + |
| 57 | +`read_daemon_endpoint` is the lookup callers want. `read_daemon_endpoint_at` exists for tests and for callers who already know the path. Both return `DaemonEndpoint::default()` (loopback + 60887) on any failure — missing file, unreadable, malformed YAML, missing fields, no home directory. **No errors are surfaced**; clients should always get *something* usable. |
| 58 | + |
| 59 | +`DaemonEndpoint` returns the raw endpoint as written. `0.0.0.0` and `::` are valid listen addresses but invalid dial addresses — normalization is the caller's job, or the `daemon_url` module's, not this one's. |
| 60 | + |
| 61 | +### `daemon_url` |
| 62 | + |
| 63 | +```rust |
| 64 | +pub fn daemon_url() -> String; |
| 65 | +pub fn daemon_url_at(path: &Path) -> String; |
| 66 | +``` |
| 67 | + |
| 68 | +Composes `http://{host}:{port}` from a bootstrap-derived endpoint, with one rewrite: wildcard listen hosts (`0.0.0.0`, `::`, `::0`) become `127.0.0.1`. Hostnames, named interfaces, and explicit IPv4/IPv6 literals pass through unchanged. |
| 69 | + |
| 70 | +```rust |
| 71 | +let url = gobby_core::daemon_url::daemon_url(); |
| 72 | +// "http://127.0.0.1:60887" for default bootstrap |
| 73 | +// "http://10.0.0.5:61234" if bootstrap has bind_host: 10.0.0.5 |
| 74 | +// "http://127.0.0.1:60887" if bootstrap has bind_host: 0.0.0.0 |
| 75 | +ureq::post(&format!("{url}/api/hooks/execute")).send_string(body)?; |
| 76 | +``` |
| 77 | + |
| 78 | +Bracketing IPv6 literals for URL embedding is **not** handled here — in practice `bootstrap.yaml` is always `localhost`, an IPv4 literal, or a wildcard. If that ever stops being true, this is the place to add it. |
| 79 | + |
| 80 | +## Why These Three Modules Specifically |
| 81 | + |
| 82 | +Each module exists because at least two binaries need exactly this logic, and getting it slightly wrong in one of them would silently misbehave: |
| 83 | + |
| 84 | +| Module | Consumers (today) | What goes wrong if duplicated | |
| 85 | +|--------|-------------------|-------------------------------| |
| 86 | +| `project` | `gcode`, `ghook` (and `gsqz`/`gloc` could use it) | Project discovery walks up across mounts, weird symlink loops, race conditions with `.gobby/` creation. One implementation = one set of edge cases. | |
| 87 | +| `bootstrap` | `gcode`, `ghook` | YAML field naming, fallback semantics. Easy for two implementations to disagree on whether a missing field is fatal. | |
| 88 | +| `daemon_url` | `ghook` (and `gcode` daemon RPC) | Wildcard-host normalization is non-obvious. A binary that POSTs to `0.0.0.0` will hang for the connect timeout instead of failing fast. | |
| 89 | + |
| 90 | +## Versioning Policy |
| 91 | + |
| 92 | +`gobby-core` is `0.x`. The contract: |
| 93 | + |
| 94 | +- **Patch bumps (0.1.x)** — bug fixes, doc changes, internal refactors with no public API change. |
| 95 | +- **Minor bumps (0.x.0)** — additive public API (new functions, new fields). Existing consumers stay compatible. |
| 96 | +- **Pre-1.0 breaking changes** — bump the minor and bump *every* consumer crate's gobby-core dep in the same release. Don't strand consumers on an old gobby-core. |
| 97 | + |
| 98 | +Consumers pin to a minor version (`gobby-core = "0.1"`) so patch updates are picked up automatically but additive changes require a coordinated bump. |
| 99 | + |
| 100 | +## How to Consume |
| 101 | + |
| 102 | +### In-tree (workspace crates) |
| 103 | + |
| 104 | +```toml |
| 105 | +[dependencies] |
| 106 | +gobby-core = { path = "../gcore", version = "0.1" } |
| 107 | +``` |
| 108 | + |
| 109 | +The `path` is for local workspace builds; `version` is required by `cargo publish` and gets used when consumers install the crate from crates.io. Don't drop the `version` field — `cargo publish` will reject the consumer's manifest. |
| 110 | + |
| 111 | +### Out-of-tree |
| 112 | + |
| 113 | +```toml |
| 114 | +[dependencies] |
| 115 | +gobby-core = "0.1" |
| 116 | +``` |
| 117 | + |
| 118 | +Resolves against crates.io. The crate has no opinionated dependencies — `anyhow`, `dirs`, `serde_json`, `serde_yaml`, and `tempfile` (dev-only). It will not pull in tokio, reqwest, tracing, or anything else heavy. |
| 119 | + |
| 120 | +## Adding a New Helper |
| 121 | + |
| 122 | +Before adding a module or function to `gobby-core`, check: |
| 123 | + |
| 124 | +1. **Do at least two binaries need it?** If only one does, keep it in that binary. |
| 125 | +2. **Is it dependency-light?** New deps in `gobby-core` propagate to *every* binary. Adding `tokio` here would 5x the binary size of `ghook` for zero benefit. If the helper needs heavy deps, it probably belongs in a separate shared crate. |
| 126 | +3. **Is it stateless or near-stateless?** `gobby-core` functions are pure or do narrow I/O (read one file, return result). A module that holds connection pools or background workers belongs elsewhere. |
| 127 | +4. **Is the public surface small?** Three functions + a `DaemonEndpoint` struct is the right order of magnitude. If you find yourself adding a builder, a config object, and an `init()` function, reconsider. |
| 128 | + |
| 129 | +If yes to all four, add the module: |
| 130 | + |
| 131 | +1. Create `crates/gcore/src/<name>.rs` with `//!` module docs. |
| 132 | +2. Add `pub mod <name>;` to `crates/gcore/src/lib.rs`. |
| 133 | +3. Write tests that pin behavior under the failure modes the consumer cares about (missing input, malformed input, edge-case values). |
| 134 | +4. Update this guide's module map. |
| 135 | +5. Bump `gobby-core` to the next minor version (`0.2.0`) since you're adding public API. |
| 136 | +6. Update consumer crates to use the new helper, replacing any duplicated implementation. Bump their versions too. |
| 137 | + |
| 138 | +## Testing |
| 139 | + |
| 140 | +Each module has `#[cfg(test)] mod tests` with `tempfile::tempdir()` for filesystem isolation: |
| 141 | + |
| 142 | +- **project**: implicitly tested via consumer binaries (`gcode`, `ghook`); the module mirrors `gcode/src/project.rs` line-for-line. |
| 143 | +- **bootstrap**: missing/malformed/empty files all return defaults; custom port/host parsing; out-of-range port falls back to default. |
| 144 | +- **daemon_url**: wildcard IPv4/IPv6 normalize to loopback; localhost passes through; custom host+port composes correctly. |
| 145 | + |
| 146 | +```bash |
| 147 | +cargo test -p gobby-core |
| 148 | +``` |
| 149 | + |
| 150 | +Fast, no I/O outside `tempdir()`, no network. Should run in well under a second. |
| 151 | + |
| 152 | +## Design Decisions |
| 153 | + |
| 154 | +### Why Infallible Defaults Instead of `Result` |
| 155 | + |
| 156 | +`read_daemon_endpoint` and friends return `DaemonEndpoint` (not `Result<DaemonEndpoint>`). The reasoning: |
| 157 | + |
| 158 | +- Every consumer wants *some* endpoint to dial. Erroring at startup because `~/.gobby/bootstrap.yaml` doesn't exist would force every binary to handle the error identically (fall back to loopback + 60887). Centralizing that fallback here is the right move. |
| 159 | +- The daemon defaults are well-known and stable. There's no "right" error message to surface — "use loopback" is always the answer. |
| 160 | +- If a binary genuinely needs to know whether the file existed (e.g. for a setup-wizard prompt), it can call `bootstrap_path()` and `Path::exists()` directly. |
| 161 | + |
| 162 | +`read_project_id` *does* return `Result` because there's no sane default for "I asked for a project ID and there isn't one" — the caller has to decide what that means. |
| 163 | + |
| 164 | +### Why Listen-Address Normalization Lives in `daemon_url`, Not `bootstrap` |
| 165 | + |
| 166 | +`bootstrap` returns the raw endpoint as written so callers can distinguish "user configured `0.0.0.0` for LAN exposure" from "user configured `127.0.0.1`." `daemon_url` is the layer concerned with *dialing*, so that's where the rewrite happens. Diagnostic tooling that wants to display the actual `bind_host` (e.g. `ghook --diagnose`) reads from `bootstrap` directly. |
| 167 | + |
| 168 | +### Why Not Re-Export from a Prelude |
| 169 | + |
| 170 | +There's no `gobby_core::prelude`. The crate is small enough that explicit imports (`use gobby_core::project::find_project_root`) are clearer than a glob. Keep it that way until the public surface grows past ~10 items. |
0 commit comments