From 54fbb1bae339d261d9f5c497acfe18014402b008 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:10:05 -0400 Subject: [PATCH] Add workspace configuration discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add workspace-local `.build-eips.toml` loading and starter configuration. Introduce `config::ActiveRepo` to load the selected checkout’s `Build.toml`, validate explicit `-C` roots, and expose active repository context to later commands. Keep source materialization, initialization, and diagnostics in their owning later branches. --- src/config.rs | 692 ++++++++++++++++++++++++++++++++++++++++++++++- src/context.rs | 119 +++++++- src/find_root.rs | 2 +- 3 files changed, 796 insertions(+), 17 deletions(-) diff --git a/src/config.rs b/src/config.rs index bb01cef..298f065 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,14 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use std::{borrow::Borrow, collections::HashMap, path::PathBuf, str::FromStr}; +use std::{ + borrow::Borrow, + collections::HashMap, + fmt, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, + str::FromStr, +}; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -12,6 +19,12 @@ use snafu::{Backtrace, IntoError, OptionExt, ResultExt, Snafu}; use url::Url; pub const MANIFEST_FILE: &str = "Build.toml"; +pub const LOCAL_CONFIG_FILE: &str = ".build-eips.toml"; +pub const DEFAULT_BUILD_ROOT_BASE: &str = ".local-build"; +pub const DEFAULT_THEME_DIR: &str = "theme"; +pub const DEFAULT_SERVER_INTERFACE: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); +pub const DEFAULT_SERVER_PORT: u16 = 1111; +pub const DEFAULT_SITE_BASE_URL: &str = "http://127.0.0.1:1111"; #[derive(Debug, Snafu)] #[non_exhaustive] @@ -24,7 +37,7 @@ pub enum Error { }, #[snafu(display( - "unable to parse repo manifest `{}`", + "unable to parse {MANIFEST_FILE} `{}`", manifest_path.to_string_lossy() ))] Parse { @@ -35,7 +48,7 @@ pub enum Error { }, #[snafu(display( - "repo manifest `{}` is invalid: {}", + "{MANIFEST_FILE} `{}` is invalid: {}", manifest_path.to_string_lossy(), source, ))] @@ -47,6 +60,30 @@ pub enum Error { }, } +#[derive(Debug, Snafu)] +pub enum WorkspaceError { + #[snafu(display("i/o error while accessing `{}`", path.to_string_lossy()))] + Fs { + path: PathBuf, + source: std::io::Error, + backtrace: Backtrace, + }, + + #[snafu( + context(name(WorkspaceParseSnafu)), + display( + "unable to parse workspace config `{}`", + config_path.to_string_lossy() + ) + )] + Parse { + config_path: PathBuf, + #[snafu(source(from(toml::de::Error, Box::new)))] + source: Box, + backtrace: Backtrace, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct Theme { @@ -232,6 +269,240 @@ impl TryFrom for RepositoryUse { } } +/// Resolved manifest context for the active proposal repository. +#[derive(Debug, Clone)] +pub struct ActiveRepo { + /// Managed workspace title for the active repository. + pub title: String, + + /// Declared sibling repository titles. + pub sibling_ids: Vec, + + /// Normalized source selection for the active repository and its siblings. + pub repository_use: RepositoryUse, + + /// Theme metadata declared by the active repository manifest. + pub theme: Theme, +} + +impl ActiveRepo { + /// Load the active repository manifest from its working-tree checkout. + pub fn load(repo_root: &Path) -> Result { + let manifest = Manifest::load(repo_root.join(MANIFEST_FILE))?; + let manifest_path = manifest.manifest_path.clone(); + let theme = manifest.theme.clone(); + let repository_use = + RepositoryUse::try_from(manifest).context(InvalidSnafu { manifest_path })?; + let title = repository_use.title.clone(); + let mut sibling_ids = repository_use + .other_repos + .keys() + .cloned() + .collect::>(); + sibling_ids.sort(); + + Ok(Self { + title, + sibling_ids, + repository_use, + theme, + }) + } +} + +/// Workspace-local configuration loaded from `.build-eips.toml`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct WorkspaceConfig { + /// Local server defaults for `build-eips serve` and `build-eips preview`. + #[serde(default)] + pub server: ServerSettings, + + /// Local rendered-site URL defaults for build and serve commands. + #[serde(default)] + pub site: SiteSettings, +} + +impl WorkspaceConfig { + fn starter() -> Self { + Self { + server: ServerSettings::default(), + site: SiteSettings::starter(), + } + } +} + +/// Workspace-local bind address defaults for local server commands. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ServerSettings { + /// IP address passed to Zola's `--interface`; hostnames are not supported. + pub interface: IpAddr, + + /// TCP port used by `serve` and `preview`. + pub port: u16, +} + +impl Default for ServerSettings { + fn default() -> Self { + Self { + interface: DEFAULT_SERVER_INTERFACE, + port: DEFAULT_SERVER_PORT, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerBinding { + pub interface: IpAddr, + pub port: u16, +} + +impl Default for ServerBinding { + fn default() -> Self { + ServerSettings::default().into() + } +} + +impl From for ServerBinding { + fn from(settings: ServerSettings) -> Self { + Self { + interface: settings.interface, + port: settings.port, + } + } +} + +impl From<&ServerSettings> for ServerBinding { + fn from(settings: &ServerSettings) -> Self { + settings.clone().into() + } +} + +impl fmt::Display for ServerBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + SocketAddr::new(self.interface, self.port).fmt(formatter) + } +} + +/// Workspace-local rendered-site URL defaults for build and serve commands. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SiteSettings { + /// Base URL written into rendered HTML, feeds, canonical links, and sitemaps. + #[serde(default)] + pub base_url: Option, +} + +impl SiteSettings { + fn starter() -> Self { + Self { + base_url: Some( + DEFAULT_SITE_BASE_URL + .parse() + .expect("default site base URL should parse"), + ), + } + } +} + +#[derive(Debug, Clone)] +pub struct LoadedWorkspaceConfig { + config_path: PathBuf, + workspace_root: PathBuf, + config: WorkspaceConfig, +} + +impl LoadedWorkspaceConfig { + pub fn from_path(path: &Path) -> Result { + let config_path = path.canonicalize().with_context(|_| FsSnafu { + path: path.to_path_buf(), + })?; + let contents = std::fs::read_to_string(&config_path).with_context(|_| FsSnafu { + path: config_path.clone(), + })?; + let config = + toml::from_str::(&contents).with_context(|_| WorkspaceParseSnafu { + config_path: config_path.clone(), + })?; + + let workspace_root = config_path + .parent() + .expect("workspace config should always have a parent") + .to_path_buf(); + + Ok(Self { + config_path, + workspace_root, + config, + }) + } + + pub fn discover(start: &Path) -> Result, WorkspaceError> { + match discover_path(start) { + Some(path) => Self::from_path(&path).map(Some), + None => Ok(None), + } + } + + pub fn config_path(&self) -> &Path { + &self.config_path + } + + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn workspace_build_root(&self, repo_name: &str) -> PathBuf { + self.workspace_root + .join(DEFAULT_BUILD_ROOT_BASE) + .join(repo_name) + } + + pub fn server_settings(&self) -> &ServerSettings { + &self.config.server + } + + pub fn site_settings(&self) -> &SiteSettings { + &self.config.site + } + + pub fn local_theme_path(&self) -> PathBuf { + self.workspace_root.join(DEFAULT_THEME_DIR) + } + + pub fn local_repo_path(&self, repo_name: &str) -> PathBuf { + self.workspace_root.join(repo_name) + } +} + +pub fn discover_path(start: &Path) -> Option { + let mut current = Some(start); + + while let Some(candidate) = current { + let config_path = candidate.join(LOCAL_CONFIG_FILE); + match std::fs::File::open(&config_path) { + Ok(_) => return Some(config_path), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => + { + current = candidate.parent(); + } + Err(_) => return Some(config_path), + } + } + + None +} + +pub fn default_workspace_config_text() -> String { + toml::to_string_pretty(&WorkspaceConfig::starter()) + .expect("workspace starter config should serialize") +} + #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; @@ -270,9 +541,9 @@ mod tests { } #[test] - fn malformed_repo_manifest_reports_parse_error() { + fn malformed_build_manifest_reports_parse_error() { let repo = TestRepo::new(); - let manifest_path = repo.write_file(MANIFEST_FILE, "repo_id = ["); + let manifest_path = repo.write_file(MANIFEST_FILE, "name = ["); let error = Manifest::load(&manifest_path).unwrap_err(); @@ -280,7 +551,7 @@ mod tests { } #[test] - fn parses_repo_manifest() { + fn parses_build_manifest() { let repo = TestRepo::new(); let manifest_path = repo.write_file( MANIFEST_FILE, @@ -307,7 +578,7 @@ commit = "aaa" } #[test] - fn repo_manifest_rejects_unsafe_names() { + fn build_manifest_rejects_unsafe_names() { let repo = TestRepo::new(); let manifest_path = repo.write_file(MANIFEST_FILE, r#"name = "^^^^""#); @@ -321,7 +592,7 @@ commit = "aaa" } #[test] - fn repo_manifest_rejects_empty_names() { + fn build_manifest_rejects_empty_names() { let repo = TestRepo::new(); let manifest_path = repo.write_file(MANIFEST_FILE, r#"name = """#); @@ -335,7 +606,7 @@ commit = "aaa" } #[test] - fn repo_manifest_requires_self() { + fn build_manifest_requires_self() { let repo = TestRepo::new(); let manifest_path = repo.write_file( MANIFEST_FILE, @@ -357,3 +628,406 @@ commit = "aaa" assert!(reason.contains("this locations's name (`banana`) must appear in `locations`")); } } + +#[cfg(test)] +mod workspace_tests { + use std::{ + net::IpAddr, + path::{Path, PathBuf}, + }; + + use tempfile::TempDir; + + use super::{ + default_workspace_config_text, discover_path, LoadedWorkspaceConfig, ServerBinding, + ServerSettings, WorkspaceError, DEFAULT_SERVER_INTERFACE, DEFAULT_SERVER_PORT, + LOCAL_CONFIG_FILE, + }; + struct TestWorkspace { + tempdir: TempDir, + } + + impl TestWorkspace { + fn new() -> Self { + Self { + tempdir: TempDir::new().unwrap(), + } + } + + fn root(&self) -> &Path { + self.tempdir.path() + } + + fn path(&self, relative: impl AsRef) -> PathBuf { + self.root().join(relative) + } + + fn write_file(&self, relative: impl AsRef, contents: &str) -> PathBuf { + let path = self.path(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, contents).unwrap(); + path + } + + fn create_dir(&self, relative: impl AsRef) -> PathBuf { + let path = self.path(relative); + std::fs::create_dir_all(&path).unwrap(); + path + } + } + + #[test] + fn parses_default_workspace_config() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file(LOCAL_CONFIG_FILE, &default_workspace_config_text()); + + let config = LoadedWorkspaceConfig::from_path(&config_path).unwrap(); + + assert_eq!(config.workspace_root(), workspace.root()); + assert_eq!(config.server_settings(), &ServerSettings::default()); + assert_eq!( + config.site_settings().base_url.as_ref().unwrap().as_str(), + "http://127.0.0.1:1111/" + ); + } + + #[test] + fn starter_workspace_config_roundtrips_stably() { + let original = default_workspace_config_text(); + let parsed = toml::from_str::(&original).unwrap(); + let reparsed = toml::to_string_pretty(&parsed).unwrap(); + + assert_eq!(reparsed, original); + assert!(!original.contains("build_root_base")); + assert!(original.contains("[server]")); + assert!(original.contains("interface = \"127.0.0.1\"")); + assert!(original.contains("port = 1111")); + assert!(original.contains("[site]")); + assert!(original.contains("base_url = \"http://127.0.0.1:1111/\"")); + assert!(!original.contains("default_profile")); + assert!(!original.contains("[profiles")); + } + + #[test] + fn parses_workspace_config_server_settings() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file( + LOCAL_CONFIG_FILE, + r#" +[server] +interface = "0.0.0.0" +port = 8080 +"#, + ); + + let config = LoadedWorkspaceConfig::from_path(&config_path).unwrap(); + + assert_eq!( + config.server_settings(), + &ServerSettings { + interface: "0.0.0.0".parse().unwrap(), + port: 8080, + } + ); + } + + #[test] + fn missing_server_settings_use_default_binding() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file( + LOCAL_CONFIG_FILE, + r#" +[site] +base_url = "http://localhost:4000" +"#, + ); + + let config = LoadedWorkspaceConfig::from_path(&config_path).unwrap(); + let binding = ServerBinding::from(config.server_settings()); + + assert_eq!(binding.interface, DEFAULT_SERVER_INTERFACE); + assert_eq!(binding.port, DEFAULT_SERVER_PORT); + assert_eq!(binding.to_string(), "127.0.0.1:1111"); + } + + #[test] + fn parses_ipv6_server_interface() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file( + LOCAL_CONFIG_FILE, + r#" +[server] +interface = "::1" +port = 8080 +"#, + ); + + let config = LoadedWorkspaceConfig::from_path(&config_path).unwrap(); + let binding = ServerBinding::from(config.server_settings()); + + assert_eq!(binding.interface, "::1".parse::().unwrap()); + assert_eq!(binding.port, 8080); + assert_eq!(binding.to_string(), "[::1]:8080"); + } + + #[test] + fn invalid_server_interfaces_error_during_config_loading() { + for interface in ["example.com", "127.0.0.1:1111"] { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file( + LOCAL_CONFIG_FILE, + &format!( + r#" +[server] +interface = "{interface}" +port = 1111 +"# + ), + ); + + let error = LoadedWorkspaceConfig::from_path(&config_path).unwrap_err(); + + assert!(matches!(error, WorkspaceError::Parse { .. })); + assert!(error + .to_string() + .contains("unable to parse workspace config")); + } + } + + #[test] + fn parses_workspace_config_site_settings() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file( + LOCAL_CONFIG_FILE, + r#" +[site] +base_url = "http://localhost:4000" +"#, + ); + + let config = LoadedWorkspaceConfig::from_path(&config_path).unwrap(); + + assert_eq!( + config.site_settings().base_url.as_ref().unwrap().as_str(), + "http://localhost:4000/" + ); + } + + #[test] + fn invalid_workspace_config_site_base_url_errors() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file( + LOCAL_CONFIG_FILE, + r#" +[site] +base_url = "not a url" +"#, + ); + let error = LoadedWorkspaceConfig::from_path(&config_path).unwrap_err(); + + assert!(error + .to_string() + .contains("unable to parse workspace config")); + } + + #[test] + fn missing_site_settings_preserve_no_base_url_override() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file( + LOCAL_CONFIG_FILE, + r#" +[server] +interface = "127.0.0.1" +port = 1111 +"#, + ); + + let config = LoadedWorkspaceConfig::from_path(&config_path).unwrap(); + + assert!(config.site_settings().base_url.is_none()); + } + + #[test] + fn minimal_workspace_config_parses() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file( + LOCAL_CONFIG_FILE, + r#" +[server] +interface = "127.0.0.1" +port = 1111 + +[site] +base_url = "http://127.0.0.1:1111" +"#, + ); + + let config = LoadedWorkspaceConfig::from_path(&config_path).unwrap(); + + assert_eq!(config.server_settings(), &ServerSettings::default()); + assert_eq!( + config.site_settings().base_url.as_ref().unwrap().as_str(), + "http://127.0.0.1:1111/" + ); + } + + #[test] + fn empty_workspace_config_uses_defaults() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file(LOCAL_CONFIG_FILE, " \n"); + + let config = LoadedWorkspaceConfig::from_path(&config_path).unwrap(); + + assert_eq!(config.server_settings(), &ServerSettings::default()); + assert!(config.site_settings().base_url.is_none()); + } + + #[test] + fn removed_workspace_config_fields_use_strict_parse_errors() { + let removed_theme_ref_field = concat!("co", "mmit"); + let cases = vec![ + ( + "build_root_base".to_owned(), + r#"build_root_base = ".local-build""#.to_owned(), + ), + ( + "default_profile".to_owned(), + r#"default_profile = "local""#.to_owned(), + ), + ( + "server.host".to_owned(), + r#" +[server] +host = "127.0.0.1" +"# + .to_owned(), + ), + ( + "profiles".to_owned(), + r#" +[profiles.local] +staging = true +"# + .to_owned(), + ), + ( + "theme".to_owned(), + r#" +[theme] +repository = "https://github.com/eips-wg/theme.git" +"# + .to_owned(), + ), + ( + format!("theme.{removed_theme_ref_field}"), + format!( + r#" +[theme] +{removed_theme_ref_field} = "3a597d4cd68ec82d36f01c01335492cfa59501ae" +"# + ), + ), + ]; + + for (field, contents) in cases { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file(LOCAL_CONFIG_FILE, &contents); + let error = LoadedWorkspaceConfig::from_path(&config_path).unwrap_err(); + + assert!( + matches!(error, WorkspaceError::Parse { .. }), + "expected strict parse error for removed field `{field}`, got {error:?}" + ); + } + } + + #[test] + fn discover_path_walks_upward() { + let workspace = TestWorkspace::new(); + let config_path = workspace.write_file(LOCAL_CONFIG_FILE, &default_workspace_config_text()); + let nested = workspace.create_dir("EIPs/content"); + + assert_eq!(discover_path(&nested).unwrap(), config_path); + assert_eq!( + LoadedWorkspaceConfig::discover(&nested) + .unwrap() + .unwrap() + .config_path(), + config_path + ); + } + + #[test] + fn missing_workspace_config_is_not_discovered() { + let workspace = TestWorkspace::new(); + let nested = workspace.create_dir("EIPs/content"); + + assert!(discover_path(&nested).is_none()); + assert!(LoadedWorkspaceConfig::discover(&nested).unwrap().is_none()); + } +} + +#[cfg(test)] +mod active_repo_tests { + use std::path::Path; + + use tempfile::TempDir; + + use super::{ActiveRepo, MANIFEST_FILE}; + + #[test] + fn legacy_repo_manifest_does_not_satisfy_active_repo_loading() { + let tempdir = TempDir::new().unwrap(); + std::fs::write( + tempdir.path().join(".build-eips.repo.toml"), + "repo_id = \"EIPs\"\n", + ) + .unwrap(); + + let error = ActiveRepo::load(tempdir.path()).unwrap_err().to_string(); + + assert!(error.contains(MANIFEST_FILE), "{error}"); + } + + #[test] + fn active_repo_loads_manifest_and_normalizes_repository_use() { + let tempdir = TempDir::new().unwrap(); + let manifest_path = tempdir.path().join(MANIFEST_FILE); + std::fs::write( + &manifest_path, + r#" +name = "EIPs" + +[locations.EIPs] +repository = "https://example.test/EIPs.git" +base-url = "https://example.test/EIPs/" + +[locations.ERCs] +repository = "https://example.test/ERCs.git" +base-url = "https://example.test/ERCs/" + +[theme] +repository = "https://example.test/theme.git" +commit = "abc123" +"#, + ) + .unwrap(); + + let active_repo = ActiveRepo::load(Path::new(tempdir.path())).unwrap(); + + assert_eq!(active_repo.title, "EIPs"); + assert_eq!(active_repo.sibling_ids, ["ERCs"]); + assert_eq!(active_repo.repository_use.title, "EIPs"); + assert_eq!( + active_repo.repository_use.location.repository.as_str(), + "https://example.test/EIPs.git" + ); + assert_eq!( + active_repo.repository_use.other_repos["ERCs"].as_str(), + "https://example.test/ERCs.git" + ); + assert_eq!(active_repo.theme.commit, "abc123"); + } +} diff --git a/src/context.rs b/src/context.rs index f371837..1f53d4e 100644 --- a/src/context.rs +++ b/src/context.rs @@ -4,17 +4,122 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +//! Command context and path resolution helpers. + use std::path::PathBuf; use snafu::{ResultExt, Whatever}; -use crate::{cli::Args, find_root}; +use crate::{cli::Args, config, find_root}; + +#[derive(Debug, Clone)] +pub(crate) struct WorkspaceCommandContext { + pub(crate) search_from: PathBuf, + pub(crate) config_path: Option, +} pub(crate) fn root(args: &Args) -> Result { - let dir = match &args.root { - None => find_root::find_root().whatever_context("cannot find root")?, - Some(p) => p.to_path_buf(), - }; - find_root::is_root(&dir).whatever_context("invalid root directory")?; - Ok(dir) + match &args.root { + None => find_root::find_root().whatever_context("cannot find repository root"), + Some(path) => { + let dir = path + .canonicalize() + .whatever_context("unable to canonicalize root directory")?; + + if !find_root::is_root(&dir).whatever_context("invalid root directory")? { + snafu::whatever!("invalid root directory"); + } + + Ok(dir) + } + } +} + +fn workspace_search_start(args: &Args) -> Result { + match &args.root { + Some(path) => path + .canonicalize() + .whatever_context("unable to canonicalize workspace search path"), + None => std::env::current_dir().whatever_context("unable to get current directory"), + } +} + +impl WorkspaceCommandContext { + pub(crate) fn load(args: &Args) -> Result { + let search_from = workspace_search_start(args)?; + let config_path = config::discover_path(&search_from); + + Ok(Self { + search_from, + config_path, + }) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use clap::Parser; + use tempfile::TempDir; + + use crate::{cli::Args, execution::resolve_execution, find_root}; + + use super::root; + + fn explicit_root_args(path: &Path) -> Args { + Args::try_parse_from(["build-eips", "-C", path.to_str().unwrap(), "changed"]).unwrap() + } + + #[test] + fn explicit_plain_directory_is_rejected_before_execution_manifest_loading() { + let directory = TempDir::new().unwrap(); + let args = explicit_root_args(directory.path()); + + let error = resolve_execution(&args).unwrap_err().to_string(); + + assert_eq!(error, "invalid root directory"); + assert!(!error.contains("unable to load active repository Build.toml")); + assert!(!error.contains("Build.toml")); + } + + #[test] + fn explicit_nonexistent_root_reports_canonicalization_error() { + let directory = TempDir::new().unwrap(); + let missing = directory.path().join("missing"); + let args = explicit_root_args(&missing); + + let error = root(&args).unwrap_err().to_string(); + + assert_eq!(error, "unable to canonicalize root directory"); + } + + #[test] + fn explicit_valid_root_is_accepted() { + let directory = TempDir::new().unwrap(); + std::fs::create_dir_all(directory.path().join("content")).unwrap(); + std::fs::write(directory.path().join("Build.toml"), "").unwrap(); + let args = explicit_root_args(directory.path()); + + assert_eq!( + root(&args).unwrap(), + directory.path().canonicalize().unwrap() + ); + } + + #[test] + fn implicit_root_keeps_auto_discovery_behavior() { + let args = Args::try_parse_from(["build-eips", "changed"]).unwrap(); + + match (root(&args), find_root::find_root()) { + (Ok(root), Ok(discovered)) => assert_eq!(root, discovered), + (Err(error), Err(_)) => { + assert!(error.to_string().contains("cannot find repository root")); + assert!(!error.to_string().contains("invalid root directory")); + } + (root, discovered) => panic!( + "context root result {root:?} did not match automatic discovery {discovered:?}" + ), + } + } } diff --git a/src/find_root.rs b/src/find_root.rs index 812ff70..e18f6b3 100644 --- a/src/find_root.rs +++ b/src/find_root.rs @@ -4,8 +4,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use super::CONTENT_DIR; use crate::config::MANIFEST_FILE; +use crate::layout::CONTENT_DIR; use snafu::{ResultExt, Snafu}; use std::{