Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support workspaces at library level #197

Merged
merged 17 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,4 @@ jobs:
- uses: actions-rs/[email protected]
with:
command: fmt
args: --all -- --check
args: --all -- --check
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ create-rust-app create <project_name>
- Add a task to the queue with `create_rust_app::tasks::queue()`
- Run the queue with `cargo run --bin tasks`

- **Workspace Support** (Enabled by default, not tied to a feature flag)
- allows you to organize your rust app in workspaces, and changes the defaults for the environment variables that specify paths to various important places.
- to organize you project as a workspace:
- enable this feature
- refactor your codebase into workspaces (see [#194](https://github.com/Wulf/create-rust-app/issues/194))
- Optional: set the following environment variables (paths are relative to the directory you call cargo fullstack/backend/run from)
- `CRA_MANIFEST_PATH`: default `./frontend/dist/manifest.json` when called from workspace root, `../frontend/dist/manifest.json` otherwise.
- `CRA_FRONTEND_DIR`: default `./frontend` when called from workspace root, `../frontend` otherwise.
- `CRA_VIEWS_GLOB`: default `backend/views/\*\*/\*.html` when called from workspace root, `views/\*\*/\*.html` otherwise.
- Note that in any non-standard setup, you will need to set the above environment variables to the correct values for your project to ensure correct behavior.

### 2. Code-gen to reduce boilerplate

```sh
Expand Down
7 changes: 5 additions & 2 deletions create-rust-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,13 @@ plugin_storage = [
"md5",
"mime_guess",
"base64",
"futures-util"
"futures-util",
]
plugin_graphql = []
plugin_utoipa = ["utoipa", "backend_actix-web"] # for now, only works with actix-web!
plugin_utoipa = [
"utoipa",
"backend_actix-web",
] # for now, only works with actix-web!
plugin_tasks = ["fang", "tokio"]
backend_poem = ["poem", "anyhow", "mime_guess", "tokio"]
backend_actix-web = [
Expand Down
6 changes: 5 additions & 1 deletion create-rust-app/src/dev/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,11 @@ fn get_features(project_dir: &'static str) -> Vec<String> {
let cargo_toml = Manifest::from_path(PathBuf::from_iter([project_dir, "Cargo.toml"]))
.unwrap_or_else(|_| panic!("Could not find \"{}\"", project_dir));
// .expect(&format!("Could not find \"{project_dir}\""));
let deps = cargo_toml.dependencies;
let mut deps = cargo_toml.dependencies;
if let Some(workspace) = cargo_toml.workspace {
// if the manifest has a workspace table, also read dependencies in there
deps.extend(workspace.dependencies);
}
let dep = deps.get("create-rust-app").unwrap_or_else(|| {
panic!(
"Expected \"{}\" to list 'create-rust-app' as a dependency.",
Expand Down
8 changes: 5 additions & 3 deletions create-rust-app/src/util/actix_web_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::str::FromStr;
use std::sync::Mutex;

use super::template_utils::SinglePageApplication;
use super::workspace_utils::frontend_dir;
use crate::util::template_utils::{to_template_name, DEFAULT_TEMPLATE, TEMPLATES};
use actix_files::NamedFile;
#[cfg(debug_assertions)]
Expand Down Expand Up @@ -102,13 +103,14 @@ pub async fn render_views(req: HttpRequest) -> HttpResponse {
#[cfg(debug_assertions)]
{
// dev asset serving
let asset_path = &format!("./frontend{path}");
let asset_path = &format!("{frontend_dir}{path}", frontend_dir = frontend_dir());
if std::path::PathBuf::from(asset_path).is_file() {
println!("ASSET_FILE {path} => {asset_path}");
return NamedFile::open(asset_path).unwrap().into_response(&req);
}

let public_path = &format!("./frontend/public{path}");
let public_path =
&format!("{frontend_dir}/public{path}", frontend_dir = frontend_dir());
if std::path::PathBuf::from(public_path).is_file() {
println!("PUBLIC_FILE {path} => {public_path}");
return NamedFile::open(public_path).unwrap().into_response(&req);
Expand All @@ -118,7 +120,7 @@ pub async fn render_views(req: HttpRequest) -> HttpResponse {
#[cfg(not(debug_assertions))]
{
// production asset serving
let static_path = &format!("./frontend/dist{path}");
let static_path = &format!("{frontend_dir}/dist{path}", frontend_dir = frontend_dir());
if std::path::PathBuf::from(static_path).is_file() {
return NamedFile::open(static_path).unwrap().into_response(&req);
}
Expand Down
3 changes: 3 additions & 0 deletions create-rust-app/src/util/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
/// are exposed directly as create_rust_app::<utilty-fn>.
///

/// constants for paths and files in workspaces
pub(crate) mod workspace_utils;

#[cfg(feature = "backend_actix-web")]
mod actix_web_utils;

Expand Down
8 changes: 5 additions & 3 deletions create-rust-app/src/util/poem_utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::sync::Mutex;

use super::workspace_utils::frontend_dir;
use poem::http::{StatusCode, Uri};
use poem::middleware::{AddData, AddDataEndpoint};
use poem::web::Data;
Expand Down Expand Up @@ -95,14 +96,15 @@ pub async fn render_views(uri: &Uri) -> impl IntoResponse {
#[cfg(debug_assertions)]
{
// dev asset serving
let asset_path = &format!("./frontend{path}");
let asset_path = &format!("{frontend_dir}{path}", frontend_dir = frontend_dir());
if std::path::PathBuf::from(asset_path).is_file() {
println!("ASSET_FILE {path} => {asset_path}");

return file_response(asset_path).await;
}

let public_path = &format!("./frontend/public{path}");
let public_path =
&format!("{frontend_dir}/public{path}", frontend_dir = frontend_dir());
if std::path::PathBuf::from(public_path).is_file() {
println!("PUBLIC_FILE {path} => {public_path}");

Expand All @@ -113,7 +115,7 @@ pub async fn render_views(uri: &Uri) -> impl IntoResponse {
#[cfg(not(debug_assertions))]
{
// production asset serving
let static_path = &format!("./frontend/dist{path}");
let static_path = &format!("{frontend_dir}/dist{path}", frontend_dir = frontend_dir());
if std::path::PathBuf::from(static_path).is_file() {
return file_response(static_path).await;
}
Expand Down
5 changes: 3 additions & 2 deletions create-rust-app/src/util/template_utils.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use super::workspace_utils::{manifest_path, views_glob};
use lazy_static::lazy_static;
use std::collections::HashMap;
use tera::Tera;
Expand All @@ -12,7 +13,7 @@ lazy_static! {
/// all the Templates (html files) included in backend/views/..., uses Tera to bundle the frontend into the template
/// TODO: ensure this is accurate documentation
pub static ref TEMPLATES: Tera = {
let mut tera = match Tera::new("backend/views/**/*.html") {
let mut tera = match Tera::new(views_glob()) {
Ok(t) => t,
Err(e) => {
println!("Parsing error(s): {e}");
Expand Down Expand Up @@ -155,7 +156,7 @@ fn load_manifest_entries() -> ViteManifest {

use serde_json::Value;
let manifest_json = serde_json::from_str(
std::fs::read_to_string(std::path::PathBuf::from("./frontend/dist/manifest.json"))
std::fs::read_to_string(std::path::PathBuf::from(manifest_path()))
.unwrap()
.as_str(),
)
Expand Down
236 changes: 236 additions & 0 deletions create-rust-app/src/util/workspace_utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
use std::path::Path;
use std::sync::OnceLock; // use LazyLock instead once that's stable

/// OnceLock wrapper around output of `cargo locate-project --workspace --message-format=plain`
///
/// if the command fails (e.g. if we're in a container or otherwise don't have access to the projects source code), or output can't be parsed, we return None
fn cargo_locate_project_workspace() -> Option<&'static str> {
static CARGO_LP_WORKSPACE: OnceLock<Option<String>> = OnceLock::new();
CARGO_LP_WORKSPACE
.get_or_init(|| {
let output = std::process::Command::new(env!("CARGO"))
.arg("locate-project")
.arg("--workspace")
.arg("--message-format=plain")
.output()
.ok()?;
let cargo_path = Path::new(std::str::from_utf8(&output.stdout).ok()?.trim());
Some(
cargo_path
.parent()
.and_then(|p| p.canonicalize().ok().or(Some(p.to_path_buf())))? // if we can't canonicalize the path, just use the original path
.to_str()?
.to_owned(),
)
})
.as_deref()
}

/// OnceLock wrapper around output of `cargo locate-project --message-format=plain`
///
/// if the command fails (e.g. if we're in a container or otherwise don't have access to the projects source code), or output can't be parsed, we return None
fn cargo_locate_project() -> Option<&'static str> {
static CARGO_LP: OnceLock<Option<String>> = OnceLock::new();
CARGO_LP
.get_or_init(|| {
let output = std::process::Command::new(env!("CARGO"))
.arg("locate-project")
.arg("--workspace")
.arg("--message-format=plain")
.output()
.ok()?;
let cargo_toml_path = Path::new(std::str::from_utf8(&output.stdout).ok()?.trim())
.parent()
.and_then(|p| p.canonicalize().ok().or(Some(p.to_path_buf())))? // if we can't canonicalize the path, just use the original path, paths should be canonicalized anyway but this is just to guarentee it
.to_str()?
.to_owned();
Some(cargo_toml_path)
})
.as_deref()
}

/// isolate logic for determining fallback values for the public functions below
/// we isolate the logic to here so we can ensure consistency and test it
///
/// # Arguments
/// - `cargo_lp_workspace_dir`: the output of `cargo locate-project --workspace --message-format=plain`, or None if the command failed
/// - `cargo_lp_dir`: the output of `cargo locate-project --message-format=plain`, or None if the command failed
/// - `comptime_manifest_dir`: the output of `env!("CARGO_MANIFEST_DIR")`
/// - `special_case`: the value to return if we're in a workspace, but not in the workspace root (like a workspace member)
/// - `default_case`: the value to return in all other cases
fn fallback(
cargo_lp_workspace_dir: Option<&'static str>,
cargo_lp_dir: Option<&'static str>,
comptime_manifest_dir: &'static str,
special_case: &'static str,
default_case: &'static str,
) -> &'static str {
match (cargo_lp_workspace_dir, cargo_lp_dir, comptime_manifest_dir) {
// if we're in a container or something, both functions will fail and return None, so this case won't be hit
// if we aren't using workspaces, both functions will return the same value, so this case won't be hit
// if we are using workspaces, and running in the workspace root, both functions will succeed and return the same value, so this case won't be hit
// if we're using workspaces, but executing from, say, the backend directory, the functions will return different values, so this case **will** be hit
// if the executable is being run from some other location, the functions might fail, they won't if the executable is being run in a directory containing another cargo project
// - but in that case, the "CARGO_MANIFEST_DIR" env var will not be the same as the output of the second function, so this case won't be hit
(Some(workspace_dir), Some(crate_dir), comptime_crate_dir)
// if !const_string_comp(workspace_dir,crate_dir) && const_string_comp(comptime_crate_dir, crate_dir) =>
if workspace_dir != crate_dir && comptime_crate_dir == crate_dir =>
{
special_case
}
_ => default_case,
}
}

/// fn for the path to the project's frontend directory
pub(crate) fn frontend_dir() -> &'static str {
static FRONTEND_DIR: OnceLock<String> = OnceLock::new();
FRONTEND_DIR.get_or_init(|| {
std::env::var("CRA_FRONTEND_DIR").unwrap_or_else(|_| {
fallback(
cargo_locate_project_workspace(),
cargo_locate_project(),
env!("CARGO_MANIFEST_DIR"),
"../frontend",
"./frontend",
)
.to_string()
})
})
}
/// fn for the path to the project's manifest.json file
pub(crate) fn manifest_path() -> &'static str {
static MANIFEST_PATH: OnceLock<String> = OnceLock::new();
MANIFEST_PATH.get_or_init(|| {
std::env::var("CRA_MANIFEST_PATH").unwrap_or_else(|_| {
fallback(
cargo_locate_project_workspace(),
cargo_locate_project(),
env!("CARGO_MANIFEST_DIR"),
"../frontend/dist/manifest.json",
"./frontend/dist/manifest.json",
)
.to_string()
})
})
}
/// fn for the path to the project's views directory
pub(crate) fn views_glob() -> &'static str {
static VIEWS_GLOB: OnceLock<String> = OnceLock::new();
VIEWS_GLOB.get_or_init(|| {
std::env::var("CRA_VIEWS_GLOB").unwrap_or_else(|_| {
fallback(
cargo_locate_project_workspace(),
cargo_locate_project(),
env!("CARGO_MANIFEST_DIR"),
"views/**/*.html",
"backend/views/**/*.html",
)
.to_string()
})
})
}

#[cfg(test)]
mod fallback_logic_tests {
use super::fallback;

#[test]
// we want the special case here
fn test_both_fail() {
assert_eq!(
fallback(None, None, "foo/bar", "special_case", "default_case"),
"default_case"
);
}

#[test]
// we want the default case here
fn test_not_using_workspaces() {
assert_eq!(
fallback(
Some("foo/bar"),
Some("foo/bar"),
"foo/bar",
"special_case",
"default_case"
),
"default_case"
);
}

#[test]
// we want the default case here
fn test_using_workspaces_at_workspace_root() {
assert_eq!(
fallback(
Some("foo/bar"),
Some("foo/bar"),
"foo/bar",
"special_case",
"default_case"
),
"default_case"
);
}

#[test]
// we want the special case here
fn test_using_workspaces_not_at_workspace_root() {
assert_eq!(
fallback(
Some("foo/bar"),
Some("foo/bar/baz"),
"foo/bar/baz",
"special_case",
"default_case"
),
"special_case"
);
}

#[test]
/// we can't know what the user would want here, so they should set the environment variables to determine the behavior
fn test_compiled_at_root_but_running_somewhere_else() {
assert_eq!(
fallback(
Some("foo/bar"),
Some("foo/bar/baz"),
"foo/bar",
"special_case",
"default_case"
),
"default_case"
);
}

#[test]
// we want the default case here
fn test_compiled_somewhere_else_but_running_at_root() {
assert_eq!(
fallback(
Some("foo/bar"),
Some("foo/bar"),
"foo/bar/baz",
"special_case",
"default_case"
),
"default_case"
);
}

#[test]
/// we can't know what the user would want here, so they should set the environment variables to determine the behavior
fn test_running_in_another_cargo_project() {
assert_eq!(
fallback(
Some("foo2/bar2"),
Some("foo2/bar2"),
"foo/bar/baz",
"special_case",
"default_case"
),
"default_case"
);
}
}
Loading