diff --git a/docs/plans/2026-03-09-app-named-slugs-test-plan.md b/docs/plans/2026-03-09-app-named-slugs-test-plan.md new file mode 100644 index 0000000..99e3c29 --- /dev/null +++ b/docs/plans/2026-03-09-app-named-slugs-test-plan.md @@ -0,0 +1,250 @@ +# App-Named Slugs — Test Plan + +## Strategy reconciliation + +The agreed strategy was **Medium fidelity**: unit tests for repo name detection, slug composition, and ls formatting, plus update existing e2e tests. After reviewing the implementation plan against the codebase: + +- **Strategy holds.** The plan's architecture is straightforward: new pure functions in `config.rs`, wiring in `up.rs`, formatting in `ls.rs`. No new dependencies, no daemon-side changes, no cert changes. +- **One adjustment (no cost change):** The implementation plan's `ls` tests compare `route.slug` against a `current_slug` parameter. In the actual codebase, `RouteInfo.slug` contains the **full hostname** (e.g., `swift-penguin-devproxy.mysite.dev`), not just the compose project name. The plan already accounts for this (constructing `format!("{slug}.{}", config.domain)` in the `run()` function). The test plan below uses full hostnames in `RouteInfo.slug` to match reality. + +## Sources of truth + +- **S1**: Implementation plan (`docs/plans/2026-03-09-app-named-slugs.md`) — defines URL format, detection logic, composition rules, ls behavior +- **S2**: RFC 1035 Section 2.3.4 — DNS label limit of 63 characters +- **S3**: Git remote URL formats — HTTPS (`https://github.com/user/repo.git`) and SSH (`git@github.com:user/repo.git`) +- **S4**: Current codebase behavior — `RouteInfo.slug` contains full hostname, `.devproxy-project` stores compose project name + +--- + +## Test plan + +### 1. Scenario: Full workflow produces app-named URL and ls shows current marker + +- **Type**: scenario +- **Harness**: e2e test binary (`tests/e2e.rs`), requires Docker +- **Preconditions**: Test config dir with certs, running test daemon, fixture directory with git remote set to `https://github.com/test/e2e-fixture.git` +- **Actions**: + 1. Run `devproxy up` from the fixture directory + 2. Extract slug from output — should be `{adj}-{animal}-e2e-fixture` + 3. Verify `.devproxy-project` contains the composite slug + 4. Run `devproxy ls` from the fixture directory + 5. Verify ls output contains the composite slug + 6. Verify ls output contains `*` marker for current project + 7. Run curl through the proxy using the composite hostname + 8. Run `devproxy down` +- **Expected outcome**: + - Up output contains URL of form `https://{adj}-{animal}-e2e-fixture.test.devproxy.dev` [S1] + - `.devproxy-project` stores the composite slug [S1, S4] + - ls shows the composite slug with `*` marker when run from the fixture dir [S1] + - curl succeeds through the proxy using the composite hostname [S1] + - Down cleans up `.devproxy-project` and `.devproxy-override.yml` [S4] +- **Interactions**: Docker compose project naming, daemon route table, TLS cert wildcard matching + +### 2. Scenario: Self-healing works with composite slugs + +- **Type**: scenario +- **Harness**: e2e test binary (`tests/e2e.rs`), requires Docker +- **Preconditions**: Test config dir, running daemon, fixture with git remote +- **Actions**: + 1. Run `devproxy up`, extract composite slug + 2. Verify route appears in `devproxy ls` + 3. Kill container externally via `docker compose kill` + 4. Wait for event watcher to process + 5. Verify route removed from `devproxy ls` +- **Expected outcome**: Route with composite slug is added on start and removed on die [S1, S4] +- **Interactions**: Docker event watcher reads `com.docker.compose.project` label which now contains the composite slug + +### 3. Scenario: Daemon restart rebuilds routes with composite slugs + +- **Type**: scenario +- **Harness**: e2e test binary (`tests/e2e.rs`), requires Docker +- **Preconditions**: Test config dir, running daemon, fixture with git remote, container running +- **Actions**: + 1. Run `devproxy up`, extract composite slug + 2. Kill daemon process + 3. Start new daemon + 4. Verify composite slug appears in `devproxy ls` +- **Expected outcome**: Route with composite slug is rebuilt from running container [S1, S4] +- **Interactions**: Docker inspect reads `com.docker.compose.project` which contains composite slug + +### 4. Integration: ls without current project shows no marker + +- **Type**: integration +- **Harness**: e2e test binary (`tests/e2e.rs`), requires Docker +- **Preconditions**: Test config dir, running daemon, container running via `devproxy up` from fixture dir +- **Actions**: + 1. Run `devproxy ls` from a **different** directory (not the fixture dir) +- **Expected outcome**: ls output shows the route but without `*` marker [S1] +- **Interactions**: ls reads `.devproxy-project` from cwd — should fail silently when not present + +### 5. Unit: extract_repo_name from HTTPS URL + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `extract_repo_name("https://github.com/user/repo.git")` +- **Expected outcome**: Returns `Some("repo")` [S3] +- **Interactions**: None + +### 6. Unit: extract_repo_name from SSH URL + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `extract_repo_name("git@github.com:user/repo.git")` +- **Expected outcome**: Returns `Some("repo")` [S3] +- **Interactions**: None + +### 7. Unit: extract_repo_name from URL without .git suffix + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `extract_repo_name("https://github.com/user/repo")` +- **Expected outcome**: Returns `Some("repo")` [S3] +- **Interactions**: None + +### 8. Unit: detect_app_name from git remote (HTTPS) + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs`, creates temp dir with `git init` + remote +- **Preconditions**: Temp dir initialized as git repo with HTTPS remote `https://github.com/user/my-cool-app.git` +- **Actions**: Call `detect_app_name(dir)` +- **Expected outcome**: Returns `"my-cool-app"` [S1, S3] +- **Interactions**: Spawns `git` subprocess + +### 9. Unit: detect_app_name from git remote (SSH) + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs`, creates temp dir with `git init` + remote +- **Preconditions**: Temp dir initialized as git repo with SSH remote `git@github.com:user/another-app.git` +- **Actions**: Call `detect_app_name(dir)` +- **Expected outcome**: Returns `"another-app"` [S1, S3] +- **Interactions**: Spawns `git` subprocess + +### 10. Unit: detect_app_name falls back to directory name + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs`, creates temp dir without git +- **Preconditions**: Temp dir named `my-project`, no git repo +- **Actions**: Call `detect_app_name(dir)` +- **Expected outcome**: Returns `"my-project"` [S1] +- **Interactions**: Spawns `git` subprocess (which fails), then reads dir name + +### 11. Unit: detect_app_name sanitizes directory name + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: Temp dir named `My Cool App!!!` +- **Actions**: Call `detect_app_name(dir)` +- **Expected outcome**: Returns `"my-cool-app"` [S1] +- **Interactions**: Spawns `git` subprocess (fails), sanitizes dir name + +### 12. Unit: sanitize_subdomain basic case + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `sanitize_subdomain("My Cool App!!!")` +- **Expected outcome**: Returns `"my-cool-app"` [S1] +- **Interactions**: None + +### 13. Unit: sanitize_subdomain already clean + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `sanitize_subdomain("my-app")` +- **Expected outcome**: Returns `"my-app"` [S1] +- **Interactions**: None + +### 14. Unit: sanitize_subdomain does not truncate + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `sanitize_subdomain(&"a".repeat(100))` +- **Expected outcome**: Returns string of length 100 [S1 — truncation is compose_slug's job] +- **Interactions**: None + +### 15. Unit: compose_slug basic case + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `compose_slug("swift-penguin", "devproxy")` +- **Expected outcome**: Returns `"swift-penguin-devproxy"` [S1] +- **Interactions**: None + +### 16. Boundary: compose_slug truncates to 63 chars + +- **Type**: boundary +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `compose_slug("swift-penguin", &"a".repeat(100))` +- **Expected outcome**: Result length <= 63, does not end with `-`, starts with `"swift-penguin-"` [S1, S2] +- **Interactions**: None + +### 17. Unit: compose_slug normal lengths not truncated + +- **Type**: unit +- **Harness**: `#[test]` in `config.rs` +- **Preconditions**: None +- **Actions**: Call `compose_slug("bold-fox", "my-cool-app")` +- **Expected outcome**: Returns `"bold-fox-my-cool-app"` [S1] +- **Interactions**: None + +### 18. Unit: format_route_line with current marker + +- **Type**: unit +- **Harness**: `#[test]` in `ls.rs` +- **Preconditions**: None +- **Actions**: Call `format_route_line` with a route whose slug matches the current slug +- **Expected outcome**: Output contains `*` [S1] +- **Interactions**: None + +### 19. Unit: format_route_line without current marker + +- **Type**: unit +- **Harness**: `#[test]` in `ls.rs` +- **Preconditions**: None +- **Actions**: Call `format_route_line` with a route whose slug does NOT match the current slug +- **Expected outcome**: Output does not contain `*` [S1] +- **Interactions**: None + +### 20. Unit: format_route_line with no current project + +- **Type**: unit +- **Harness**: `#[test]` in `ls.rs` +- **Preconditions**: None +- **Actions**: Call `format_route_line` with `current_slug = None` +- **Expected outcome**: Output does not contain `*` [S1] +- **Interactions**: None + +--- + +## Coverage summary + +### Covered + +- App name detection from git remote (HTTPS and SSH formats) +- App name fallback to directory name +- Subdomain sanitization (special chars, case, consecutive hyphens) +- Composite slug composition (join and truncation) +- DNS label length limit (63 chars) enforcement +- ls current-directory `*` marker (match, no match, no project) +- Full e2e workflow with composite slugs (up, ls, curl, down) +- Self-healing with composite slugs +- Daemon restart route rebuilding with composite slugs +- ls behavior from non-project directory + +### Explicitly excluded per strategy + +- **Daemon-side routing logic**: The plan explicitly states no daemon changes are needed. The composite slug flows through `com.docker.compose.project` label which the daemon already reads. Covered indirectly by e2e tests. +- **TLS cert changes**: No cert changes needed (single subdomain level maintained). Verified indirectly by curl in e2e test. +- **Performance**: No performance-critical changes. The only new work is one `git` subprocess call during `devproxy up`, which is already dominated by `docker compose up` time. + +### Risks of exclusions + +- If the composite slug somehow exceeds the wildcard cert's matching, the e2e curl test would catch this. +- If Docker Compose handles the longer project name differently (e.g., container naming), the e2e tests would catch this since they run real Docker containers. diff --git a/docs/plans/2026-03-09-app-named-slugs.md b/docs/plans/2026-03-09-app-named-slugs.md new file mode 100644 index 0000000..ef3d055 --- /dev/null +++ b/docs/plans/2026-03-09-app-named-slugs.md @@ -0,0 +1,452 @@ +# App-Named Slugs — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. + +**Goal:** Make devproxy URLs include the app/repo name so users can identify which project a URL belongs to. Change the URL format from `https://{slug}.{domain}` to `https://{slug}-{app-name}.{domain}` where `{app-name}` is derived from the git remote origin (GitHub repo name), falling back to the directory name. Also enhance `devproxy ls` to mark the current directory's project with `*`. + +**Architecture:** + +The URL format changes from `https://swift-penguin.mysite.dev` to `https://swift-penguin-devproxy.mysite.dev` where `devproxy` is the app name (derived from the repo name of the cwd where `devproxy up` was run). Multiple instances of the same repo (e.g., worktrees) get different random slugs but share the same app-name suffix: `https://bold-fox-devproxy.mysite.dev`, `https://calm-otter-devproxy.mysite.dev`. + +**Why a single subdomain level:** RFC 6125 Section 6.4.3 specifies that wildcard certificates (`*.domain`) only match a single DNS label. The existing TLS cert uses `*.mysite.dev` which matches `anything.mysite.dev` but would NOT match `a.b.mysite.dev`. By keeping the format as `{slug}-{appname}.{domain}` (a single subdomain label), the existing wildcard cert works without modification. No cert changes are needed. + +**Key mechanism:** The `devproxy up` command detects the app name and combines it with the random slug to form the Docker Compose project name: `{slug}-{app-name}` (e.g., `swift-penguin-devproxy`). This composite name is used as the `--project-name` for `docker compose`, which means the `com.docker.compose.project` label on the container already contains the full routing key. The daemon's docker.rs reads this label and inserts it into the router as before — no daemon-side changes needed. + +This is the simplest possible approach: the app name is baked into the compose project name, which Docker propagates as a label, which the daemon already reads for routing. No new labels, no docker.rs changes, no cert changes. + +**App name detection:** A new function `detect_app_name(dir: &Path) -> Result` in `config.rs`: +1. Run `git -C {dir} remote get-url origin` and parse the repo name from the URL (handles both HTTPS and SSH formats). Strip `.git` suffix if present. +2. If git fails or there's no remote, fall back to the directory name. +3. Sanitize the result: lowercase, replace non-alphanumeric chars with hyphens, collapse consecutive hyphens, trim leading/trailing hyphens. No truncation at this stage — truncation happens when composing the full slug. + +**Composite slug and DNS label limit:** A new function `compose_slug(random_slug: &str, app_name: &str) -> String` in `config.rs` joins them as `{random_slug}-{app_name}` and then truncates the result to 63 characters (the RFC 1035 DNS label limit), trimming any trailing hyphen that the truncation might produce. The longest random slug is `bright-penguin` (14 chars); typical app names are short (e.g., `devproxy` = 8 chars, composite = 23 chars). Truncation only fires for unusually long app names. + +**Compose project name:** Currently `up.rs` generates a random slug (e.g., `swift-penguin`) and uses it as `--project-name`. The new behavior calls `compose_slug(&random_slug, &app_name)` to form the composite (e.g., `swift-penguin-devproxy`) and uses that as `--project-name`. The `.devproxy-project` file stores this composite name. The daemon reads `com.docker.compose.project` which already equals this composite name — no daemon changes needed. + +**`.devproxy-project` file:** No format change needed. The file continues to store the compose project name (now the composite `{slug}-{app-name}`). The `read_project_file` and `write_project_file` signatures remain the same. + +**`devproxy ls` current-directory indicator:** The `ls` command reads `.devproxy-project` from the cwd (if it exists, failing silently if not) to get the current project's composite slug. When printing routes, any route matching the cwd's slug gets a `*` prefix. + +**Tech Stack:** No new dependencies. Uses `std::process::Command` to run `git`. + +--- + +### Task 1: Add `detect_app_name()` and helpers to `config.rs` + +**Files:** +- Modify: `src/config.rs` + +**Step 1: Write the failing tests** + +Add unit tests for app name detection, repo name extraction, and subdomain sanitization: + +```rust +#[cfg(test)] +mod tests { + // ... existing tests ... + + #[test] + fn detect_app_name_from_git_remote_https() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["remote", "add", "origin", "https://github.com/user/my-cool-app.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let name = detect_app_name(dir.path()).unwrap(); + assert_eq!(name, "my-cool-app"); + } + + #[test] + fn detect_app_name_from_git_remote_ssh() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["remote", "add", "origin", "git@github.com:user/another-app.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let name = detect_app_name(dir.path()).unwrap(); + assert_eq!(name, "another-app"); + } + + #[test] + fn detect_app_name_falls_back_to_dir_name() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("my-project"); + std::fs::create_dir_all(&sub).unwrap(); + let name = detect_app_name(&sub).unwrap(); + assert_eq!(name, "my-project"); + } + + #[test] + fn detect_app_name_sanitizes() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("My Cool App!!!"); + std::fs::create_dir_all(&sub).unwrap(); + let name = detect_app_name(&sub).unwrap(); + assert_eq!(name, "my-cool-app"); + } + + #[test] + fn extract_repo_name_https() { + assert_eq!( + extract_repo_name("https://github.com/user/repo.git"), + Some("repo".to_string()) + ); + } + + #[test] + fn extract_repo_name_ssh() { + assert_eq!( + extract_repo_name("git@github.com:user/repo.git"), + Some("repo".to_string()) + ); + } + + #[test] + fn extract_repo_name_no_git_suffix() { + assert_eq!( + extract_repo_name("https://github.com/user/repo"), + Some("repo".to_string()) + ); + } + + #[test] + fn sanitize_subdomain_basic() { + assert_eq!(sanitize_subdomain("My Cool App!!!"), "my-cool-app"); + } + + #[test] + fn sanitize_subdomain_already_clean() { + assert_eq!(sanitize_subdomain("my-app"), "my-app"); + } + + #[test] + fn sanitize_subdomain_does_not_truncate() { + let long_name = "a".repeat(100); + let result = sanitize_subdomain(&long_name); + assert_eq!(result.len(), 100, "sanitize_subdomain should not truncate"); + } + + #[test] + fn compose_slug_basic() { + assert_eq!(compose_slug("swift-penguin", "devproxy"), "swift-penguin-devproxy"); + } + + #[test] + fn compose_slug_truncates_to_63_chars() { + let long_app = "a".repeat(100); + let result = compose_slug("swift-penguin", &long_app); + assert!(result.len() <= 63, "composite slug must fit in a DNS label: len={}", result.len()); + assert!(!result.ends_with('-'), "should not end with hyphen after truncation"); + assert!(result.starts_with("swift-penguin-"), "should preserve the random slug prefix"); + } + + #[test] + fn compose_slug_normal_lengths_not_truncated() { + let result = compose_slug("bold-fox", "my-cool-app"); + assert_eq!(result, "bold-fox-my-cool-app"); + } +} +``` + +**Step 2: Implement** + +```rust +/// Detect the app name for the given directory. +/// Tries git remote origin first (extracts repo name from URL), +/// falls back to directory name. Result is sanitized for use in a subdomain label. +pub fn detect_app_name(dir: &Path) -> Result { + // Try git remote + if let Ok(output) = std::process::Command::new("git") + .args(["-C", &dir.to_string_lossy(), "remote", "get-url", "origin"]) + .output() + { + if output.status.success() { + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if let Some(name) = extract_repo_name(&url) { + return Ok(sanitize_subdomain(&name)); + } + } + } + + // Fall back to directory name + let dir_name = dir + .file_name() + .context("directory has no name")? + .to_string_lossy() + .to_string(); + Ok(sanitize_subdomain(&dir_name)) +} + +/// Extract the repository name from a git remote URL. +/// Handles HTTPS (https://github.com/user/repo.git) and SSH (git@github.com:user/repo.git). +fn extract_repo_name(url: &str) -> Option { + // For SSH URLs like git@github.com:user/repo.git, split on ':' first then '/' + let path_part = if url.contains("://") { + url.split('/').last()? + } else if let Some(after_colon) = url.split(':').nth(1) { + after_colon.split('/').last()? + } else { + return None; + }; + let name = path_part.strip_suffix(".git").unwrap_or(path_part); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +/// Sanitize a string for use as part of a DNS subdomain label: +/// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens, +/// trim leading/trailing hyphens. Does NOT truncate — callers that need length +/// enforcement should use `compose_slug` which truncates the composite result. +fn sanitize_subdomain(s: &str) -> String { + let lower = s.to_lowercase(); + let replaced: String = lower + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + replaced + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Compose a DNS-safe slug from a random slug and app name. +/// Joins as `{random_slug}-{app_name}` and truncates the result to 63 characters +/// (the RFC 1035 DNS label limit), trimming any trailing hyphen. +pub fn compose_slug(random_slug: &str, app_name: &str) -> String { + let composite = format!("{random_slug}-{app_name}"); + if composite.len() <= 63 { + return composite; + } + composite[..63].trim_end_matches('-').to_string() +} +``` + +**Step 3: Verify** + +Run `cargo test config::tests` — all new and existing tests should pass. + +--- + +### Task 2: Update `up.rs` to detect app name and form composite slug + +**Files:** +- Modify: `src/commands/up.rs` + +**Step 1: No new unit tests needed** — this is wiring. The unit tests for `detect_app_name` and the e2e test cover the behavior. + +**Step 2: Implement** + +Change slug generation from: +```rust +let slug = slugs::generate_slug(); +``` +to: +```rust +let app_name = config::detect_app_name(&cwd)?; +eprintln!("app: {}", app_name.cyan()); + +let random_slug = slugs::generate_slug(); +let slug = config::compose_slug(&random_slug, &app_name); +eprintln!("slug: {}", slug.cyan()); +``` + +Everything downstream already uses `slug` as the compose project name and the value written to `.devproxy-project`. The URL output line changes from: +```rust +let url = format!("https://{slug}.{}", config.domain); +``` +This remains correct since `slug` is now the composite `swift-penguin-devproxy`. + +No signature changes to `write_project_file`, `write_override_file`, or any other function. + +**Step 3: Verify** + +`cargo build` succeeds. The `test_up_without_label` and `test_up_without_compose_file` e2e tests still pass (they fail before reaching slug generation). + +--- + +### Task 3: Update `ls.rs` to show `*` indicator for current directory's project + +**Files:** +- Modify: `src/commands/ls.rs` + +**Step 1: Write the failing tests** + +Add a unit test module for the formatting logic. The `format_route_line` function is extracted to be testable independently of IPC: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::ipc::RouteInfo; + + #[test] + fn format_route_with_current_marker() { + let route = RouteInfo { + slug: "swift-penguin-devproxy.mysite.dev".to_string(), + port: 51234, + }; + let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); + assert!(line.contains("*"), "current project should have * marker: {line}"); + } + + #[test] + fn format_route_without_current_marker() { + let route = RouteInfo { + slug: "bold-fox-devproxy.mysite.dev".to_string(), + port: 51235, + }; + let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); + assert!(!line.contains("*"), "non-current project should not have * marker: {line}"); + } + + #[test] + fn format_route_no_current_project() { + let route = RouteInfo { + slug: "swift-penguin-devproxy.mysite.dev".to_string(), + port: 51234, + }; + let line = format_route_line(&route, None); + assert!(!line.contains("*"), "no current project means no marker: {line}"); + } +} +``` + +**Step 2: Implement** + +Refactor `ls.rs` to: +1. Extract a `format_route_line` function that takes a route and optional current slug. +2. In `run()`, attempt to read `.devproxy-project` from the cwd (silently ignore failures) and construct the full hostname to match against. +3. Print each route using `format_route_line`, prepending `*` for the current project or a space for others. + +```rust +use crate::config::{self, Config}; +use crate::ipc::{self, Request, Response, RouteInfo}; +use anyhow::{Result, bail}; +use colored::Colorize; + +/// Format a single route line, with optional `*` marker for current project. +fn format_route_line(route: &RouteInfo, current_slug: Option<&str>) -> String { + let marker = match current_slug { + Some(s) if s == route.slug => "* ", + _ => " ", + }; + format!( + "{}{:<40} {:<10}", + marker, + format!("https://{}", route.slug), + route.port + ) +} + +pub async fn run() -> Result<()> { + let socket_path = Config::socket_path()?; + let response = ipc::send_request(&socket_path, &Request::List).await?; + + // Try to read current project slug from cwd (silently ignore failures) + let current_slug = std::env::current_dir() + .ok() + .and_then(|cwd| config::read_project_file(&cwd).ok()) + .and_then(|slug| { + let config = Config::load().ok()?; + Some(format!("{slug}.{}", config.domain)) + }); + + match response { + Response::Routes { routes } => { + if routes.is_empty() { + println!("no active projects"); + } else { + println!(" {:<40} {:<10}", "URL".bold(), "PORT".bold()); + for route in &routes { + println!("{}", format_route_line(route, current_slug.as_deref())); + } + println!(); + println!("{} active project(s)", routes.len()); + } + } + Response::Error { message } => bail!("daemon error: {message}"), + _ => bail!("unexpected response from daemon"), + } + + Ok(()) +} +``` + +Note: The column header changes from `SLUG` to `URL` since the displayed value is now the full `https://` URL which includes the app name. The column width increases from 30 to 40 to accommodate longer composite slugs. + +**Step 3: Verify** + +Run `cargo test ls::tests` — all 3 tests pass. + +--- + +### Task 4: Update e2e tests + +**Files:** +- Modify: `tests/e2e.rs` + +**Step 1: Update existing e2e tests** + +The `test_full_e2e_workflow` test extracts the slug from `up` output by splitting on the first dot. This still works because the composite slug (`swift-penguin-devproxy`) is the first dot-separated component of `swift-penguin-devproxy.test.devproxy.dev`. + +However, several things need updating: +1. The slug is now composite (e.g., `swift-penguin-devproxy`) — it includes the app name suffix derived from the fixtures directory name. Since `copy_fixtures` copies into a temp dir with a name like `devproxy-fixtures-e2e-{pid}`, the app name will be derived from that directory name (there's no git remote in the fixture copy). Account for this in assertions. +2. The `ls` output now shows `*` for the current directory's project. Add an assertion for this. +3. The `curl` resolve and host must use the composite slug. + +The e2e test runs `devproxy up` from the fixtures directory. Since the fixtures directory has no git remote, `detect_app_name` falls back to the directory name. The copy is at a path like `/tmp/devproxy-fixtures-e2e-{pid}`, so the sanitized directory name becomes something like `devproxy-fixtures-e2e-{pid}`. The slug in the `up` output will be `{adj}-{animal}-devproxy-fixtures-e2e-{pid}`. + +To make e2e testing cleaner, we can initialize a git repo with a known remote in the fixture copy. Add this after `copy_fixtures`: + +```rust +// Initialize a git repo with a known remote so detect_app_name is predictable +std::process::Command::new("git") + .args(["init"]) + .current_dir(&fixtures) + .output() + .expect("git init failed"); +std::process::Command::new("git") + .args(["remote", "add", "origin", "https://github.com/test/e2e-fixture.git"]) + .current_dir(&fixtures) + .output() + .expect("git remote add failed"); +``` + +Then the app name will always be `e2e-fixture` and the slug will be like `swift-penguin-e2e-fixture`. + +Update slug extraction to handle the composite format. The existing extraction (`split('.').next()`) already returns the full first label, which is now `swift-penguin-e2e-fixture`. Update assertions accordingly. + +For the `ls` check from the fixture directory, verify that the `*` indicator appears: +```rust +let ls_output = Command::new(devproxy_bin()) + .args(["ls"]) + .current_dir(&fixtures) // Run from fixtures dir to get * indicator + .env("DEVPROXY_CONFIG_DIR", &config_dir) + .output() + .expect("failed to run devproxy ls"); +let ls_stdout = String::from_utf8_lossy(&ls_output.stdout); +assert!(ls_stdout.contains(slug), "ls should show our slug '{slug}': {ls_stdout}"); +assert!(ls_stdout.contains("*"), "ls should show * for current project: {ls_stdout}"); +``` + +Also update the `test_self_healing_route_removed_on_container_die` and `test_daemon_restart_rebuilds_routes` tests similarly (add git init + remote to fixture copies, update slug extraction). + +**Step 2: Verify** + +Run `cargo test --test e2e` (non-ignored) to verify compilation. Full e2e: `cargo test --test e2e -- --ignored test_full_e2e_workflow`. diff --git a/src/commands/ls.rs b/src/commands/ls.rs index c9f3079..ff4f2f0 100644 --- a/src/commands/ls.rs +++ b/src/commands/ls.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::config::{self, Config}; use crate::ipc::{self, Request, Response}; use anyhow::{Result, bail}; use colored::Colorize; @@ -7,17 +7,39 @@ pub async fn run() -> Result<()> { let socket_path = Config::socket_path()?; let response = ipc::send_request(&socket_path, &Request::List).await?; + // Try to read current project slug from cwd (silently ignore failures) + let current_slug = std::env::current_dir() + .ok() + .and_then(|cwd| config::read_project_file(&cwd).ok()) + .and_then(|slug| { + let config = Config::load().ok()?; + Some(format!("{slug}.{}", config.domain)) + }); + match response { Response::Routes { routes } => { if routes.is_empty() { println!("no active projects"); } else { - println!("{:<30} {:<10}", "SLUG".bold(), "PORT".bold()); + // Compute column width dynamically based on longest URL + let url_width = routes + .iter() + .map(|r| format!("https://{}", r.slug).len()) + .max() + .unwrap_or(3) + .max(3); // at least "URL".len() + + println!(" {: Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use crate::ipc::RouteInfo; + + /// Format a single route line, with optional `*` marker for current project. + /// Used only in tests to verify marker logic independently of colored output. + fn format_route_line(route: &RouteInfo, current_slug: Option<&str>) -> String { + let marker = match current_slug { + Some(s) if s == route.slug => "* ", + _ => " ", + }; + let url = format!("https://{}", route.slug); + format!("{}{} {}", marker, url, route.port) + } + + #[test] + fn format_route_with_current_marker() { + let route = RouteInfo { + slug: "swift-penguin-devproxy.mysite.dev".to_string(), + port: 51234, + }; + let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); + assert!(line.starts_with("* "), "current project should have * marker: {line}"); + } + + #[test] + fn format_route_without_current_marker() { + let route = RouteInfo { + slug: "bold-fox-devproxy.mysite.dev".to_string(), + port: 51235, + }; + let line = format_route_line(&route, Some("swift-penguin-devproxy.mysite.dev")); + assert!(line.starts_with(" "), "non-current project should not have * marker: {line}"); + } + + #[test] + fn format_route_no_current_project() { + let route = RouteInfo { + slug: "swift-penguin-devproxy.mysite.dev".to_string(), + port: 51234, + }; + let line = format_route_line(&route, None); + assert!(line.starts_with(" "), "no current project means no marker: {line}"); + } +} diff --git a/src/commands/up.rs b/src/commands/up.rs index 86ec994..96ce888 100644 --- a/src/commands/up.rs +++ b/src/commands/up.rs @@ -28,8 +28,12 @@ pub fn run() -> Result<()> { container_port.to_string().cyan() ); - // Generate slug - let slug = slugs::generate_slug(); + // Detect app name and generate composite slug + let app_name = config::detect_app_name(&cwd)?; + eprintln!("app: {}", app_name.cyan()); + + let random_slug = slugs::generate_slug(); + let slug = config::compose_slug(&random_slug, &app_name); eprintln!("slug: {}", slug.cyan()); // Find free port diff --git a/src/config.rs b/src/config.rs index 61cdebe..4946423 100644 --- a/src/config.rs +++ b/src/config.rs @@ -225,6 +225,77 @@ pub fn read_project_file(dir: &Path) -> Result { Ok(content.trim().to_string()) } +/// Detect the app name for the given directory. +/// Tries git remote origin first (extracts repo name from URL), +/// falls back to directory name. Result is sanitized for use in a subdomain label. +pub fn detect_app_name(dir: &Path) -> Result { + // Try git remote + if let Ok(output) = std::process::Command::new("git") + .args(["-C", &dir.to_string_lossy(), "remote", "get-url", "origin"]) + .output() + && output.status.success() + { + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if let Some(name) = extract_repo_name(&url) { + return Ok(sanitize_subdomain(&name)); + } + } + + // Fall back to directory name + let dir_name = dir + .file_name() + .context("directory has no name")? + .to_string_lossy() + .to_string(); + Ok(sanitize_subdomain(&dir_name)) +} + +/// Extract the repository name from a git remote URL. +/// Handles HTTPS (https://github.com/user/repo.git) and SSH (git@github.com:user/repo.git). +fn extract_repo_name(url: &str) -> Option { + let path_part = if url.contains("://") { + url.split('/').next_back()? + } else if let Some(after_colon) = url.split(':').nth(1) { + after_colon.split('/').next_back()? + } else { + return None; + }; + let name = path_part.strip_suffix(".git").unwrap_or(path_part); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +/// Sanitize a string for use as part of a DNS subdomain label: +/// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens, +/// trim leading/trailing hyphens. Does NOT truncate — callers that need length +/// enforcement should use `compose_slug` which truncates the composite result. +fn sanitize_subdomain(s: &str) -> String { + let lower = s.to_lowercase(); + let replaced: String = lower + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + replaced + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Compose a DNS-safe slug from a random slug and app name. +/// Joins as `{random_slug}-{app_name}` and truncates the result to 63 characters +/// (the RFC 1035 DNS label limit), trimming any trailing hyphen. +pub fn compose_slug(random_slug: &str, app_name: &str) -> String { + let composite = format!("{random_slug}-{app_name}"); + if composite.len() <= 63 { + return composite; + } + composite.chars().take(63).collect::().trim_end_matches('-').to_string() +} + /// Find a free ephemeral port pub fn find_free_port() -> Result { let listener = std::net::TcpListener::bind("127.0.0.1:0")?; @@ -380,4 +451,117 @@ services: .contains("no docker-compose.yml") ); } + + #[test] + fn detect_app_name_from_git_remote_https() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["remote", "add", "origin", "https://github.com/user/my-cool-app.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let name = detect_app_name(dir.path()).unwrap(); + assert_eq!(name, "my-cool-app"); + } + + #[test] + fn detect_app_name_from_git_remote_ssh() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["remote", "add", "origin", "git@github.com:user/another-app.git"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let name = detect_app_name(dir.path()).unwrap(); + assert_eq!(name, "another-app"); + } + + #[test] + fn detect_app_name_falls_back_to_dir_name() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("my-project"); + std::fs::create_dir_all(&sub).unwrap(); + let name = detect_app_name(&sub).unwrap(); + assert_eq!(name, "my-project"); + } + + #[test] + fn detect_app_name_sanitizes() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("My Cool App!!!"); + std::fs::create_dir_all(&sub).unwrap(); + let name = detect_app_name(&sub).unwrap(); + assert_eq!(name, "my-cool-app"); + } + + #[test] + fn extract_repo_name_https() { + assert_eq!( + extract_repo_name("https://github.com/user/repo.git"), + Some("repo".to_string()) + ); + } + + #[test] + fn extract_repo_name_ssh() { + assert_eq!( + extract_repo_name("git@github.com:user/repo.git"), + Some("repo".to_string()) + ); + } + + #[test] + fn extract_repo_name_no_git_suffix() { + assert_eq!( + extract_repo_name("https://github.com/user/repo"), + Some("repo".to_string()) + ); + } + + #[test] + fn sanitize_subdomain_basic() { + assert_eq!(sanitize_subdomain("My Cool App!!!"), "my-cool-app"); + } + + #[test] + fn sanitize_subdomain_already_clean() { + assert_eq!(sanitize_subdomain("my-app"), "my-app"); + } + + #[test] + fn sanitize_subdomain_does_not_truncate() { + let long_name = "a".repeat(100); + let result = sanitize_subdomain(&long_name); + assert_eq!(result.len(), 100, "sanitize_subdomain should not truncate"); + } + + #[test] + fn compose_slug_basic() { + assert_eq!(compose_slug("swift-penguin", "devproxy"), "swift-penguin-devproxy"); + } + + #[test] + fn compose_slug_truncates_to_63_chars() { + let long_app = "a".repeat(100); + let result = compose_slug("swift-penguin", &long_app); + assert!(result.len() <= 63, "composite slug must fit in a DNS label: len={}", result.len()); + assert!(!result.ends_with('-'), "should not end with hyphen after truncation"); + assert!(result.starts_with("swift-penguin-"), "should preserve the random slug prefix"); + } + + #[test] + fn compose_slug_normal_lengths_not_truncated() { + let result = compose_slug("bold-fox", "my-cool-app"); + assert_eq!(result, "bold-fox-my-cool-app"); + } } diff --git a/tests/e2e.rs b/tests/e2e.rs index 1ff26f1..973224e 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -45,6 +45,7 @@ fn find_free_port() -> u16 { /// Copy the fixtures directory into an isolated temp dir for one test. /// Returns the path to the copy (which contains docker-compose.yml, Dockerfile, etc). +/// Initializes a git repo with a known remote so detect_app_name is predictable. fn copy_fixtures(test_name: &str) -> PathBuf { let dest = std::env::temp_dir().join(format!( "devproxy-fixtures-{test_name}-{}", @@ -61,9 +62,29 @@ fn copy_fixtures(test_name: &str) -> PathBuf { let dest_path = dest.join(entry.file_name()); std::fs::copy(entry.path(), &dest_path).unwrap(); } + + // Initialize a git repo with a known remote so detect_app_name is predictable + Command::new("git") + .args(["init"]) + .current_dir(&dest) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .expect("git init failed"); + Command::new("git") + .args(["remote", "add", "origin", "https://github.com/test/e2e-fixture.git"]) + .current_dir(&dest) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .expect("git remote add failed"); + dest } +/// The expected app name suffix for fixture directories (derived from git remote) +const FIXTURE_APP_NAME: &str = "e2e-fixture"; + /// Create an isolated test config directory and generate certs using `init --no-daemon`. /// Returns the path to the config directory (to be set as DEVPROXY_CONFIG_DIR). fn create_test_config_dir(test_name: &str) -> PathBuf { @@ -473,6 +494,12 @@ fn test_full_e2e_workflow() { .and_then(|l| l.split("https://").nth(1).and_then(|s| s.split('.').next())) .expect("should find slug in up output"); + // Verify the slug contains the app name suffix + assert!( + slug.ends_with(&format!("-{FIXTURE_APP_NAME}")), + "slug should end with app name '-{FIXTURE_APP_NAME}': {slug}" + ); + // Verify .devproxy-project was written with the correct slug let project_file = fixtures.join(".devproxy-project"); assert!( @@ -506,9 +533,10 @@ fn test_full_e2e_workflow() { "daemon should be running: {status_stderr}" ); - // Ls check + // Ls check (run from fixtures dir to get * indicator) let ls_output = Command::new(devproxy_bin()) .args(["ls"]) + .current_dir(&fixtures) .env("DEVPROXY_CONFIG_DIR", &config_dir) .output() .expect("failed to run devproxy ls"); @@ -517,6 +545,10 @@ fn test_full_e2e_workflow() { ls_stdout.contains(slug), "ls should show our slug '{slug}': {ls_stdout}" ); + assert!( + ls_stdout.contains("*"), + "ls should show * for current project: {ls_stdout}" + ); // Curl through the proxy (--resolve bypasses DNS, --cacert trusts our test CA) let ca_cert_path = config_dir.join("ca-cert.pem");