From f88eccec0df431c676eb1b90f81983e007191866 Mon Sep 17 00:00:00 2001 From: Caleb Cox Date: Mon, 29 Jan 2024 22:36:44 -0600 Subject: [PATCH] Add link support --- Cargo.lock | 34 -- Cargo.toml | 2 - README.md | 232 ++++++++------ default_config.toml | 13 +- e2e/setup.sh | 2 +- src/allocator.rs | 40 ++- src/caddy.rs | 138 ++++---- src/cli.rs | 68 ++-- src/config.rs | 33 +- src/dependencies.rs | 21 +- src/init.rs | 21 +- src/main.rs | 422 +++++++++++++++---------- src/matcher.rs | 188 ----------- src/registry.rs | 751 ++++++++++++++++++++++++++++++++------------ 14 files changed, 1123 insertions(+), 842 deletions(-) delete mode 100644 src/matcher.rs diff --git a/Cargo.lock b/Cargo.lock index ac674a1..f2d58bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "aho-corasick" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" version = "0.3.0" @@ -298,12 +289,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - [[package]] name = "libc" version = "0.2.142" @@ -339,9 +324,7 @@ dependencies = [ "clap_mangen", "directories", "entrait", - "lazy_static", "rand", - "regex", "serde", "toml", "unimock", @@ -421,23 +404,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "regex" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" - [[package]] name = "roff" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 116de30..b2ea714 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,7 @@ anyhow = "1.0.66" clap = { version = "4.2.4", features = ["derive"] } directories = "5.0.0" entrait = { version = "0.4.6", features = ["unimock"] } -lazy_static = "1.4.0" rand = "0.8.5" -regex = { version = "1.5.5", default-features = false, features = ["perf", "std"] } serde = { version = "1.0.136", features = ["derive"] } toml = "0.7.3" unimock = "0.3.14" diff --git a/README.md b/README.md index 0683f1a..802b8cb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # portman -`portman` transforms your local project URLs from http://localhost:8080 and http://localhost:3000 to pretty URLs like https://foo.localhost and https://bar.localhost. +`portman` transforms your local project URLs from http://localhost:8080 and http://localhost:3000 into pretty URLs like https://app.localhost and https://project.localhost. `portman` has three components: -1. The CLI lets you register projects and generates unique port assignments for each one. +1. The CLI lets you register projects and assign unique autogenerated ports to each one. 1. The shell integration automatically sets the PORT environment variable when you `cd` into the project's directory. 1. The [caddy](https://caddyserver.com) integration automatically generates a Caddyfile that caddy will use to reverse-proxy your localhost:\* urls to https://\*.localhost URLs. @@ -25,189 +25,219 @@ brew services start caddy ## Basic usage ```sh -# Allocate a new autogenerated port for this project -cd my-cool-project -portman allocate +# Create a new project and autogenerate a unique port for it +cd /projects/app +portman create # Check that the shell integration automatically set the $PORT -echo $PORT +echo "Port is $PORT" # Run the project's dev server how you normally would npm run dev + +# Open the app in the browser +open "https://app.localhost" ``` -## Gallery +## Linked ports -portman provides a simple web server for graphically viewing all of your allocated projects and some basic information about them. It is available at https://localhost. +In addition assigning unique, autogenerated ports to projects, portman can also link running servers to a specific port and dynamically change which project links to that port while those servers are running. -## Matchers +Suppose that you have a work project that needs to be run at http://localhost:3000 because of OAuth configuration that is outside of your control. Further suppose that you have three git worktrees for that project. That gives you an isolated development space for the feature you're working on, a bug you're fixing, and a co-worker's code you're reviewing locally. Without portman, to switch from running the feature worktree to the bugfix worktree you have to stop the feature worktree server listening on port 3000 and start the bugfix worktree server. To run your co-worker's code, you have to then stop the bugfix worktree server and start the code review worktree server. Stopping and restarting servers like this is tedious, especially when frequently switching between projects. portman provides a way to dynamically change which project port 3000 is linked to without needing to stop and restart servers. -When the current directory changes, portman searches through the list of allocated projects to see if any of them match the current directory. If any match, it activates the project by setting `$PORT` to that project's port. By default, portman searches by the current working directory when the project was allocated. However, if the project moves directories or if you `cd` into a subdirectory of the project, portman won't be able to tell that you entered that project because the directories won't match. To alleviate this, multiple matching strategies are available. +First, create a project for each worktree. At this point you can start any or all of the three servers. Each one will use its unique, autogenerated port so they won't conflict with each other. -### `--matcher=dir` +```sh +# In terminal tab 1... +cd /projects/worktree-feature +portman create +npm run dev -This is the default matching strategy. portman remembers the current working directory when a project is allocated and activates a project whenever the current working directory matches the projects' saved working directory. +# In terminal tab 2... +cd /projects/worktree-bugfix +portman create +npm run dev -### `--matcher=git` +# In terminal tab 3... +cd /projects/worktree-review +portman create +npm run dev +``` -This matching strategy takes advantage of the stability of a project's git origin URL to still be able to find projects when they move around on the filesystem or when entering a project's subdirectory. portman runs `git --get remote.origin.url` to retrieve the origin URL when a project is allocated and activates a project whenever the output of `git --get remote.origin.url` matches the project's saved origin URL. One caveat with this matcher is that if you have multiple clones of the same repository, portman won't be able to distinguish them and will allocate the same port to them, which may or may not be desirable. +Then, run `portman link` to link a project to a specific port. ```sh -# Setup the project and allocate a port -git clone https://github.com/user/project.git -cd project -portman allocate --matcher=git +# Link http://localhost:3000 to the worktree-bugfix project +portman link 3000 worktree-bugfix -# Rename the project -cd .. -mv project project-renamed +# Sometime later... -# portman can still find the project -cd . +# Link http://localhost:3000 to the worktree-review project +portman link 3000 worktree-review ``` -### `--matcher=none` +To achieve this, portman sets up a reverse-proxy that sends traffic from http://localhost:3000 to the port that the project is linked to. -This matching strategy turns off matching. The project will never be activated automatically. You can still get the project's port by passing the project's name to `portman get`. +You can also omit the project name to link the active project. ```sh -# Run the project's dev server how you normally would, passing it the generated PORT -cd my-cool-project -PORT=$(portman get my-cool-project) npm run dev +# Link http://localhost:3000 to the worktree-feature project +cd /projects/worktree-feature +portman link 3000 ``` -## Project names +Lastly, you can link a project to a port when creating it by passing the `--link` flag. + +Projects can only be linked to one port at a time, so adding a new linked port removes the previous linked port. -portman can usually infer a name for a project. Therefore, the name argument can often be omitted from `allocate`, `get`, and `release`. The default project name depends on the matching strategy. +```sh +portman link 3000 worktree-bugfix -When the matching strategy is [`dir`](#--matcherdir) (the default), the project name defaults to the name of the current directory. +# worktree-bug is linked to port 3001 and nothing is linked to port 3000 +portman link 3001 worktree-bugfix +``` ```sh -cd /path/to/my-project -# Project name defaults to "my-project" -portman allocate +cd /projects/worktree-feature-2 +# Create the project and link it to port 3000 +portman create --link=3000 ``` -When the matching strategy is [`git`](#--matchergit), the project name defaults to the name of the GitHub project. Currently, only GitHub repositories are supported when extracting the project name from the remote origin URL. +## Gallery + +portman provides a simple web server for graphically viewing all of your projects and some basic information about them. It is available at https://localhost. + +## Activation + +When you create a project, portman remembers the current working directory and associates it with the project. Later when you `cd` to that directory again, portman activates the project by setting the `$PORT` environment variable to the project's port. Note that the shell integration must be enabled for portman to be able to detect changes to the current directory. During activation portman also sets `$PORTMAN_PROJECT` to the name of the active project and sets `$PORTMAN_LINKED_PORT` to the port linked to the active project if there is one. + +To create a project without tying it to a specific directory, use the `--no-activate` flag. The project will not be linked to the current directory and therefore cannot be automatically activated. You must also manually provide a name for the project. ```sh -git clone https://github.com/user/my-project.git -cd my-project -# Project name defaults to "my-project" -portman allocate +portman create service --no-activate +echo "Port for service is $(portman get service)" ``` -When the matching strategy is [`none`](#--matchernone), the project name has no default and must be provided manually. - -## Manual port assignment +## Project names -Sometimes, a randomly-assigned port doesn't work because a server hard-codes the port to listen on, for example. In that case, simply provide the `--port` flag. +portman can usually infer a reasonable name for a project when it is omitted from from `create`. The default project is based on the directory, and portman attempts to normalize it to a valid subdomain by converting it to lowercase, converting all characters other than a-z, 0-9, and dash (-) to dashes, stripping leading and trailing dashes, combining adjacent dashes into a single dash, and truncating it to 63 characters. ```sh -portman allocate --port=1234 +cd /projects/app +# Project name defaults to "app" +portman create ``` -This port can even fall outside of the configured range of allowable ports. +Projects that don't auto activate aren't associated with a directory. As a result, the project name cannot be inferred and must be provided manually. -## Redirecting instead of reverse-proxying +```sh +# Project name is explicitly set to "app" +portman create app --no-activate +``` + +## Configuration -Sometimes, using a reverse-proxied domain like https://my-project.localhost doesn't work because of CORS or other architectural factors. In that case, simply provide the `--redirect` flag. Now, navigating to `https://my-project.localhost` will redirect to http://localhost:$PORT instead of reverse-proxying. +portman has a few configuration parameters that can be tweaked. Run `portman config show` to locate the default config file location. Run `portman config edit` to open the configuration file with `$EDITOR`. You might want to copy the contents of the [`default_config.toml`](default_config.toml) file as a starting point and then make your desired changes. The config file location can also be changed by setting the `PORTMAN_CONFIG` environment variable. ```sh -portman allocate --port=9000 --redirect +PORTMAN_CONFIG=~/portman.toml portman config show ``` -## CLI API +The config file is in TOML format. This is the default config: -### `portman -h`, `portman --help` +```toml +ranges = [[3000, 3999]] +reserved = [] +``` -Prints CLI usage information. +### `ranges` -### `portman -V`, `portman --version` +`ranges` is an array of two-element `[start, end]` arrays representing the allowed port ranges. The first element is the beginning of the port range, inclusive, and the second element is the end of the port range, inclusive. For example, `[[3000, 3999], [8000, 8099]]` would assign ports from 3000-3999 and 8000-8099. -Prints portman version. +Defaults to `[[3000, 3999]]` if omitted. -### `portman init fish` +### `reserved` + +`reserved` is an array of ports that are reserved and will not be assigned to any project. For example, if you want to assign ports between 3000 and 3999, but port 3277 is used by a something on your machine, set `reserved` to `[3277]` to prevent portman from assigning port 3277 to a project. -Prints the shell configuration command to enable the shell integration. Currently, only Fish shell is supported, but other shells would be trivial to add. +Defaults to `[]` if omitted. -### `portman allocate [project] [--port=PORT] [--matcher=dir|git|none] [--redirect]` +## Setting up DNS -Allocates a new automatically generated port for a new project. If `project-name` is not provided, a default is calculated by the provided matcher. `project` is required if `--matcher=none`. If `port` is provided, that port will be used instead of randomly assigning one. See [matchers](#matchers) for more details about matchers configuration or [project names](#project-names) for more details about default project names. If `redirect` is specified, redirect to the server instead of reverse-proxying. See [here](#redirecting-instead-of-reverse-proxying) for more details. +Chromium-based browsers automatically resolve the `localhost` tld to 127.0.0.1. To use other browsers or other tools, you may need to configure your DNS to resolve \*.localhost to 127.0.0.1. I use [NextDNS](https://nextdns.io) for ad blocking, and it's trivial to add a rewrite in NextDNS for \*.localhost domains. -`portman allocate` is idempotent, i.e. calling it multiple times with the same arguments will allocate a project the first time and do nothing for future invocations. However, an error will occur if a port, matcher, or redirect is provided that differs from the existing project's configuration. +## Bonus: Starship integration + +To show the active project's port in your [Starship](https://starship.rs) prompt, add this to your `starship.toml`: + +```toml +[custom.port] +command = 'if test -n "$PORTMAN_LINKED_PORT"; then echo "$PORT -> $PORTMAN_LINKED_PORT"; else echo "$PORT"; fi' +when = 'test -n "$PORT"' +format = ':[$output]($style) ' +shell = ['bash', '--noprofile', '--norc'] +``` -### `portman get [project]` +## CLI API -Prints the port allocated for a project. If `project-name` is provided, it searches for a project by its name. If `project-name` is not provided, it searches for a project that matches the current directory according to the rules explained in [matchers](#matchers). +### `portman -h`, `portman --help` -### `portman release [project]` +Prints CLI usage information. -Releases the port allocated for a project. Just like `portman get`, if `project-name` is provided, it searches for a project by its name and releases the project found. If `project-name` is not provided, it searches for a project that matches the current directory according to the rules explained in [matchers](#matchers) and releases the project found. +### `portman -V`, `portman --version` -### `portman list` +Prints portman version. -Lists all the ports assigned to all projects in alphabetical order. +### `portman init fish` -### `portman reset` +Prints the shell configuration command to enable the shell integration. Currently, only fish shell is supported, but other shells would be trivial to add. -Releases all ports allocated for all projects. +### `portman create [project-name] [--no-activate|-A] [--link=$port]` -### `portman caddyfile` +Creates a new project and assigns it a unique, autogenerated port. If `project-name` is not provided, a default is calculated based on the current directory. `project-name` is required if `--no-activate` is present. If `--no-activate` is present, the project is not associated with a directory and will never be activated by the shell integration. See [project names](#project-names) for more details about default project names. If `--link` is provided the new project is linked to the specific port. -Prints a valid Caddyfile that reverse-proxies all registered projects from their allocated ports to https://\*.localhost URLs. +`portman create` is idempotent, i.e. calling it multiple times with the same arguments will create a project the first time and do nothing for future invocations. However, an error will occur if the presence of `--no-activate` or the directory differs from the existing project's configuration. -### `portman reload-caddy` +### `portman get [project-name] [--extended|-e]` -Regenerates the Caddyfile and reloads the caddy config. portman updates the Caddyfile and reloads caddy whenever it makes changes, so this command should only be necessary if something else outside of portman's control is manipulating the Caddyfile or caddy config. +Prints a project's port. `project-name` defaults to the active project. If `--extended` is present, the project's name, directory, and linked port are also printed in addition to the port. -### `portman config show` +### `portman delete [project]` -Prints the configuration that is currently being used. +Deletes a project. `project-name` defaults to the active project. Its autogenerated port may be assigned to another project in the future. -### `portman config edit` +### `portman cleanup` -Opens the configuration file using the command in the `$EDITOR` environment variable. +Deletes all projects whose directories don't exist anymore. -## Configuring portman +### `portman reset` -portman has a few configuration parameters that can be tweaked. Run `portman config show` to locate the default config file location. Run `portman config edit` to open the configuration file with `$EDITOR`. You might want to copy the contents of the [`default_config.toml`](default_config.toml) file as a starting point and then make your desired changes. The config file location can also be changed by setting the `PORTMAN_CONFIG` environment variable. +Deletes all projects. -```sh -PORTMAN_CONFIG=~/portman.toml portman config show -``` +### `portman list` -The config file is in TOML format. This is the default config: +Lists each project in alphabetical order with its ports, directory, and linked port. -```toml -ranges = [[3000, 3999]] -reserved = [] -``` +### `portman link $port [project-name]` -### `ranges` +Links a project to the specified port. `project-name` defaults to the active project. -`ranges` is an array of two-element `[start, end]` arrays representing the allowed port ranges. The first element is the beginning of the port range, inclusive, and the second element is the end of the port range, inclusive. For example, [[3000, 3999], [8000, 8099]] would allocate ports from 3000-3999 and 8000-8099. +### `portman unlink [project-name]` -Defaults to `[[3000, 3999]]` if omitted. +Removes the linked port from a project. `project-name` defaults to the active project. -### `reserved` +### `portman caddyfile` -`reserved` is an array of ports that are reserved and will not be allocated to any project. For example, if you want to allocate ports between 3000 and 3999, but port 3277 is used by a something on your machine, set `reserved` to `[3277]` to prevent portman from allocating port 3277 to a project. +Prints a valid Caddyfile that reverse-proxies all projects' ports to https://\*.localhost URLs where the subdomain is the project name. -Defaults to `[]` if omitted. +### `portman reload-caddy` -## Setting up DNS +Regenerates the Caddyfile and reloads the caddy config. portman updates the Caddyfile and reloads caddy whenever it makes changes, so this command should only be necessary if something else outside of portman's control is manipulating the Caddyfile or caddy config. -Chromium-based browsers automatically resolve the `localhost` tld to 127.0.0.1. To use other browsers or other tools, you may need to configure your DNS to resolve \*.localhost to 127.0.0.1. I use [NextDNS](https://nextdns.io) for ad blocking, and it's trivial to add a rewrite in NextDNS for \*.localhost domains. +### `portman config show` -## Bonus: Starship integration +Prints the configuration that is currently being used. -To show the port allocated to the current project in your [Starship](https://starship.rs) prompt, add this to your `starship.toml`: +### `portman config edit` -```toml -[custom.port] -command = 'echo $PORT' -when = 'test -n "${PORT-}"' -format = ':[$output]($style) ' -shell = ['bash', '--noprofile', '--norc'] -``` +Opens the configuration file using the command in the `$EDITOR` environment variable. diff --git a/default_config.toml b/default_config.toml index eea3617..e7f065b 100644 --- a/default_config.toml +++ b/default_config.toml @@ -1,13 +1,14 @@ -# `ranges` tells portman which ranges of ports to allocate ports in. It is an -# array of two-item arrays. The first item is the start of the port range and -# the second item is the end of the port range. +# `ranges` tells portman which ranges of ports can be assigned to projects. It +# is an array of two-item arrays. The first item is the start of the port range +# and the second item is the end of the port range. # -# Example (allocates ports from 3000-3099 and 3200-3299): +# Example (assigns ports from 3000-3099 and 3200-3299): # ranges = [[3000, 3099], [3200, 3299]] ranges = [[3000, 3999]] -# `ranges` overrides `ranges` and tells portman which ports to never allocate. +# `reserved` overrides `ranges` and tells portman which ports to never assign. # It is an array of integers. # -# Example (allocates ports in `ranges` except for 3210 and 3121): +# Example (assigns ports in `ranges` except for 3210 and 3121): +# reserved = [3210, 3121] reserved = [] diff --git a/e2e/setup.sh b/e2e/setup.sh index 2ac856b..724884c 100755 --- a/e2e/setup.sh +++ b/e2e/setup.sh @@ -8,4 +8,4 @@ echo -e "# Empty" > $HOMEBREW_PREFIX/etc/Caddyfile sudo $(which caddy) start --config $HOMEBREW_PREFIX/etc/Caddyfile echo "127.0.0.1 portman.localhost" | sudo tee -a /etc/hosts -portman allocate +portman create diff --git a/src/allocator.rs b/src/allocator.rs index f3fc1df..066fe0c 100644 --- a/src/allocator.rs +++ b/src/allocator.rs @@ -1,4 +1,5 @@ use crate::dependencies::ChoosePort; +use anyhow::{bail, Result}; use std::collections::HashSet; pub struct PortAllocator { @@ -13,8 +14,13 @@ impl PortAllocator { } } + // Remove a port from the pool of available ports + pub fn discard(&mut self, port: u16) { + self.available_ports.remove(&port); + } + // Allocate a new port, using the desired port if it is provided and is valid - pub fn allocate(&mut self, deps: &impl ChoosePort, desired_port: Option) -> Option { + pub fn allocate(&mut self, deps: &impl ChoosePort, desired_port: Option) -> Result { let allocated_port = desired_port .and_then(|port| { if self.available_ports.contains(&port) { @@ -24,10 +30,11 @@ impl PortAllocator { } }) .or_else(|| deps.choose_port(&self.available_ports)); - if let Some(port) = allocated_port { - self.available_ports.remove(&port); - } - allocated_port + let Some(port) = allocated_port else { + bail!("All available ports have been allocated already") + }; + self.available_ports.remove(&port); + Ok(port) } } @@ -52,22 +59,31 @@ mod tests { assert!(range.contains(&allocator.allocate(&deps, None).unwrap())); } + #[test] + fn test_discard() { + let mut allocator = PortAllocator::new(3000..=3001); + let deps = get_deps(); + allocator.discard(3000); + assert_eq!(allocator.allocate(&deps, None).unwrap(), 3001); + assert!(allocator.allocate(&deps, None).is_err()); + } + #[test] fn test_allocate() { let mut allocator = PortAllocator::new(3000..=3001); let deps = get_deps(); - assert!(allocator.allocate(&deps, None).is_some()); - assert!(allocator.allocate(&deps, None).is_some()); - assert_eq!(allocator.allocate(&deps, None), None); + assert!(allocator.allocate(&deps, None).is_ok()); + assert!(allocator.allocate(&deps, None).is_ok()); + assert!(allocator.allocate(&deps, None).is_err()); } #[test] fn test_desired_port() { let mut allocator = PortAllocator::new(3000..=3002); let deps = get_deps(); - assert_eq!(allocator.allocate(&deps, Some(3001)), Some(3001)); - assert_eq!(allocator.allocate(&deps, Some(4000)), Some(3000)); - assert_eq!(allocator.allocate(&deps, None), Some(3002)); - assert_eq!(allocator.allocate(&deps, None), None); + assert_eq!(allocator.allocate(&deps, Some(3001)).unwrap(), 3001); + assert_eq!(allocator.allocate(&deps, Some(4000)).unwrap(), 3000); + assert_eq!(allocator.allocate(&deps, None).unwrap(), 3002); + assert!(allocator.allocate(&deps, None).is_err()); } } diff --git a/src/caddy.rs b/src/caddy.rs index b2ce598..07e0df8 100644 --- a/src/caddy.rs +++ b/src/caddy.rs @@ -1,7 +1,7 @@ use crate::dependencies::{DataDir, Environment, Exec, ReadFile, WriteFile}; -use crate::matcher::Matcher; use crate::registry::PortRegistry; use anyhow::{bail, Context, Result}; +use std::fmt::Write; use std::path::PathBuf; // Return the path the portman Caddyfile import @@ -16,28 +16,31 @@ fn gallery_www_path(deps: &impl DataDir) -> Result { // Return the generated gallery fn generate_gallery_index(registry: &PortRegistry) -> String { + let project_count = registry.iter_projects().count(); let projects = registry - .iter() - .map(|(name, project)| { - let location = project.matcher.as_ref().map(|matcher| { - format!( - "\n

