diff --git a/Cargo.lock b/Cargo.lock index 1a016c49f..b0509860d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4655,11 +4655,17 @@ dependencies = [ name = "spk-workspace" version = "0.42.0" dependencies = [ + "format_serde_error", "glob", + "miette", "rstest 0.18.2", "serde", "serde_json", + "serde_yaml 0.9.27", + "spk-schema-foundation", "spk-solve", + "tempfile", + "thiserror", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fca9b084e..1ca8b2365 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ dunce = "1.0.4" dyn-clone = "1.0" enum_dispatch = "0.3.13" flatbuffers = "23.5.26" +format_serde_error = { version = "0.3", default-features = false } fuser = "0.14.0" futures = "0.3.28" futures-core = "0.3.28" diff --git a/crates/spk-schema/Cargo.toml b/crates/spk-schema/Cargo.toml index b83acc60d..17db94761 100644 --- a/crates/spk-schema/Cargo.toml +++ b/crates/spk-schema/Cargo.toml @@ -20,7 +20,7 @@ config = { workspace = true } data-encoding = "2.3" dunce = { workspace = true } enum_dispatch = { workspace = true } -format_serde_error = { version = "0.3", default-features = false, features = [ +format_serde_error = { workspace = true, default-features = false, features = [ "serde_yaml", "colored", ] } diff --git a/crates/spk-schema/crates/foundation/Cargo.toml b/crates/spk-schema/crates/foundation/Cargo.toml index a1c785bb5..6524bb0d8 100644 --- a/crates/spk-schema/crates/foundation/Cargo.toml +++ b/crates/spk-schema/crates/foundation/Cargo.toml @@ -24,7 +24,7 @@ async-trait = { workspace = true } colored = { workspace = true } data-encoding = "2.3" enum_dispatch = { workspace = true } -format_serde_error = { version = "0.3", default-features = false, features = [ +format_serde_error = { workspace = true, default-features = false, features = [ "serde_yaml", "colored", ] } diff --git a/crates/spk-schema/crates/ident/Cargo.toml b/crates/spk-schema/crates/ident/Cargo.toml index 5ceafa9a6..44e72b5e4 100644 --- a/crates/spk-schema/crates/ident/Cargo.toml +++ b/crates/spk-schema/crates/ident/Cargo.toml @@ -17,7 +17,7 @@ migration-to-components = ["spk-schema-foundation/migration-to-components"] [dependencies] colored = { workspace = true } -format_serde_error = { version = "0.3", default-features = false, features = [ +format_serde_error = { workspace = true, default-features = false, features = [ "serde_yaml", "colored", ] } diff --git a/crates/spk-storage/Cargo.toml b/crates/spk-storage/Cargo.toml index e34b78ddc..04803882b 100644 --- a/crates/spk-storage/Cargo.toml +++ b/crates/spk-storage/Cargo.toml @@ -23,7 +23,7 @@ colored = { workspace = true } dashmap = "5.4.0" data-encoding = "2.3.0" enum_dispatch = { workspace = true } -format_serde_error = { version = "0.3", default-features = false, features = [ +format_serde_error = { workspace = true, default-features = false, features = [ "serde_yaml", "colored", ] } diff --git a/crates/spk-workspace/Cargo.toml b/crates/spk-workspace/Cargo.toml index 052877405..ad5f125bc 100644 --- a/crates/spk-workspace/Cargo.toml +++ b/crates/spk-workspace/Cargo.toml @@ -19,7 +19,13 @@ sentry = ["spk-solve/sentry"] serde = { workspace = true, features = ["derive"] } glob = { workspace = true } spk-solve = { workspace = true } +thiserror = { workspace = true } +miette = { workspace = true } +serde_yaml = { workspace = true } +spk-schema-foundation = { workspace = true } +format_serde_error = { workspace = true } [dev-dependencies] rstest = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/spk-workspace/src/error.rs b/crates/spk-workspace/src/error.rs new file mode 100644 index 000000000..80aa8d156 --- /dev/null +++ b/crates/spk-workspace/src/error.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +#[derive(thiserror::Error, miette::Diagnostic, Debug)] +pub enum LoadWorkspaceError { + #[error( + "workspace not found, no {} in {0:?} or any parent", + crate::Workspace::FILE_NAME + )] + WorkspaceNotFound(PathBuf), + #[error("'{}' not found in {0:?}", crate::Workspace::FILE_NAME)] + NoWorkspaceFile(PathBuf), + #[error(transparent)] + ReadFailed(std::io::Error), + #[error(transparent)] + InvalidYaml(format_serde_error::SerdeError), +} diff --git a/crates/spk-workspace/src/lib.rs b/crates/spk-workspace/src/lib.rs index 9e3d3cbc2..30e6a8e47 100644 --- a/crates/spk-workspace/src/lib.rs +++ b/crates/spk-workspace/src/lib.rs @@ -1 +1,4 @@ +pub mod error; mod spec; + +pub use spec::Workspace; diff --git a/crates/spk-workspace/src/spec.rs b/crates/spk-workspace/src/spec.rs index 70272223f..1aa4f9b0d 100644 --- a/crates/spk-workspace/src/spec.rs +++ b/crates/spk-workspace/src/spec.rs @@ -1,4 +1,9 @@ +use std::path::Path; + use serde::{Deserialize, Serialize}; +use spk_schema_foundation::FromYaml; + +use crate::error::LoadWorkspaceError; #[cfg(test)] #[path = "spec_test.rs"] @@ -10,6 +15,53 @@ pub struct Workspace { pub recipes: Vec, } +impl Workspace { + pub const FILE_NAME: &str = "workspace.spk.yaml"; + + /// Load a workspace from its root directory on disk + pub fn load>(root: P) -> Result { + let root = root + .as_ref() + .canonicalize() + .map_err(|_| LoadWorkspaceError::NoWorkspaceFile(root.as_ref().into()))?; + + let workspace_file = std::fs::read_to_string(root.join(Workspace::FILE_NAME)) + .map_err(LoadWorkspaceError::ReadFailed)?; + Workspace::from_yaml(workspace_file).map_err(LoadWorkspaceError::InvalidYaml) + } + + /// Load the workspace for a given dir, looking at parent directories + /// as necessary to find the workspace root + pub fn discover>(cwd: P) -> Result { + let cwd = if cwd.as_ref().is_absolute() { + cwd.as_ref().to_owned() + } else { + // prefer PWD if available, since it may be more representative of + // how the user arrived at the current dir and avoids dereferencing + // symlinks that could otherwise make error messages harder to understand + match std::env::var("PWD").ok() { + Some(pwd) => Path::new(&pwd).join(cwd), + None => std::env::current_dir().unwrap_or_default().join(cwd), + } + }; + let mut candidate = cwd.clone(); + let mut last_found = None; + + loop { + if candidate.join(Workspace::FILE_NAME).is_file() { + last_found = Some(candidate.clone()); + } + if !candidate.pop() { + break; + } + } + match last_found { + Some(path) => Self::load(path), + None => Err(LoadWorkspaceError::WorkspaceNotFound(cwd)), + } + } +} + mod glob_from_str { use serde::{Deserializer, Serialize, Serializer}; diff --git a/crates/spk-workspace/src/spec_test.rs b/crates/spk-workspace/src/spec_test.rs index ff1ee8f77..7a075ab14 100644 --- a/crates/spk-workspace/src/spec_test.rs +++ b/crates/spk-workspace/src/spec_test.rs @@ -1,7 +1,20 @@ -use rstest::rstest; +use rstest::{fixture, rstest}; use super::Workspace; +#[fixture] +pub fn tmpdir() -> tempfile::TempDir { + tempfile::Builder::new() + .prefix("spk-test-") + .tempdir() + .expect("create a temp directory for test files") +} + +const EMPTY_WORKSPACE: &str = r#" +api: v0/workspace +recipes: [] +"#; + #[rstest] fn test_workspace_roundtrip() { let workspace = Workspace { @@ -16,3 +29,41 @@ fn test_workspace_roundtrip() { assert_eq!(workspace, deserialized); } + +#[rstest] +fn test_empty_workspace_loading(tmpdir: tempfile::TempDir) { + let root = tmpdir.path(); + std::fs::write(root.join(Workspace::FILE_NAME), EMPTY_WORKSPACE).unwrap(); + let _workspace = Workspace::load(root).expect("failed to load empty workspace"); +} + +#[rstest] +fn test_must_have_file(tmpdir: tempfile::TempDir) { + let root = tmpdir.path(); + Workspace::load(root).expect_err("workspace should fail to load for empty dir"); +} + +#[rstest] +#[case("", "")] +#[case("my-workspace", "my-workspace/src/packages")] +#[should_panic] +#[case("my-workspace", "other-dir")] +#[case("", "")] +#[case("my-workspace", "my-workspace/src/packages")] +#[should_panic] +#[case("my-workspace", "other-dir/src/packages")] +fn test_workspace_discovery( + tmpdir: tempfile::TempDir, + #[case] workspace_root: &str, + #[case] discovery_start: &str, +) { + let dir = tmpdir.path(); + let root = dir.join(workspace_root); + let cwd = dir.join(discovery_start); + + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join(Workspace::FILE_NAME), EMPTY_WORKSPACE).unwrap(); + + Workspace::discover(&cwd).expect("failed to load workspace"); +} diff --git a/workspace.spk.yml b/workspace.spk.yml new file mode 100644 index 000000000..cc340e414 --- /dev/null +++ b/workspace.spk.yml @@ -0,0 +1,4 @@ +api: v0/workspace + +recipes: + - packages/**.spk.yml