{}

", - match matcher { - Matcher::Directory { directory } => directory.to_string_lossy().to_string(), - Matcher::GitRepository { repository } => repository.clone(), - } - ) - }); - format!( - r#" + .iter_projects() + .fold(String::new(), |mut output, (name, project)| { + let port = project.port; + let directory = project + .directory + .as_ref() + .map(|directory| { + format!( + "\n

\"{}\"

", + directory.display() + ) + }) + .unwrap_or_default(); + let _ = write!( + output, + r#" +

{name}

-

Port: {}

{} +

Port: {port}

{directory}
"#, - project.port, - location.unwrap_or_default() - ) - }) - .collect::>(); + ); + output + }); format!( r#" @@ -95,36 +98,38 @@ fn generate_gallery_index(registry: &PortRegistry) -> String {
-

portman projects ({})

- "#, - projects.len(), - projects.join("\n") ) } // Return the Caddyfile as a string pub fn generate_caddyfile(deps: &impl DataDir, registry: &PortRegistry) -> Result { - let caddyfile = registry - .iter() - .map(|(name, project)| { - let action = if project.redirect { - format!("redir http://localhost:{}", project.port) - } else { - format!("reverse_proxy localhost:{}", project.port) - }; - format!("{name}.localhost {{\n\t{action}\n}}\n") - }) - .collect::>() - .join("\n"); + let projects = registry + .iter_projects() + .fold(String::new(), |mut output, (name, project)| { + let _ = write!( + output, + "\n{name}.localhost {{\n\treverse_proxy localhost:{}\n}}\n", + project.port + ); + if let Some(linked_port) = project.linked_port { + let _ = write!( + output, + "\nhttp://localhost:{linked_port} {{\n\treverse_proxy localhost:{}\n}}\n", + project.port + ); + } + output + }); Ok(format!( - "localhost {{\n\tfile_server {{\n\t\troot \"{}\"\n\t}}\n}}\n\n{caddyfile}", - gallery_www_path(deps)?.to_string_lossy() + "localhost {{\n\tfile_server {{\n\t\troot \"{}\"\n\t}}\n}}\n{projects}", + gallery_www_path(deps)?.display() )) } @@ -134,7 +139,7 @@ fn update_import( deps: &impl DataDir, existing_caddyfile: Option, ) -> Result> { - let import_statement = format!("import \"{}\"\n", import_path(deps)?.to_string_lossy()); + let import_statement = format!("import \"{}\"\n", import_path(deps)?.display()); let existing_caddyfile = existing_caddyfile.unwrap_or_default(); Ok(if existing_caddyfile.contains(import_statement.as_str()) { None @@ -151,12 +156,7 @@ pub fn reload( // Determine the caddyfile path let import_path = import_path(deps)?; deps.write_file(&import_path, &generate_caddyfile(deps, registry)?) - .with_context(|| { - format!( - "Failed to write Caddyfile at \"{}\"", - import_path.to_string_lossy() - ) - })?; + .with_context(|| format!("Failed to write Caddyfile at \"{}\"", import_path.display()))?; // Read the existing caddyfile so that we can update it as necessary let caddyfile_path = PathBuf::from(deps.read_var("HOMEBREW_PREFIX")?) @@ -165,7 +165,7 @@ pub fn reload( let existing_caddyfile = deps.read_file(&caddyfile_path).with_context(|| { format!( "Failed to read Caddyfile at \"{}\"", - caddyfile_path.to_string_lossy() + caddyfile_path.display() ) })?; if let Some(caddyfile_contents) = update_import(deps, existing_caddyfile)? { @@ -173,7 +173,7 @@ pub fn reload( .with_context(|| { format!( "Failed to write Caddyfile at \"{}\"", - caddyfile_path.to_string_lossy() + caddyfile_path.display() ) })?; } @@ -187,7 +187,7 @@ pub fn reload( .with_context(|| { format!( "Failed to write gallery index file at \"{}\"", - gallery_index_path.to_string_lossy() + gallery_index_path.display() ) })?; @@ -230,60 +230,62 @@ app2.localhost { \treverse_proxy localhost:3002 } +http://localhost:3000 { +\treverse_proxy localhost:3002 +} + app3.localhost { -\tredir http://localhost:3003 +\treverse_proxy localhost:3003 } "; #[test] - fn test_caddyfile() -> Result<()> { - let registry = get_mocked_registry()?; + fn test_caddyfile() { + let registry = get_mocked_registry().unwrap(); let deps = unimock::mock([data_dir_mock()]); - assert_eq!(generate_caddyfile(&deps, ®istry)?, GOLDEN_CADDYFILE); - Ok(()) + assert_eq!( + generate_caddyfile(&deps, ®istry).unwrap(), + GOLDEN_CADDYFILE + ); } #[test] - fn test_update_import_no_existing() -> Result<()> { + fn test_update_import_no_existing() { let deps = unimock::mock([data_dir_mock()]); assert_eq!( - update_import(&deps, None)?, + update_import(&deps, None).unwrap(), Some(String::from("import \"/data/Caddyfile\"\n")) ); - Ok(()) } #[test] - fn test_update_import_already_present() -> Result<()> { + fn test_update_import_already_present() { let deps = unimock::mock([data_dir_mock()]); assert!(update_import( &deps, Some(String::from( "import \"/data/Caddyfile\"\n# Other content\n" )) - )? + ) + .unwrap() .is_none()); - Ok(()) } #[test] - fn test_update_import_prepend() -> Result<()> { + fn test_update_import_prepend() { let deps = unimock::mock([data_dir_mock()]); assert_eq!( - update_import(&deps, Some(String::from("# Suffix\n")))?, + update_import(&deps, Some(String::from("# Suffix\n"))).unwrap(), Some(String::from("import \"/data/Caddyfile\"\n# Suffix\n")) ); - Ok(()) } #[test] - fn test_generate_gallery() -> Result<()> { - let registry = get_mocked_registry()?; + fn test_generate_gallery() { + let registry = get_mocked_registry().unwrap(); let gallery = generate_gallery_index(®istry); assert!(gallery.contains("portman projects (3)")); assert!(gallery.contains("href=\"https://app1.localhost\"")); - assert!(gallery.contains("

https://github.com/user/app2.git

")); - assert!(gallery.contains("

/projects/app3

")); - Ok(()) + assert!(gallery.contains("

\"/projects/app3\"

")); } } diff --git a/src/cli.rs b/src/cli.rs index 97110ea..787c00e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,13 +5,6 @@ pub enum InitShell { Fish, } -#[derive(ValueEnum, Clone)] -pub enum Matcher { - Dir, - Git, - None, -} - #[derive(Subcommand)] pub enum Config { /// Display the current configuration @@ -35,44 +28,61 @@ pub enum Cli { #[clap(subcommand)] Config(Config), - /// Print the port allocated for a project + /// Print a project's port Get { - /// The name of the project to look for (defaults to searching through projects using their configured matcher) + /// The name of the project to print (defaults to the active project) project_name: Option, + + /// Print the project's name, directory, and linked port in addition to its port + #[clap(long, short = 'e')] + extended: bool, }, - /// Allocate a port for a new project - Allocate { - /// The name of the project to allocate a port for (defaults to being provided by the matcher if not none) - #[clap(required_if_eq("matcher", "none"))] + /// Create a new project + Create { + /// The name of the project (defaults to the basename of the current directory unless --no-activate is present) project_name: Option, - /// Allocate a specific port to the project instead of randomly assigning one - #[clap(long)] - port: Option, + /// Link the project to a port + #[clap(long, value_name = "PORT")] + link: Option, - /// The matching strategy to use when activating the project - #[clap(long, value_enum, default_value = "dir")] - matcher: Matcher, - - /// Navigate to the project via a redirect instead of reverse-proxy - #[clap(long)] - redirect: bool, + /// Do not automatically activate this project + #[clap(long, short = 'A', requires("project_name"))] + no_activate: bool, }, - /// Release an allocated port - Release { - /// The name of the project to release (defaults to searching through projects using their configured matcher) + /// Delete an existing project + Delete { + /// The name of the project to delete (defaults to the active project) project_name: Option, }, - /// Reset all of the port assignments + /// Cleanup projects whose directory has been deleted + Cleanup, + + /// Delete all existing projects Reset, - /// List all of the port assignments + /// List all projects List, - /// Print the generated Caddyfile for the allocated ports + /// Link a project to a port + Link { + /// The port to link + port: u16, + + /// The name of the project to link (defaults to the active project) + project_name: Option, + }, + + /// Unlink the port from a project + Unlink { + /// The name of the project to unlink (defaults to the active project) + project_name: Option, + }, + + /// Print the generated Caddyfile Caddyfile, /// Regenerate the Caddyfile and restart caddy diff --git a/src/config.rs b/src/config.rs index 660012b..2220a11 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,20 +34,15 @@ impl Config { // Return None if the file doesn't exist pub fn load(deps: &impl ReadFile, path: &Path) -> Result> { deps.read_file(path) - .with_context(|| format!("Failed to read config at \"{}\"", path.to_string_lossy()))? + .with_context(|| format!("Failed to read config at \"{}\"", path.display()))? .map(|config_str| Self::from_toml(&config_str)) .transpose() - .with_context(|| { - format!( - "Failed to deserialize config at \"{}\"", - path.to_string_lossy() - ) - }) + .with_context(|| format!("Failed to deserialize config at \"{}\"", path.display())) } // Return a new configuration from a TOML string fn from_toml(toml_str: &str) -> Result { - let config: Config = toml::from_str(toml_str).context("Failed to deserialize config")?; + let config: Config = toml::from_str(toml_str)?; if config.ranges.is_empty() { bail!("Failed to validate config: port ranges must not be empty") @@ -106,16 +101,15 @@ mod tests { use unimock::{matching, MockFn}; #[test] - fn test_load_config() -> Result<()> { + fn test_load_config() { let deps = unimock::mock([dependencies::read_file::Fn .each_call(matching!(_)) .answers(|_| Ok(Some(String::from("ranges = [[3000, 3999]]\nreserved = []")))) .in_any_order()]); - let config = Config::load(&deps, &PathBuf::new())?.unwrap(); + let config = Config::load(&deps, &PathBuf::new()).unwrap().unwrap(); assert_eq!(config.ranges, vec![(3000, 3999)]); assert_eq!(config.reserved, vec![]); - Ok(()) } #[test] @@ -130,11 +124,10 @@ mod tests { } #[test] - fn test_empty_config() -> Result<()> { - let config = Config::from_toml("")?; + fn test_empty_config() { + let config = Config::from_toml("").unwrap(); assert_eq!(config.ranges, vec![(3000, 3999)]); assert_eq!(config.reserved, vec![]); - Ok(()) } #[test] @@ -156,26 +149,26 @@ mod tests { } #[test] - fn test_valid_ports() -> Result<()> { + fn test_valid_ports() { let config = Config::from_toml( "ranges = [[3000, 3002], [4000, 4005]]\nreserved = [3001, 4000, 4004]", - )?; + ) + .unwrap(); assert_eq!( config.get_valid_ports().collect::>(), vec![3000, 3002, 4001, 4002, 4003, 4005] ); - Ok(()) } #[test] - fn test_display() -> Result<()> { + fn test_display() { let config = Config::from_toml( "ranges = [[3000, 3999], [4500, 4999]]\nreserved = [3000, 3100, 3200]", - )?; + ) + .unwrap(); assert_eq!( format!("{config}"), "Allowed port ranges: 3000-3999 & 4500-4999\nReserved ports: 3000, 3100, 3200" ); - Ok(()) } } diff --git a/src/dependencies.rs b/src/dependencies.rs index 8e71678..d01d2d5 100644 --- a/src/dependencies.rs +++ b/src/dependencies.rs @@ -14,6 +14,11 @@ fn get_args(_deps: &impl std::any::Any) -> Vec { std::env::args().collect() } +#[entrait(pub CheckPath)] +fn path_exists(_deps: &impl std::any::Any, path: &Path) -> bool { + path.exists() +} + #[entrait(pub ChoosePort)] fn choose_port(_deps: &impl std::any::Any, available_ports: &HashSet) -> Option { let mut rng = rand::thread_rng(); @@ -79,13 +84,13 @@ fn write_file(_deps: &impl std::any::Any, path: &Path, contents: &str) -> Result let parent_dir = path.parent().with_context(|| { format!( "Failed to determine parent directory for file at \"{}\"", - path.to_string_lossy() + path.display() ) })?; std::fs::create_dir_all(parent_dir).with_context(|| { format!( "Failed to create parent directory for file at \"{}\"", - parent_dir.to_string_lossy() + parent_dir.display() ) })?; Ok(std::fs::write(path, contents)?) @@ -93,13 +98,21 @@ fn write_file(_deps: &impl std::any::Any, path: &Path, contents: &str) -> Result #[cfg(test)] pub mod mocks { - use super::{exec, get_data_dir, write_file}; + use super::{exec, get_cwd, get_data_dir, write_file}; + use std::path::PathBuf; use unimock::{matching, Clause, MockFn}; + pub fn cwd_mock() -> Clause { + get_cwd::Fn + .each_call(matching!(_)) + .answers(|()| Ok(PathBuf::from("/portman"))) + .in_any_order() + } + pub fn data_dir_mock() -> Clause { get_data_dir::Fn .each_call(matching!(_)) - .answers(|_| Ok(std::path::PathBuf::from("/data"))) + .answers(|()| Ok(std::path::PathBuf::from("/data"))) .in_any_order() } diff --git a/src/init.rs b/src/init.rs index 1ba148a..e08d319 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,22 +1,29 @@ // Return the fish shell initialization command pub fn init_fish() -> &'static str { - "function __portman_sync_port - if set port (portman get 2> /dev/null) - set -gx PORT $port + "function __portman_activate + if set lines (portman get --extended 2> /dev/null) + set -gx PORT $lines[1] + set -gx PORTMAN_PROJECT $lines[2] + if test -n $lines[4] + set -gx PORTMAN_LINKED_PORT $lines[4] + else + set -e PORTMAN_LINKED_PORT + end else - set -e PORT + set -e PORT PORTMAN_PROJECT PORTMAN_LINKED_PORT end end function __portman_prompt_hook --on-event fish_prompt - __portman_sync_port + __portman_activate function __portman_cd_hook --on-variable PWD - __portman_sync_port + __portman_activate end end function __portman_preexec_hook --on-event fish_preexec # Without clearing the cd hook, the cd hook and prompt hook would both sync the port functions -e __portman_cd_hook -end" +end +" } diff --git a/src/main.rs b/src/main.rs index c68ad8f..6be36fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,6 @@ mod cli; mod config; mod dependencies; mod init; -mod matcher; mod registry; use crate::allocator::PortAllocator; @@ -15,63 +14,107 @@ use crate::caddy::{generate_caddyfile, reload}; use crate::cli::{Cli, Config as ConfigSubcommand, InitShell}; use crate::config::Config; use crate::init::init_fish; -use crate::matcher::Matcher; use crate::registry::PortRegistry; -use anyhow::{anyhow, bail, Result}; +use anyhow::{anyhow, bail, Context, Result}; use clap::{error::ErrorKind, Parser}; use dependencies::{ - Args, ChoosePort, DataDir, Environment, Exec, ReadFile, WorkingDirectory, WriteFile, + Args, CheckPath, ChoosePort, DataDir, Environment, Exec, ReadFile, WorkingDirectory, WriteFile, }; use entrait::Impl; use registry::Project; +use std::fmt::Write; use std::io::{stdout, IsTerminal}; +use std::path::PathBuf; use std::process::{self, Command}; -fn allocate( +// Find and return a reference to the active project based on the current directory +fn active_project<'registry>( + deps: &impl WorkingDirectory, + registry: &'registry PortRegistry, +) -> Result<(&'registry String, &'registry Project)> { + registry + .match_cwd(deps)? + .ok_or_else(|| anyhow!("No projects match the current directory")) +} + +fn format_project(name: &str, project: &Project) -> String { + let directory = project + .directory + .as_ref() + .map(|directory| format!(" ({})", directory.display())) + .unwrap_or_default(); + let linked_port = project + .linked_port + .map(|port| format!(" -> :{port}")) + .unwrap_or_default(); + format!("{name} :{}{linked_port}{directory}", project.port) +} + +fn create( deps: &(impl ChoosePort + DataDir + Environment + Exec + ReadFile + WriteFile + WorkingDirectory), registry: &mut PortRegistry, - cli_name: Option, - cli_port: Option, - cli_matcher: &cli::Matcher, - cli_redirect: bool, + name: Option, + no_activate: bool, + linked_port: Option, ) -> Result<(String, Project)> { - let matcher = match cli_matcher { - cli::Matcher::Dir => Some(Matcher::from_cwd(deps)?), - cli::Matcher::Git => Some(Matcher::from_git(deps)?), - cli::Matcher::None => None, + let name = if let Some(name) = name { + name + } else { + let directory = deps.get_cwd()?; + let basename = directory + .file_name() + .context("Failed to extract directory basename")?; + let name = basename + .to_str() + .context("Failed to convert directory to string")?; + PortRegistry::normalize_name(name) }; - let existing_project = match cli_name { - Some(ref name) => registry.get(name), - None => registry.match_cwd(deps).map(|(_, project)| project), - } - .cloned(); - let name = match cli_name { - Some(cli_name) => cli_name, - None => matcher.as_ref().unwrap().get_name()?, + let directory = if no_activate { + None + } else { + Some(deps.get_cwd()?) }; + let existing_project = registry.get(&name).cloned(); let project = match existing_project { Some(project) => { - if let Some(port) = cli_port { - if project.port != port { - bail!("Cannot change port for project {name}") - } - } - if project.matcher != matcher { - bail!("Cannot change matcher for project {name}") - } - if project.redirect != cli_redirect { - bail!("Cannot change redirect for project {name}") + if project.directory.is_none() && directory.is_some() { + bail!("Project was originally created with --no-activate"); + } else if project.directory.is_some() && directory.is_none() { + bail!("Project was originally created without --no-activate"); + } else if project.directory != directory { + bail!("Cannot change directory for project {name}"); } project } - None => registry.allocate(deps, name.clone(), cli_port, cli_redirect, matcher)?, + None => registry.create(deps, name.clone(), directory, linked_port)?, }; Ok((name, project)) } +fn cleanup( + deps: &(impl CheckPath + DataDir + Environment + Exec + ReadFile + WriteFile), + registry: &mut PortRegistry, +) -> Result> { + // Find all existing projects with a directory that doesn't exist + let removed_projects = registry + .iter_projects() + .filter_map(|(name, project)| { + project.directory.as_ref().and_then(|directory| { + if deps.path_exists(directory) { + None + } else { + Some(name.clone()) + } + }) + }) + .collect(); + registry.delete_many(deps, removed_projects) +} + #[allow(clippy::too_many_lines)] fn run( deps: &(impl Args + + CheckPath + ChoosePort + DataDir + Environment @@ -84,12 +127,12 @@ fn run( let config_env = deps.read_var("PORTMAN_CONFIG").ok(); let config_env_present = config_env.is_some(); let config_path = match config_env { - Some(config_path) => std::path::PathBuf::from(config_path), + Some(config_path) => PathBuf::from(config_path), None => data_dir.join("config.toml"), }; let config = Config::load(deps, &config_path)?.unwrap_or_else(|| { if config_env_present { - println!("Warning: config file doesn't exist. Using default config."); + eprintln!("Warning: config file doesn't exist. Using default config."); } Config::default() }); @@ -120,21 +163,14 @@ fn run( Cli::Config(subcommand) => match subcommand { ConfigSubcommand::Show => { println!( - "Config path: {}\nRegistry path: {}\nConfiguration:\n--------------\n{}", - config_path.to_string_lossy(), - data_dir - .join(std::path::PathBuf::from("registry.toml")) - .to_string_lossy(), - config + "Config path: {}\nRegistry path: {}\nConfiguration:\n--------------\n{config}", + config_path.display(), + data_dir.join(PathBuf::from("registry.toml")).display() ); } ConfigSubcommand::Edit => { let editor = deps.read_var("EDITOR")?; - println!( - "Opening \"{}\" with \"{}\"", - config_path.to_string_lossy(), - editor, - ); + println!("Opening \"{}\" with \"{editor}\"", config_path.display()); let (status, _) = deps.exec(Command::new(editor).arg(config_path))?; if !status.success() { bail!("Editor command failed to execute successfully"); @@ -142,37 +178,49 @@ fn run( } }, - Cli::Get { project_name } => { + Cli::Get { + project_name, + extended, + } => { let registry = PortRegistry::new(deps, port_allocator)?; - let project = match project_name { + let (name, project) = match project_name { Some(ref name) => registry .get(name) + .map(|project| (name, project)) .ok_or_else(|| anyhow!("Project {name} does not exist")), - None => registry - .match_cwd(deps) - .map(|(_, project)| project) - .ok_or_else(|| anyhow!("No projects match the current directory")), + None => active_project(deps, ®istry), }?; - println!("{}", project.port); + if extended { + let directory = project + .directory + .as_ref() + .map(|directory| directory.display().to_string()) + .unwrap_or_default(); + let linked_port = project + .linked_port + .map(|port| port.to_string()) + .unwrap_or_default(); + if stdout().is_terminal() { + print!("port: {}\nname: {name}\ndirectory: {directory}\nlinked port: {linked_port}\n", project.port); + } else { + print!("{}\n{name}\n{directory}\n{linked_port}\n", project.port); + } + } else { + println!("{}", project.port); + } } - Cli::Allocate { + Cli::Create { project_name, - port, - matcher, - redirect, + link, + no_activate, } => { let mut registry = PortRegistry::new(deps, port_allocator)?; - let (name, project) = - allocate(deps, &mut registry, project_name, port, &matcher, redirect)?; + let (name, project) = create(deps, &mut registry, project_name, no_activate, link)?; if stdout().is_terminal() { - println!("Port {} is allocated for project {name}", project.port); - if let Some(matcher) = project.matcher { - let matcher_trigger = match matcher { - Matcher::GitRepository { .. } => "git repository", - Matcher::Directory { .. } => "directory", - }; - println!("\nThe PORT environment variable will now be automatically set whenever this {matcher_trigger} is cd-ed into from an initialized shell."); + println!("Created project {}", format_project(&name, &project)); + if !no_activate { + println!("\nThe PORT environment variable will now be automatically set whenever this directory is cd-ed into from an initialized shell."); } } else { // Only print the port if stdout isn't a TTY for easier scripting @@ -180,39 +228,75 @@ fn run( } } - Cli::Release { project_name } => { + Cli::Delete { project_name } => { let mut registry = PortRegistry::new(deps, port_allocator)?; let project_name = match project_name { Some(name) => name, - None => registry - .match_cwd(deps) - .map(|(name, _)| name.clone()) - .ok_or_else(|| anyhow!("No projects match the current directory"))?, + None => active_project(deps, ®istry)?.0.clone(), }; - let project = registry.release(deps, &project_name)?; - println!("Released port {} for project {project_name}", project.port); + let project = registry.delete(deps, &project_name)?; + println!( + "Deleted project {}", + format_project(&project_name, &project), + ); + } + + Cli::Cleanup => { + let mut registry = PortRegistry::new(deps, port_allocator)?; + let deleted_projects = cleanup(deps, &mut registry)?; + print!( + "Deleted {}\n{}", + match deleted_projects.len() { + 1 => String::from("1 project"), + count => format!("{count} projects"), + }, + deleted_projects + .iter() + .fold(String::new(), |mut output, (name, project)| { + let _ = writeln!(output, "{}", format_project(name, project)); + output + }) + ); } Cli::Reset => { let mut registry = PortRegistry::new(deps, port_allocator)?; - registry.release_all(deps)?; - println!("All allocated ports have been released"); + registry.delete_all(deps)?; + println!("Deleted all projects"); } Cli::List => { let registry = PortRegistry::new(deps, port_allocator)?; - for (name, project) in registry.iter() { - println!( - "{} :{}{}", - name, - project.port, - project - .matcher - .as_ref() - .map(|matcher| format!(" (matches {matcher})")) - .unwrap_or_default() - ); - } + let output = + registry + .iter_projects() + .fold(String::new(), |mut output, (name, project)| { + let _ = writeln!(output, "{}", format_project(name, project)); + output + }); + print!("{output}"); + } + + Cli::Link { port, project_name } => { + let mut registry = PortRegistry::new(deps, port_allocator)?; + let project_name = match project_name { + Some(name) => name, + None => active_project(deps, ®istry)?.0.clone(), + }; + registry.link(deps, port, &project_name)?; + println!("Linked project {project_name} to port {port}"); + } + + Cli::Unlink { project_name } => { + let mut registry = PortRegistry::new(deps, port_allocator)?; + let project_name = match project_name { + Some(name) => name, + None => active_project(deps, ®istry)?.0.clone(), + }; + match registry.unlink(deps, &project_name)? { + Some(port) => println!("Unlinked project {project_name} from port {port}"), + None => println!("Project {project_name} was not linked to a port"), + }; } Cli::Caddyfile => { @@ -223,7 +307,7 @@ fn run( Cli::ReloadCaddy => { let registry = PortRegistry::new(deps, port_allocator)?; reload(deps, ®istry)?; - println!("caddy was successfully reloaded"); + println!("Successfully reloaded caddy"); } } @@ -241,8 +325,8 @@ fn main() { #[cfg(test)] mod tests { use super::*; - use crate::dependencies::mocks::{data_dir_mock, exec_mock, write_file_mock}; - use std::{os::unix::process::ExitStatusExt, path::PathBuf}; + use crate::dependencies::mocks::{cwd_mock, data_dir_mock, exec_mock, write_file_mock}; + use std::os::unix::process::ExitStatusExt; use unimock::{matching, Clause, MockFn}; fn choose_port_mock() -> Clause { @@ -252,13 +336,6 @@ mod tests { .in_any_order() } - fn cwd_mock() -> Clause { - dependencies::get_cwd::Fn - .each_call(matching!(_)) - .answers(|_| Ok(PathBuf::from("/portman"))) - .in_any_order() - } - fn read_file_mock() -> Clause { dependencies::read_file::Fn .each_call(matching!(_)) @@ -274,30 +351,57 @@ mod tests { } #[test] - fn test_allocate() { + fn test_format_project_simple() { + assert_eq!( + format_project( + "app1", + &Project { + port: 3001, + directory: None, + linked_port: None, + } + ), + String::from("app1 :3001"), + ); + } + + #[test] + fn test_format_project_complex() { + assert_eq!( + format_project( + "app1", + &Project { + port: 3001, + directory: Some(PathBuf::from("/projects/app1")), + linked_port: Some(3000), + } + ), + String::from("app1 :3001 -> :3000 (/projects/app1)"), + ); + } + + #[test] + fn test_create() { let mocked_deps = unimock::mock([data_dir_mock(), read_file_mock()]); let config = Config::default(); let allocator = PortAllocator::new(config.get_valid_ports()); let mut registry = PortRegistry::new(&mocked_deps, allocator).unwrap(); let mocked_deps = unimock::mock([ - data_dir_mock(), choose_port_mock(), + cwd_mock(), + data_dir_mock(), exec_mock(), read_file_mock(), read_var_mock(), - dependencies::write_file::Fn - .each_call(matching!(_)) - .answers(|_| Ok(())) - .in_any_order(), + write_file_mock(), ]); - let (name, project) = allocate( + let (name, project) = create( &mocked_deps, &mut registry, Some(String::from("project")), - None, - &cli::Matcher::None, false, + None, ) .unwrap(); assert_eq!(name, String::from("project")); @@ -305,43 +409,66 @@ mod tests { project, Project { port: 3000, - pinned: false, - matcher: None, - redirect: false, + directory: Some(PathBuf::from("/portman")), + linked_port: None, } ); } #[test] - fn test_cli_version() { + fn test_cleanup() { let mocked_deps = unimock::mock([ - dependencies::get_args::Fn - .each_call(matching!()) - .returns(vec![String::from("portman"), String::from("--version")]) + data_dir_mock(), + dependencies::read_file::Fn + .each_call(matching!(_)) + .answers(|_| { + Ok(Some(String::from( + "[projects] +app1 = { port = 3001 } +app2 = { port = 3002 } +app3 = { port = 3003, directory = '/projects/app3' } +app4 = { port = 3004, directory = '/projects/app4' }", + ))) + }) .in_any_order(), + ]); + let config = Config::default(); + let allocator = PortAllocator::new(config.get_valid_ports()); + let mut registry = PortRegistry::new(&mocked_deps, allocator).unwrap(); + + let mocked_deps = unimock::mock([ + dependencies::path_exists::Fn + .next_call(matching!((path) if path == &PathBuf::from("/projects/app3"))) + .answers(|_| false) + .once() + .in_order(), + dependencies::path_exists::Fn + .next_call(matching!((path) if path == &PathBuf::from("/projects/app4"))) + .answers(|_| true) + .once() + .in_order(), data_dir_mock(), + exec_mock(), read_file_mock(), read_var_mock(), + write_file_mock(), ]); - let result = run(&mocked_deps); - assert!(result.is_ok()); + let cleaned_projects = cleanup(&mocked_deps, &mut registry).unwrap(); + assert_eq!(cleaned_projects.len(), 1); + assert_eq!(cleaned_projects.get(0).unwrap().0, String::from("app3")); } #[test] - fn test_cli_allocate() { + fn test_cli_version() { let mocked_deps = unimock::mock([ dependencies::get_args::Fn .each_call(matching!()) - .returns(vec![String::from("portman"), String::from("allocate")]) + .returns(vec![String::from("portman"), String::from("--version")]) .in_any_order(), - choose_port_mock(), - cwd_mock(), data_dir_mock(), - exec_mock(), read_file_mock(), read_var_mock(), - write_file_mock(), ]); let result = run(&mocked_deps); @@ -349,15 +476,11 @@ mod tests { } #[test] - fn test_cli_allocate_dir_matcher() { + fn test_cli_create() { let mocked_deps = unimock::mock([ dependencies::get_args::Fn .each_call(matching!()) - .returns(vec![ - String::from("portman"), - String::from("allocate"), - String::from("--matcher=dir"), - ]) + .returns(vec![String::from("portman"), String::from("create")]) .in_any_order(), choose_port_mock(), cwd_mock(), @@ -373,27 +496,20 @@ mod tests { } #[test] - fn test_cli_allocate_git_matcher() { + fn test_cli_create_no_activate() { let mocked_deps = unimock::mock([ dependencies::get_args::Fn .each_call(matching!()) .returns(vec![ String::from("portman"), - String::from("allocate"), - String::from("--matcher=git"), + String::from("create"), + String::from("project"), + String::from("--no-activate"), ]) .in_any_order(), choose_port_mock(), data_dir_mock(), - dependencies::exec::Fn - .each_call(matching!(_)) - .answers(|_| { - Ok(( - ExitStatusExt::from_raw(0), - String::from("https://github.com/user/project.git"), - )) - }) - .in_any_order(), + exec_mock(), read_file_mock(), read_var_mock(), write_file_mock(), @@ -404,14 +520,14 @@ mod tests { } #[test] - fn test_cli_allocate_none_matcher() { + fn test_cli_create_no_activate_no_name() { let mocked_deps = unimock::mock([ dependencies::get_args::Fn .each_call(matching!()) .returns(vec![ String::from("portman"), - String::from("allocate"), - String::from("--matcher=none"), + String::from("create"), + String::from("--no-activate"), ]) .in_any_order(), data_dir_mock(), @@ -423,30 +539,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn test_cli_allocate_none_matcher_name() { - let mocked_deps = unimock::mock([ - dependencies::get_args::Fn - .each_call(matching!()) - .returns(vec![ - String::from("portman"), - String::from("allocate"), - String::from("project"), - String::from("--matcher=none"), - ]) - .in_any_order(), - choose_port_mock(), - data_dir_mock(), - exec_mock(), - read_file_mock(), - read_var_mock(), - write_file_mock(), - ]); - - let result = run(&mocked_deps); - assert!(result.is_ok()); - } - #[test] fn test_edit_config() { let mocked_deps = unimock::mock([ diff --git a/src/matcher.rs b/src/matcher.rs deleted file mode 100644 index 9bcc1cc..0000000 --- a/src/matcher.rs +++ /dev/null @@ -1,188 +0,0 @@ -use crate::dependencies::{Exec, WorkingDirectory}; -use anyhow::{anyhow, Context, Result}; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::{fmt::Display, path::PathBuf, process::Command}; - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case", tag = "type")] -pub enum Matcher { - Directory { directory: PathBuf }, - GitRepository { repository: String }, -} - -impl Matcher { - // Return the origin remote URL of the git repository in the current working directory - fn get_git_repo(deps: &impl Exec) -> Result { - let (_, stdout) = - deps.exec(Command::new("git").args(["config", "--get", "remote.origin.url"]))?; - Ok(stdout.trim_end().to_string()) - } - - // Return the path to the current working directory - fn get_cwd(deps: &impl WorkingDirectory) -> Result { - deps.get_cwd() - } - - // Create a new matcher that will match the current working directory - pub fn from_cwd(deps: &impl WorkingDirectory) -> Result { - Ok(Matcher::Directory { - directory: Self::get_cwd(deps)?, - }) - } - - // Create a new matcher that will match the origin remote URL of the git - // repository in the current working directory - pub fn from_git(deps: &impl Exec) -> Result { - Ok(Matcher::GitRepository { - repository: Self::get_git_repo(deps)?, - }) - } - - // Extract the name of a project from its matcher - pub fn get_name(&self) -> Result { - match self { - Matcher::GitRepository { repository } => { - lazy_static::lazy_static! { - static ref RE: Regex = - Regex::new(r"^https://github\.com/(?:.+)/(?P.+?)(?:\.git)?$").unwrap(); - } - RE.captures(repository) - .and_then(|captures| captures.name("project")) - .map(|capture| capture.as_str().to_string()) - .ok_or_else(|| anyhow!("Failed to extract project name from git repo URL")) - } - Matcher::Directory { directory } => { - let basename = directory - .file_name() - .context("Failed to extract directory basename")?; - Ok(basename - .to_str() - .context("Failed to convert directory to string")? - .to_string()) - } - } - } - - // Determine whether the current working directory is a match for this matcher - pub fn matches_cwd(&self, deps: &(impl Exec + WorkingDirectory)) -> Result { - match self { - Matcher::GitRepository { repository } => Ok(Self::get_git_repo(deps)? == *repository), - Matcher::Directory { directory } => Ok(Self::get_cwd(deps)? == *directory), - } - } -} - -impl Display for Matcher { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Matcher::Directory { directory } => { - write!(f, "directory \"{}\"", directory.to_string_lossy()) - } - Matcher::GitRepository { repository } => write!(f, "git repo \"{repository}\""), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::dependencies; - use std::os::unix::process::ExitStatusExt; - use unimock::{matching, MockFn}; - - const CWD: &str = "/path/to/directory"; - const GIT_REPO: &str = "https://github.com/user/project.git"; - - fn dir_matcher() -> Matcher { - Matcher::Directory { - directory: PathBuf::from(CWD), - } - } - - fn git_matcher() -> Matcher { - Matcher::GitRepository { - repository: String::from(GIT_REPO), - } - } - - #[test] - fn test_from_git() { - let mocked_deps = unimock::mock([dependencies::exec::Fn - .each_call(matching!(_)) - .answers(|_| Ok((ExitStatusExt::from_raw(0), format!("{GIT_REPO}\n")))) - .in_any_order()]); - assert_eq!( - Matcher::from_git(&mocked_deps).unwrap(), - Matcher::GitRepository { - repository: String::from(GIT_REPO), - } - ); - } - - #[test] - fn test_from_cwd() { - let mocked_deps = unimock::mock([dependencies::get_cwd::Fn - .each_call(matching!()) - .answers(|_| Ok(PathBuf::from(CWD))) - .in_any_order()]); - assert_eq!( - Matcher::from_cwd(&mocked_deps).unwrap(), - Matcher::Directory { - directory: PathBuf::from(CWD), - } - ); - } - - #[test] - fn test_get_name() { - assert_eq!(dir_matcher().get_name().unwrap(), "directory"); - assert_eq!(git_matcher().get_name().unwrap(), "project"); - - let matcher = Matcher::GitRepository { - repository: String::from(&GIT_REPO[..GIT_REPO.len() - 4]), - }; - assert_eq!(matcher.get_name().unwrap(), "project"); - - let matcher = Matcher::GitRepository { - repository: String::from("https://gitlab.com/project/project"), - }; - assert!(matcher.get_name().is_err()); - } - - #[test] - fn test_matches_cwd() { - let mocked_deps = unimock::mock([ - dependencies::get_cwd::Fn - .each_call(matching!()) - .answers(|_| Ok(PathBuf::from(CWD))) - .in_any_order(), - dependencies::exec::Fn - .each_call(matching!(_)) - .answers(|_| Ok((ExitStatusExt::from_raw(0), format!("{GIT_REPO}\n")))) - .in_any_order(), - ]); - assert!(dir_matcher().matches_cwd(&mocked_deps).unwrap()); - assert!(git_matcher().matches_cwd(&mocked_deps).unwrap()); - - assert!(!Matcher::Directory { - directory: PathBuf::from("/path/to/other/directory"), - } - .matches_cwd(&mocked_deps) - .unwrap()); - assert!(!Matcher::GitRepository { - repository: String::from("https://github.com/user/other-project.git"), - } - .matches_cwd(&mocked_deps) - .unwrap()); - } - - #[test] - fn test_display() { - assert_eq!(format!("{}", dir_matcher()), format!("directory \"{CWD}\"")); - assert_eq!( - format!("{}", git_matcher()), - format!("git repo \"{GIT_REPO}\"") - ); - } -} diff --git a/src/registry.rs b/src/registry.rs index 3b35b1e..2e8ce87 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,25 +1,16 @@ use crate::caddy::reload; use crate::dependencies::{ChoosePort, DataDir, Exec, ReadFile, WorkingDirectory, WriteFile}; -use crate::matcher::Matcher; use crate::{allocator::PortAllocator, dependencies::Environment}; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeMap, path::PathBuf}; +use std::collections::{BTreeMap, HashSet}; +use std::path::PathBuf; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Project { pub port: u16, - - // If pinned, this port won't ever be changed, if it is not an available - // port according to the config - #[serde(default)] - pub pinned: bool, - - // Use redirection instead of a reverse-proxy in the Caddyfile - #[serde(default)] - pub redirect: bool, - - pub matcher: Option, + pub directory: Option, + pub linked_port: Option, } // The port registry data that will be serialized and deserialized in the database @@ -48,46 +39,67 @@ impl PortRegistry { toml::from_str::(®istry_str).with_context(|| { format!( "Failed to deserialize project registry at \"{}\"", - store_path.to_string_lossy() + store_path.display() ) }) }) .transpose()? .unwrap_or_default(); - // Validate all ports in the registry against the required config and - // regenerate invalid ones as necessary - let mut changed = false; let mut allocator = port_allocator; - let validated_projects = registry_data + let mut linked_ports = HashSet::new(); + for project in registry_data.projects.values() { + if let Some(linked_port) = project.linked_port { + // Prevent projects from using this port + allocator.discard(linked_port); + }; + } + + let mut changed = false; + let mut directories: HashSet = HashSet::new(); + + // Validate all ports in the registry against the config and regenerate + // invalid ones as necessary + let projects = registry_data .projects .into_iter() .map(|(name, old_project)| { - if old_project.pinned { - // Don't reallocate the project's port if the port is pinned - Some((name, old_project)) - } else { - allocator - .allocate(deps, Some(old_project.port)) - .map(|port| { - if port != old_project.port { - changed = true; - } - ( - name, - Project { - port, - ..old_project - }, - ) - }) + Self::validate_name(&name)?; + + let mut old_project = old_project; + + if let Some(linked_port) = old_project.linked_port { + if !linked_ports.insert(linked_port) { + old_project.linked_port = None; + changed = true; + } } + + if let Some(directory) = old_project.directory.as_ref() { + if !directories.insert(directory.clone()) { + old_project.directory = None; + changed = true; + } + } + + let existing_port = old_project.port; + allocator.allocate(deps, Some(existing_port)).map(|port| { + if port != existing_port { + changed = true; + } + ( + name, + Project { + port, + ..old_project + }, + ) + }) }) - .collect::>>() - .ok_or_else(|| anyhow!("All available ports have been allocated already"))?; + .collect::>>()?; let registry = Self { store_path, - projects: validated_projects, + projects, allocator, }; if changed { @@ -110,75 +122,106 @@ impl PortRegistry { .context("Failed to save registry")?; if let Err(err) = reload(deps, self) { // An error reloading Caddy is just a warning, not a fatal error - println!("Warning: couldn't reload Caddy config.\n\n{err}"); + eprintln!("Warning: couldn't reload Caddy config.\n\n{err}"); } Ok(()) } // Get a project from the registry - pub fn get(&self, name: &String) -> Option<&Project> { + pub fn get(&self, name: &str) -> Option<&Project> { self.projects.get(name) } - // Allocate a port to a new project - pub fn allocate( + // Create a new project + pub fn create( &mut self, deps: &(impl ChoosePort + DataDir + Environment + Exec + ReadFile + WriteFile), name: String, - port: Option, - redirect: bool, - matcher: Option, + directory: Option, + linked_port: Option, ) -> Result { - if self.projects.get(&name).is_some() { - bail!("Project \"{name}\" already exists"); + Self::validate_name(&name)?; + + if self.projects.contains_key(&name) { + bail!("A project already has the name {name}"); } - if let Some(matcher) = matcher.as_ref() { - if self.projects.values().any(|project| { - project - .matcher - .as_ref() - .map_or(false, |existing_matcher| existing_matcher == matcher) - }) { - bail!("Project with matcher \"{matcher}\" already exists"); + if let Some(directory) = directory.as_ref() { + if self + .projects + .values() + .any(|project| project.directory.as_ref() == Some(directory)) + { + bail!( + "A project already has the directory \"{}\"", + directory.display() + ); } } - let new_port = match port { - Some(port) => port, - None => self - .allocator - .allocate(deps, None) - .ok_or_else(|| anyhow!("Failed to choose a port"))?, - }; + if let Some(port) = linked_port { + for project in &mut self.projects.values_mut() { + if project.port == port { + // Take the port from the project so that it can be used by the linked port + self.allocator.discard(port); + project.port = self.allocator.allocate(deps, None)?; + } + if project.linked_port == linked_port { + // Unlink the previously linked project + project.linked_port = None; + } + } + } + + let port = self.allocator.allocate(deps, None)?; let new_project = Project { - port: new_port, - pinned: port.is_some(), - redirect, - matcher, + port, + directory, + linked_port, }; self.projects.insert(name, new_project.clone()); + self.save(deps)?; Ok(new_project) } - // Release a previously allocated project's port - pub fn release( + // Delete a project + pub fn delete( &mut self, deps: &(impl DataDir + Environment + Exec + ReadFile + WriteFile), - name: &String, + name: &str, ) -> Result { - match self.projects.remove(name) { - Some(project) => { - self.save(deps)?; - Ok(project) - } - None => Err(anyhow!("Project \"{name}\" does not exist")), + let Some(project) = self.projects.remove(name) else { + bail!("Project {name} does not exist"); + }; + self.save(deps)?; + Ok(project) + } + + // Delete multiple projects + pub fn delete_many( + &mut self, + deps: &(impl DataDir + Environment + Exec + ReadFile + WriteFile), + project_names: Vec, + ) -> Result> { + let deleted_projects: Vec<(String, Project)> = project_names + .into_iter() + .map(|name| { + if let Some(project) = self.projects.remove(&name) { + Ok((name, project)) + } else { + bail!("Project {name} does not exist"); + } + }) + .collect::>>()?; + if !deleted_projects.is_empty() { + self.save(deps)?; } + Ok(deleted_projects) } - // Release all previously allocated projects - pub fn release_all( + // Delete all projects + pub fn delete_all( &mut self, deps: &(impl DataDir + Environment + Exec + ReadFile + WriteFile), ) -> Result<()> { @@ -187,21 +230,114 @@ impl PortRegistry { } // Iterate over all projects with their names - pub fn iter(&self) -> impl Iterator { + pub fn iter_projects(&self) -> impl Iterator { self.projects.iter() } + // Link a port to a project + pub fn link( + &mut self, + deps: &(impl ChoosePort + DataDir + Environment + Exec + ReadFile + WriteFile), + linked_port: u16, + project_name: &str, + ) -> Result<()> { + if !self.projects.contains_key(project_name) { + bail!("Project {project_name} does not exist"); + } + + for (name, project) in &mut self.projects.iter_mut() { + if project.port == linked_port { + // Take the port from the project so that it can be used by the linked port + project.port = self.allocator.allocate(deps, None)?; + } + if name == project_name { + // Link the port to the new project + project.linked_port = Some(linked_port); + } else if project.linked_port == Some(linked_port) { + // Unlink the port from the previous project + project.linked_port = None; + } + } + + self.save(deps) + } + + // Unlink the port linked to a project, returning the previous linked port + pub fn unlink( + &mut self, + deps: &(impl ChoosePort + DataDir + Environment + Exec + ReadFile + WriteFile), + project_name: &str, + ) -> Result> { + let Some(project) = self.projects.get_mut(project_name) else { + bail!("Project {project_name} does not exist"); + }; + + let previous_linked_port = project.linked_port.take(); + if previous_linked_port.is_some() { + self.save(deps)?; + } + Ok(previous_linked_port) + } + // Find and return the project that matches the current working directory, if any - pub fn match_cwd( - &self, - deps: &(impl Environment + Exec + WorkingDirectory), - ) -> Option<(&String, &Project)> { - self.iter().find(|(_, project)| { + pub fn match_cwd(&self, deps: &impl WorkingDirectory) -> Result> { + let cwd = deps.get_cwd()?; + Ok(self.iter_projects().find(|(_, project)| { project - .matcher + .directory .as_ref() - .map_or(false, |matcher| matcher.matches_cwd(deps).unwrap_or(false)) - }) + .map_or(false, |directory| directory == &cwd) + })) + } + + // Normalize a potential project name by stripping out invalid characters + pub fn normalize_name(name: &str) -> String { + let mut normalized = name + .chars() + .map(|char| { + if char.is_ascii_alphanumeric() || char == '-' { + char + } else { + '-' + } + }) + .skip_while(|char| char == &'-') + .fold(String::with_capacity(name.len()), |mut result, char| { + // Remove adjacent dashes + if !(result.ends_with('-') && char == '-') { + result.push(char.to_ascii_lowercase()); + } + result + }) + .to_string(); + normalized.truncate(63); + if normalized.ends_with('-') { + normalized.pop(); + } + normalized + } + + // Validate a project name + pub fn validate_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("Project name cannot be empty") + } + if name.len() > 63 { + bail!("Project name cannot exceed 63 characters") + } + if name.starts_with('-') || name.ends_with('-') { + bail!("Project name cannot start or end with a dash") + } + if name.contains("--") { + bail!("Project name cannot contain consecutive dashes") + } + if name + .chars() + .any(|char| !(char.is_ascii_lowercase() || char.is_numeric() || char == '-')) + { + bail!("Project name can only contain the lowercase alphanumeric characters and dashes") + } + Ok(()) } } @@ -209,8 +345,8 @@ impl PortRegistry { pub mod tests { use super::*; use crate::config::Config; - use crate::dependencies::mocks::{exec_mock, write_file_mock}; - use crate::dependencies::{self, mocks::data_dir_mock}; + use crate::dependencies::mocks::{data_dir_mock, exec_mock, write_file_mock}; + use crate::dependencies::{self}; use std::os::unix::process::ExitStatusExt; use unimock::{matching, MockFn}; @@ -222,18 +358,10 @@ port = 3001 [projects.app2] port = 3002 -pinned = true - -[projects.app2.matcher] -type = 'git_repository' -repository = 'https://github.com/user/app2.git' +linked_port = 3000 [projects.app3] port = 3003 -redirect = true - -[projects.app3.matcher] -type = 'directory' directory = '/projects/app3' "; @@ -261,8 +389,8 @@ directory = '/projects/app3' pub fn get_mocked_registry() -> Result { let mocked_deps = unimock::mock([data_dir_mock(), read_file_mock()]); let config = Config::default(); - let mock_allocator = PortAllocator::new(config.get_valid_ports()); - PortRegistry::new(&mocked_deps, mock_allocator) + let allocator = PortAllocator::new(config.get_valid_ports()); + PortRegistry::new(&mocked_deps, allocator) } #[test] @@ -275,12 +403,111 @@ directory = '/projects/app3' .answers(|_| Ok(Some(String::from(";")))) .in_any_order(), ]); - let mock_allocator = PortAllocator::new(config.get_valid_ports()); - assert!(PortRegistry::new(&mocked_deps, mock_allocator).is_err()); + let allocator = PortAllocator::new(config.get_valid_ports()); + assert!(PortRegistry::new(&mocked_deps, allocator).is_err()); + } + + #[test] + fn test_load_invalid_name() { + let config = Config::default(); + let mocked_deps = unimock::mock([ + data_dir_mock(), + dependencies::read_file::Fn + .each_call(matching!(_)) + .answers(|_| Ok(Some(String::from("projects.App1 = { port = 3001 }")))) + .in_any_order(), + ]); + let allocator = PortAllocator::new(config.get_valid_ports()); + assert!(PortRegistry::new(&mocked_deps, allocator).is_err()); + } + + #[test] + fn test_load_duplicate_directory() { + let config = Config::default(); + let mocked_deps = unimock::mock([ + data_dir_mock(), + exec_mock(), + dependencies::read_file::Fn + .each_call(matching!(_)) + .answers(|_| { + Ok(Some(String::from( + "[projects] + +[projects.app1] +port = 3001 +directory = '/projects/app' + +[projects.app2] +port = 3002 +directory = '/projects/app'", + ))) + }) + .in_any_order(), + read_var_mock(), + write_file_mock(), + ]); + let allocator = PortAllocator::new(config.get_valid_ports()); + let registry = PortRegistry::new(&mocked_deps, allocator).unwrap(); + assert!(registry.get("app1").unwrap().directory.is_some()); + assert!(registry.get("app2").unwrap().directory.is_none()); + } + + #[test] + fn test_load_duplicate_linked() { + let config = Config::default(); + let mocked_deps = unimock::mock([ + data_dir_mock(), + exec_mock(), + dependencies::read_file::Fn + .each_call(matching!(_)) + .answers(|_| { + Ok(Some(String::from( + "[projects] + +[projects.app1] +port = 3001 +linked_port = 3000 + +[projects.app2] +port = 3002 +linked_port = 3000", + ))) + }) + .in_any_order(), + read_var_mock(), + write_file_mock(), + ]); + let allocator = PortAllocator::new(config.get_valid_ports()); + let registry = PortRegistry::new(&mocked_deps, allocator).unwrap(); + assert!(registry.get("app1").unwrap().linked_port.is_some()); + assert!(registry.get("app2").unwrap().linked_port.is_none()); } #[test] - fn test_load_normalizes() -> Result<()> { + fn test_load_reallocates_for_linked() { + let config = Config::default(); + let mocked_deps = unimock::mock([ + choose_port_mock(), + data_dir_mock(), + exec_mock(), + dependencies::read_file::Fn + .each_call(matching!(_)) + .answers(|_| { + Ok(Some(String::from( + "projects.app1 = { port = 3001, linked_port = 3001 }", + ))) + }) + .in_any_order(), + read_var_mock(), + write_file_mock(), + ]); + let allocator = PortAllocator::new(config.get_valid_ports()); + let registry = PortRegistry::new(&mocked_deps, allocator).unwrap(); + assert_eq!(registry.projects.get("app1").unwrap().port, 3000); + } + + #[test] + fn test_load_normalizes() { let config = Config { ranges: vec![(4000, 4999)], ..Default::default() @@ -289,28 +516,26 @@ directory = '/projects/app3' choose_port_mock(), data_dir_mock(), exec_mock(), - read_var_mock(), read_file_mock(), + read_var_mock(), write_file_mock(), ]); - let mock_allocator = PortAllocator::new(config.get_valid_ports()); - let registry = PortRegistry::new(&mocked_deps, mock_allocator)?; - assert_eq!(registry.projects.get("app1").unwrap().port, 4000); - assert_eq!(registry.projects.get("app2").unwrap().port, 3002); - assert_eq!(registry.projects.get("app3").unwrap().port, 4001); - Ok(()) + let allocator = PortAllocator::new(config.get_valid_ports()); + let registry = PortRegistry::new(&mocked_deps, allocator).unwrap(); + assert_eq!(registry.get("app1").unwrap().port, 4000); + assert_eq!(registry.get("app2").unwrap().port, 4001); + assert_eq!(registry.get("app3").unwrap().port, 4002); } #[test] - fn test_get() -> Result<()> { - let registry = get_mocked_registry()?; - assert_eq!(registry.get(&String::from("app1")).unwrap().port, 3001); - assert!(registry.get(&String::from("app4")).is_none()); - Ok(()) + fn test_get() { + let registry = get_mocked_registry().unwrap(); + assert_eq!(registry.get("app1").unwrap().port, 3001); + assert!(registry.get("app4").is_none()); } #[test] - fn test_allocate() -> Result<()> { + fn test_create() { let mocked_deps = unimock::mock([ choose_port_mock(), data_dir_mock(), @@ -319,24 +544,35 @@ directory = '/projects/app3' read_var_mock(), write_file_mock(), ]); - let mut registry = get_mocked_registry()?; - registry.allocate(&mocked_deps, String::from("app4"), None, false, None)?; - assert!(registry.projects.get(&String::from("app4")).is_some()); - Ok(()) + let mut registry = get_mocked_registry().unwrap(); + registry + .create(&mocked_deps, String::from("app4"), None, None) + .unwrap(); + assert!(registry.get("app4").is_some()); + } + + #[test] + fn test_create_invalid_name() { + let mocked_deps = unimock::mock([]); + let mut registry = get_mocked_registry().unwrap(); + assert!(registry + .create(&mocked_deps, String::from("App3"), None, None) + .is_err()); } #[test] - fn test_allocate_duplicate_name() { + fn test_create_duplicate_name() { let mocked_deps = unimock::mock([]); let mut registry = get_mocked_registry().unwrap(); assert!(registry - .allocate(&mocked_deps, String::from("app3"), None, false, None) + .create(&mocked_deps, String::from("app3"), None, None) .is_err()); } #[test] - fn test_allocate_with_port() { + fn test_create_linked_port() { let mocked_deps = unimock::mock([ + choose_port_mock(), data_dir_mock(), exec_mock(), read_file_mock(), @@ -344,18 +580,16 @@ directory = '/projects/app3' write_file_mock(), ]); let mut registry = get_mocked_registry().unwrap(); - assert_eq!( - registry - .allocate(&mocked_deps, String::from("app4"), Some(3100), false, None) - .unwrap() - .port, - 3100 - ); + registry + .create(&mocked_deps, String::from("app4"), None, Some(3100)) + .unwrap(); + assert_eq!(registry.get("app4").unwrap().linked_port.unwrap(), 3100); } #[test] - fn test_allocate_with_duplicate_port() { + fn test_create_linked_port_reallocates_previous() { let mocked_deps = unimock::mock([ + choose_port_mock(), data_dir_mock(), exec_mock(), read_file_mock(), @@ -363,56 +597,28 @@ directory = '/projects/app3' write_file_mock(), ]); let mut registry = get_mocked_registry().unwrap(); - assert_eq!( - registry - .allocate(&mocked_deps, String::from("app4"), Some(3001), false, None) - .unwrap(), - Project { - port: 3001, - pinned: true, - redirect: false, - matcher: None, - } - ); + registry + .create(&mocked_deps, String::from("app4"), None, Some(3001)) + .unwrap(); + assert_eq!(registry.get("app1").unwrap().port, 3004); } #[test] - fn test_allocate_duplicate_matcher() { + fn test_create_duplicate_directory() { let mocked_deps = unimock::mock([]); let mut registry = get_mocked_registry().unwrap(); assert!(registry - .allocate( + .create( &mocked_deps, String::from("app4"), + Some(PathBuf::from("/projects/app3")), None, - false, - Some(Matcher::Directory { - directory: PathBuf::from("/projects/app3") - }), ) .is_err()); } #[test] - fn test_allocate_redirect() { - let mocked_deps = unimock::mock([ - data_dir_mock(), - exec_mock(), - read_file_mock(), - read_var_mock(), - write_file_mock(), - ]); - let mut registry = get_mocked_registry().unwrap(); - assert!( - registry - .allocate(&mocked_deps, String::from("app4"), Some(3100), true, None) - .unwrap() - .redirect, - ); - } - - #[test] - fn test_allocate_caddy_read_failure() { + fn test_create_caddy_read_failure() { let mocked_deps = unimock::mock([ choose_port_mock(), data_dir_mock(), @@ -425,12 +631,12 @@ directory = '/projects/app3' ]); let mut registry = get_mocked_registry().unwrap(); assert!(registry - .allocate(&mocked_deps, String::from("app4"), None, false, None,) + .create(&mocked_deps, String::from("app4"), None, None) .is_ok()); } #[test] - fn test_allocate_caddy_write_portman_caddyfile_failure() { + fn test_create_caddy_write_portman_caddyfile_failure() { let mocked_deps = unimock::mock([ choose_port_mock(), data_dir_mock(), @@ -447,12 +653,12 @@ directory = '/projects/app3' ]); let mut registry = get_mocked_registry().unwrap(); assert!(registry - .allocate(&mocked_deps, String::from("app4"), None, false, None,) + .create(&mocked_deps, String::from("app4"), None, None) .is_ok()); } #[test] - fn test_allocate_caddy_write_root_caddyfile_failure() { + fn test_create_caddy_write_root_caddyfile_failure() { let mocked_deps = unimock::mock([ choose_port_mock(), data_dir_mock(), @@ -476,12 +682,12 @@ directory = '/projects/app3' ]); let mut registry = get_mocked_registry().unwrap(); assert!(registry - .allocate(&mocked_deps, String::from("app4"), None, false, None,) + .create(&mocked_deps, String::from("app4"), None, None) .is_ok()); } #[test] - fn test_allocate_caddy_exec_failure() { + fn test_create_caddy_exec_failure() { let mocked_deps = unimock::mock([ choose_port_mock(), data_dir_mock(), @@ -495,12 +701,12 @@ directory = '/projects/app3' ]); let mut registry = get_mocked_registry().unwrap(); assert!(registry - .allocate(&mocked_deps, String::from("app4"), None, false, None) + .create(&mocked_deps, String::from("app4"), None, None) .is_ok()); } #[test] - fn test_release() -> Result<()> { + fn test_delete() { let mocked_deps = unimock::mock([ data_dir_mock(), exec_mock(), @@ -508,23 +714,56 @@ directory = '/projects/app3' read_var_mock(), write_file_mock(), ]); - let mut registry = get_mocked_registry()?; - registry.release(&mocked_deps, &String::from("app2"))?; - assert!(registry.projects.get(&String::from("app2")).is_none()); - Ok(()) + let mut registry = get_mocked_registry().unwrap(); + registry.delete(&mocked_deps, "app2").unwrap(); + assert!(registry.get("app2").is_none()); + } + + #[test] + fn test_delete_nonexistent() { + let mocked_deps = unimock::mock([]); + let mut registry = get_mocked_registry().unwrap(); + assert!(registry.delete(&mocked_deps, "app4").is_err()); } #[test] - fn test_release_nonexistent() { + fn test_delete_many() { + let mocked_deps = unimock::mock([ + data_dir_mock(), + exec_mock(), + read_file_mock(), + read_var_mock(), + write_file_mock(), + ]); + let mut registry = get_mocked_registry().unwrap(); + let deleted_projects = registry + .delete_many( + &mocked_deps, + vec![String::from("app1"), String::from("app2")], + ) + .unwrap() + .into_iter() + .map(|(name, _)| name) + .collect::>(); + assert_eq!( + deleted_projects, + vec![String::from("app1"), String::from("app2")], + ); + assert_eq!(registry.projects.keys().collect::>(), vec!["app3"]); + } + + #[test] + fn test_delete_many_none() { let mocked_deps = unimock::mock([]); let mut registry = get_mocked_registry().unwrap(); assert!(registry - .release(&mocked_deps, &String::from("app4")) - .is_err()); + .delete_many(&mocked_deps, vec![]) + .unwrap() + .is_empty()); } #[test] - fn test_release_all() -> Result<()> { + fn test_delete_all() { let mocked_deps = unimock::mock([ data_dir_mock(), exec_mock(), @@ -532,43 +771,145 @@ directory = '/projects/app3' read_var_mock(), write_file_mock(), ]); - let mut registry = get_mocked_registry()?; - registry.release_all(&mocked_deps)?; + let mut registry = get_mocked_registry().unwrap(); + registry.delete_all(&mocked_deps).unwrap(); assert!(registry.projects.is_empty()); - Ok(()) } #[test] - fn test_match_cwd_dir() { + fn test_link_create() { let mocked_deps = unimock::mock([ - dependencies::get_cwd::Fn - .each_call(matching!(_)) - .answers(|_| Ok(PathBuf::from("/projects/app3"))) - .in_any_order(), + data_dir_mock(), exec_mock(), + read_file_mock(), + read_var_mock(), + write_file_mock(), ]); - let registry = get_mocked_registry().unwrap(); + let mut registry = get_mocked_registry().unwrap(); + registry.link(&mocked_deps, 3005, "app2").unwrap(); + assert_eq!(registry.get("app2").unwrap().linked_port.unwrap(), 3005); + } + + #[test] + fn test_link_update() { + let mocked_deps = unimock::mock([ + data_dir_mock(), + exec_mock(), + read_file_mock(), + read_var_mock(), + write_file_mock(), + ]); + let mut registry = get_mocked_registry().unwrap(); + registry.link(&mocked_deps, 3000, "app3").unwrap(); + assert!(registry.get("app2").unwrap().linked_port.is_none()); + assert_eq!(registry.get("app3").unwrap().linked_port.unwrap(), 3000); + } + + #[test] + fn test_link_nonexistent() { + let mocked_deps = unimock::mock([]); + let mut registry = get_mocked_registry().unwrap(); + assert!(registry.link(&mocked_deps, 3004, "app4").is_err()); + } + + #[test] + fn test_link_reallocates_previous() { + let mocked_deps = unimock::mock([ + choose_port_mock(), + data_dir_mock(), + exec_mock(), + read_file_mock(), + read_var_mock(), + write_file_mock(), + ]); + let mut registry = get_mocked_registry().unwrap(); + registry.link(&mocked_deps, 3001, "app2").unwrap(); + assert_ne!(registry.get("app1").unwrap().port, 3001); + assert_eq!(registry.get("app2").unwrap().linked_port.unwrap(), 3001); + } + + #[test] + fn test_link_reallocates_self() { + let mocked_deps = unimock::mock([ + choose_port_mock(), + data_dir_mock(), + exec_mock(), + read_file_mock(), + read_var_mock(), + write_file_mock(), + ]); + let mut registry = get_mocked_registry().unwrap(); + registry.link(&mocked_deps, 3001, "app1").unwrap(); + assert_ne!(registry.get("app1").unwrap().port, 3001); + assert_eq!(registry.get("app1").unwrap().linked_port.unwrap(), 3001); + } + + #[test] + fn test_unlink() { + let mocked_deps = unimock::mock([ + data_dir_mock(), + exec_mock(), + read_file_mock(), + read_var_mock(), + write_file_mock(), + ]); + let mut registry = get_mocked_registry().unwrap(); assert_eq!( - registry.match_cwd(&mocked_deps).unwrap().0, - &String::from("app3") + registry.unlink(&mocked_deps, "app2").unwrap().unwrap(), + 3000, ); } #[test] - fn test_match_cwd_git() { - let mocked_deps = unimock::mock([dependencies::exec::Fn + fn test_unlink_no_previous() { + let mocked_deps = unimock::mock([]); + let mut registry = get_mocked_registry().unwrap(); + assert!(registry.unlink(&mocked_deps, "app1").unwrap().is_none()); + } + + #[test] + fn test_unlink_nonexistent() { + let mocked_deps = unimock::mock([]); + let mut registry = get_mocked_registry().unwrap(); + assert!(registry.unlink(&mocked_deps, "app4").is_err()); + } + + #[test] + fn test_match_cwd_dir() { + let mocked_deps = unimock::mock([dependencies::get_cwd::Fn .each_call(matching!(_)) - .answers(|_| { - Ok(( - ExitStatusExt::from_raw(0), - String::from("https://github.com/user/app2.git"), - )) - }) + .answers(|()| Ok(PathBuf::from("/projects/app3"))) .in_any_order()]); let registry = get_mocked_registry().unwrap(); assert_eq!( - registry.match_cwd(&mocked_deps).unwrap().0, - &String::from("app2") + registry.match_cwd(&mocked_deps).unwrap().unwrap().0, + ("app3") ); } + + #[test] + fn test_normalize_name() { + assert_eq!( + PortRegistry::normalize_name("--ABC_def---_123-"), + String::from("abc-def-123"), + ); + assert_eq!(PortRegistry::normalize_name(&"a".repeat(100)).len(), 63); + assert_eq!( + PortRegistry::normalize_name(&format!("{}-a", "a".repeat(62))).len(), + 62, + ); + } + + #[test] + fn test_validate_name() { + assert!(PortRegistry::validate_name("").is_err()); + assert!(PortRegistry::validate_name(&"a".repeat(64)).is_err()); + assert!(PortRegistry::validate_name("-a").is_err()); + assert!(PortRegistry::validate_name("a-").is_err()); + assert!(PortRegistry::validate_name("a--b").is_err()); + assert!(PortRegistry::validate_name("a_b").is_err()); + assert!(PortRegistry::validate_name("A-B").is_err()); + assert!(PortRegistry::validate_name("a").is_ok()); + assert!(PortRegistry::validate_name("a-0").is_ok()); + } }