From 54fbb1bae339d261d9f5c497acfe18014402b008 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:10:05 -0400 Subject: [PATCH 1/9] 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::{ From c66646be1fff3724b879109522519ca3d381b393 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:10:27 -0400 Subject: [PATCH 2/9] Add source materialization layer Add clean and dirty source materialization for the active repository and sibling content. Use `config::RepositoryUse` throughout the runtime path, preserve tracked working-tree materialization and `index_path`, and reject dirty active manifests in clean modes. Build, check, and serve prepare sources without fetching the active upstream; `changed` retains upstream fetching for comparison. --- src/changed.rs | 72 +--- src/git.rs | 963 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 774 insertions(+), 261 deletions(-) diff --git a/src/changed.rs b/src/changed.rs index 4472b59..07ad880 100644 --- a/src/changed.rs +++ b/src/changed.rs @@ -4,78 +4,46 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -//! Changed-file command execution. +//! Changed-file command helpers. -use std::{ - ffi::OsStr, - path::{Path, PathBuf}, -}; +use std::path::Path; use snafu::{ResultExt, Whatever}; -use crate::{cli::ChangedFormat, config::RepositoryUse, git, layout::REPO_DIR}; - -pub(crate) fn is_proposal_path(mut p: PathBuf) -> bool { - // Only lint `content/00001.md` and `content/00001/index.md` files. - - // content/00000.md | content/00000/index.md - // ^^^^^^^^ | ^^^^^^^^ - match p.file_name() { - Some(n) if n == "index.md" => { - p.pop(); - } - Some(_) if p.extension().map(|x| x == "md").unwrap_or(false) => { - p.set_extension(""); - } - None | Some(_) => return false, - } - - // content/00000 - // ^^^^^ - match p.file_name().and_then(OsStr::to_str) { - None => return false, - Some(f) if f.parse::().is_err() => return false, - Some(_) => { - p.pop(); - } - } - - // content - // ^^^^^^^ - match p.file_name() { - Some(f) if f == "content" => { - p.pop(); - } - _ => return false, - } - - p == OsStr::new("") -} +use crate::{ + cli::ChangedFormat, execution::ResolvedExecution, git, layout::REPO_DIR, + proposal::is_proposal_path, +}; pub(crate) fn run( - root_path: &Path, + resolved: &ResolvedExecution, build_path: &Path, - repo_use: RepositoryUse, all: bool, format: &ChangedFormat, ) -> Result<(), Whatever> { let repo_path = build_path.join(REPO_DIR); - let both = git::Fresh::new(root_path, &repo_path, repo_use) - .whatever_context("initializing build repo")? - .clone_src() - .whatever_context("cloning source repo")? - .fetch_upstream() - .whatever_context("fetching upstream repo")?; + let both = git::Fresh::new( + &resolved.root_path, + &repo_path, + resolved.repository_use.clone(), + resolved.source_materialization, + ) + .whatever_context("initializing build repo")? + .clone_src() + .whatever_context("cloning source repo")? + .fetch_upstream() + .whatever_context("fetching upstream repo")?; let changed_files: Vec<_> = both .changed_files() .whatever_context("unable to list changed files")? .into_iter() - .filter(|p| all || is_proposal_path(p.into())) + .filter(|p| all || is_proposal_path(p)) .map(|p| repo_path.join(p)) .collect(); format.print(&changed_files, &repo_path); + Ok(()) } diff --git a/src/git.rs b/src/git.rs index 275c828..38c7d81 100644 --- a/src/git.rs +++ b/src/git.rs @@ -5,24 +5,28 @@ */ use std::{ + collections::BTreeSet, ffi::OsStr, path::{absolute, Path, PathBuf}, }; +use crate::config::RepositoryUse; use crate::{ - cache::Cache, - config::{NoIdentityError, RepositoryUse}, + layout::{BUILD_DIR, CONTENT_DIR}, progress::{Git, ProgressIteratorExt}, }; use git2::{ - build::{CheckoutBuilder, TreeUpdateBuilder}, - BranchType, Commit, FetchOptions, FileMode, ObjectType, Oid, RepositoryOpenFlags, Signature, + build::TreeUpdateBuilder, + Commit, FetchOptions, FileMode, ObjectType, Oid, Signature, Status, StatusOptions, Tree, TreeEntry, TreeWalkResult, }; use log::{debug, info}; use snafu::{ensure, Backtrace, IntoError, OptionExt, ResultExt, Snafu}; use url::Url; +const DIRTY_PATH_DISPLAY_LIMIT: usize = 10; +const CONTENT_INDEX_PATH: &str = "content/_index.md"; + #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("cannot convert path into URL (`{}`)", path.to_string_lossy()))] @@ -39,43 +43,551 @@ pub enum Error { source: git2::Error, backtrace: Backtrace, }, - #[snafu(context(false))] - NoIdentity { - #[snafu(backtrace)] - source: NoIdentityError, + #[snafu(display("{message}"))] + Dirty { + message: String, + backtrace: Backtrace, }, - #[snafu(display("working tree or index has uncommitted modifications"))] - Dirty { backtrace: Backtrace }, + #[snafu(display( + "dirty mode cannot materialize conflicted path `{}`; resolve the conflict and try again", + path.to_string_lossy() + ))] + DirtyConflict { path: PathBuf, backtrace: Backtrace }, + #[snafu(display( + "dirty mode cannot materialize `{}` because it is not a tracked file or symlink in the working tree", + path.to_string_lossy() + ))] + DirtyUnsupportedPath { path: PathBuf, backtrace: Backtrace }, #[snafu(display("unable to update tree ({msg})"))] UpdateTree { msg: String, backtrace: Backtrace }, - #[snafu(context(false))] - Cache { - #[snafu(backtrace)] - source: crate::cache::Error, - }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceMaterialization { + Clean, + Dirty, +} + +pub fn repository_available(path: &Path) -> bool { + git2::Repository::open(path).is_ok() +} + +fn is_generated_path(path: &Path) -> bool { + path.components() + .next() + .map(|component| component.as_os_str() == OsStr::new(BUILD_DIR)) + .unwrap_or(false) +} + +fn dirty_statuses(repo: &git2::Repository) -> Result, Error> { + let mut options = StatusOptions::default(); + options + .include_untracked(true) + .recurse_untracked_dirs(true) + .renames_head_to_index(true) + .renames_index_to_workdir(true); + repo.statuses(Some(&mut options)).context(GitSnafu { + what: "get root repository status", + }) +} + +fn format_dirty_rejection(tracked_paths: &BTreeSet, untracked_count: usize) -> String { + let mut lines = vec![String::from( + "working tree or index has uncommitted modifications; the selected clean source path requires a clean working tree:", + )]; + + for path in tracked_paths.iter().take(DIRTY_PATH_DISPLAY_LIMIT) { + lines.push(format!("- {}", path.to_string_lossy())); + } + + if tracked_paths.len() > DIRTY_PATH_DISPLAY_LIMIT { + lines.push(format!( + "- ... and {} more tracked path(s)", + tracked_paths.len() - DIRTY_PATH_DISPLAY_LIMIT + )); + } + + if untracked_count > 0 { + lines.push(format!( + "- ... plus {} untracked file(s) not listed", + untracked_count + )); + } + + lines.push(String::new()); + + if untracked_count > 0 { + lines.push(String::from( + "For local build/serve/check commands, run without `--clean` to include tracked local changes. For `--clean` runs, commit or stash tracked changes first. Commit/stash/remove untracked files before retrying.", + )); + } else { + lines.push(String::from( + "For local build/serve/check commands, run without `--clean` to include tracked local changes. For `--clean` runs, commit or stash tracked changes first.", + )); + } + + lines.join("\n") } pub fn check_dirty(root_path: &Path) -> Result<(), Error> { + let (tracked_paths, untracked_count) = + collect_dirty_paths(root_path, |path| !is_generated_path(path))?; + + if tracked_paths.is_empty() && untracked_count == 0 { + Ok(()) + } else { + DirtySnafu { + message: format_dirty_rejection(&tracked_paths, untracked_count), + } + .fail() + } +} + +fn entry_path(entry: &git2::StatusEntry<'_>) -> Option { + entry + .head_to_index() + .and_then(|delta| delta.new_file().path().or_else(|| delta.old_file().path())) + .or_else(|| { + entry + .index_to_workdir() + .and_then(|delta| delta.new_file().path().or_else(|| delta.old_file().path())) + }) + .or_else(|| entry.path().map(Path::new)) + .map(Path::to_path_buf) +} + +fn collect_dirty_paths( + root_path: &Path, + include_path: impl Fn(&Path) -> bool, +) -> Result<(BTreeSet, usize), Error> { let repo = git2::Repository::open(root_path).context(GitSnafu { what: "open root repository", })?; - let mut options = StatusOptions::default(); - options.include_untracked(true); - let statuses = repo.statuses(Some(&mut options)).context(GitSnafu { - what: "get root repository status", + let statuses = dirty_statuses(&repo)?; + let mut paths = BTreeSet::new(); + let mut untracked_count = 0; + + for entry in statuses.iter() { + let status = entry.status(); + let path = entry_path(&entry).unwrap_or_else(|| PathBuf::from("")); + + if status.contains(Status::CONFLICTED) { + return DirtyConflictSnafu { path }.fail(); + } + + if status == Status::CURRENT || status == Status::IGNORED { + continue; + } + + if status == Status::WT_NEW { + if include_path(&path) { + untracked_count += 1; + } + continue; + } + + if let Some(delta) = entry.head_to_index() { + if let Some(old_path) = delta.old_file().path().filter(|path| include_path(path)) { + paths.insert(old_path.to_path_buf()); + } + if let Some(new_path) = delta.new_file().path().filter(|path| include_path(path)) { + paths.insert(new_path.to_path_buf()); + } + } + + if let Some(delta) = entry.index_to_workdir() { + if let Some(old_path) = delta.old_file().path().filter(|path| include_path(path)) { + paths.insert(old_path.to_path_buf()); + } + if let Some(new_path) = delta.new_file().path().filter(|path| include_path(path)) { + paths.insert(new_path.to_path_buf()); + } + } + + if include_path(&path) { + paths.insert(path); + } + } + + Ok((paths, untracked_count)) +} + +pub fn working_tree_paths(root_path: &Path) -> Result, Error> { + let (paths, _) = collect_dirty_paths(root_path, |path| !is_generated_path(path))?; + Ok(paths.into_iter().collect()) +} + +pub fn tracked_working_tree_paths(root_path: &Path) -> Result, Error> { + let (paths, _) = collect_dirty_paths(root_path, |_| true)?; + Ok(paths.into_iter().collect()) +} + +pub fn materialize_working_tree(source_root: &Path, destination_root: &Path) -> Result<(), Error> { + remove_existing_path(destination_root).with_context(|_| IoSnafu { + path: destination_root.to_path_buf(), })?; - let mut statuses = statuses.iter().filter(|x| { - x.path() - .map(|x| !x.trim_end_matches('/').ends_with(super::BUILD_DIR)) - .unwrap_or(false) + std::fs::create_dir_all(destination_root).with_context(|_| IoSnafu { + path: destination_root.to_path_buf(), + })?; + + let mut paths = tracked_paths(source_root, |_| true)?; + paths.extend(tracked_working_tree_paths(source_root)?); + sync_working_tree_paths(source_root, destination_root, &paths) +} + +pub fn sync_working_tree_paths( + source_root: &Path, + destination_root: &Path, + relative_paths: &BTreeSet, +) -> Result<(), Error> { + for path in relative_paths { + sync_working_tree_path(source_root, destination_root, path)?; + } + + Ok(()) +} + +pub fn index_path(root_path: &Path) -> Result { + let repo = git2::Repository::open(root_path).context(GitSnafu { + what: "open root repository", + })?; + let index = repo.index().context(GitSnafu { + what: "open root repository index", + })?; + index + .path() + .map(Path::to_path_buf) + .with_context(|| UpdateTreeSnafu:: { + msg: "repository index is in-memory".into(), + }) +} + +pub fn sync_materialized_paths( + source_root: &Path, + build_repo_path: &Path, + relative_paths: &BTreeSet, +) -> Result<(), Error> { + if relative_paths.is_empty() { + return Ok(()); + } + + let working_repo = git2::Repository::open(build_repo_path).context(GitSnafu { + what: "open build repository", + })?; + let working_root = working_repo + .workdir() + .with_context(|| UpdateTreeSnafu:: { + msg: "build repository workdir is unavailable".into(), + })?; + let mut index = working_repo.index().context(GitSnafu { + what: "open build repository index", + })?; + + for path in relative_paths { + sync_dirty_path(source_root, working_root, &mut index, path)?; + } + + index.write().context(GitSnafu { + what: "write build repository index", + })?; + + Ok(()) +} + +fn remove_existing_path(path: &Path) -> Result<(), std::io::Error> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + std::fs::remove_dir_all(path) + } + Ok(_) => std::fs::remove_file(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn tracked_paths( + root_path: &Path, + include_path: impl Fn(&Path) -> bool, +) -> Result, Error> { + let repo = git2::Repository::open(root_path).context(GitSnafu { + what: "open root repository", + })?; + let head = repo.head().context(GitSnafu { what: "head" })?; + let commit = head.peel_to_commit().context(GitSnafu { + what: "peel head to commit", + })?; + let tree = commit.tree().context(GitSnafu { what: "head tree" })?; + let mut paths = BTreeSet::new(); + let mut walk_error = None; + + let walk_result = tree.walk(git2::TreeWalkMode::PreOrder, |prefix, entry| { + let Some(name) = entry.name() else { + walk_error = Some( + UpdateTreeSnafu { + msg: format!("tree entry without name in `{prefix}`"), + } + .build(), + ); + return TreeWalkResult::Abort; + }; + + match entry.kind() { + Some(ObjectType::Blob) => (), + Some(ObjectType::Tree) => return TreeWalkResult::Ok, + kind => { + walk_error = Some( + UpdateTreeSnafu { + msg: format!("unknown blob type `{kind:?}` for `{}{name}`", prefix), + } + .build(), + ); + return TreeWalkResult::Abort; + } + } + + let path = PathBuf::from(format!("{prefix}{name}")); + if include_path(&path) { + paths.insert(path); + } + + TreeWalkResult::Ok }); - if statuses.next().is_some() { - DirtySnafu.fail() + + if let Some(error) = walk_error { + return Err(error); + } + + walk_result.context(GitSnafu { + what: "traverse tree", + })?; + + Ok(paths) +} + +fn remove_index_path(index: &mut git2::Index, path: &Path) -> Result<(), Error> { + match index.remove_path(path) { + Ok(()) => Ok(()), + Err(error) if error.code() == git2::ErrorCode::NotFound => match index.remove_dir(path, -1) + { + Ok(()) => Ok(()), + Err(error) if error.code() == git2::ErrorCode::NotFound => Ok(()), + Err(error) => Err(GitSnafu { + what: "remove dirty path from index", + } + .into_error(error)), + }, + Err(error) => Err(GitSnafu { + what: "remove dirty path from index", + } + .into_error(error)), + } +} + +#[cfg(target_family = "unix")] +fn copy_symlink(source: &Path, destination: &Path) -> Result<(), std::io::Error> { + let target = std::fs::read_link(source)?; + std::os::unix::fs::symlink(target, destination) +} + +#[cfg(target_family = "windows")] +fn copy_symlink(source: &Path, destination: &Path) -> Result<(), std::io::Error> { + let target = std::fs::read_link(source)?; + let resolved_target = source + .parent() + .map(|parent| parent.join(&target)) + .unwrap_or_else(|| target.clone()); + + if std::fs::metadata(&resolved_target) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false) + { + std::os::windows::fs::symlink_dir(target, destination) } else { - Ok(()) + std::os::windows::fs::symlink_file(target, destination) } } +#[cfg(not(any(target_family = "unix", target_family = "windows")))] +fn copy_symlink(_source: &Path, _destination: &Path) -> Result<(), std::io::Error> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "no symlink implementation available", + )) +} + +fn sync_dirty_path( + source_root: &Path, + working_root: &Path, + index: &mut git2::Index, + relative_path: &Path, +) -> Result<(), Error> { + let source_path = source_root.join(relative_path); + let working_path = working_root.join(relative_path); + + match std::fs::symlink_metadata(&source_path) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + remove_existing_path(&working_path).with_context(|_| IoSnafu { + path: working_path.clone(), + })?; + remove_index_path(index, relative_path)?; + Ok(()) + } + Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => { + if let Some(parent) = working_path.parent() { + std::fs::create_dir_all(parent).with_context(|_| IoSnafu { + path: parent.to_path_buf(), + })?; + } + + remove_existing_path(&working_path).with_context(|_| IoSnafu { + path: working_path.clone(), + })?; + + if metadata.file_type().is_symlink() { + copy_symlink(&source_path, &working_path).with_context(|_| IoSnafu { + path: working_path.clone(), + })?; + } else { + std::fs::copy(&source_path, &working_path).with_context(|_| IoSnafu { + path: source_path.clone(), + })?; + } + + index.add_path(relative_path).context(GitSnafu { + what: "add dirty path to index", + })?; + Ok(()) + } + Ok(_) => DirtyUnsupportedPathSnafu { + path: relative_path.to_path_buf(), + } + .fail(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + remove_existing_path(&working_path).with_context(|_| IoSnafu { + path: working_path.clone(), + })?; + remove_index_path(index, relative_path)?; + Ok(()) + } + Err(error) => Err(IoSnafu { path: source_path }.into_error(error)), + } +} + +fn sync_working_tree_path( + source_root: &Path, + destination_root: &Path, + relative_path: &Path, +) -> Result<(), Error> { + let source_path = source_root.join(relative_path); + let destination_path = destination_root.join(relative_path); + + match std::fs::symlink_metadata(&source_path) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + remove_existing_path(&destination_path).with_context(|_| IoSnafu { + path: destination_path.clone(), + }) + } + Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => { + if let Some(parent) = destination_path.parent() { + std::fs::create_dir_all(parent).with_context(|_| IoSnafu { + path: parent.to_path_buf(), + })?; + } + + remove_existing_path(&destination_path).with_context(|_| IoSnafu { + path: destination_path.clone(), + })?; + + if metadata.file_type().is_symlink() { + copy_symlink(&source_path, &destination_path).with_context(|_| IoSnafu { + path: destination_path.clone(), + })?; + } else { + std::fs::copy(&source_path, &destination_path).with_context(|_| IoSnafu { + path: source_path.clone(), + })?; + } + + Ok(()) + } + Ok(_) => DirtyUnsupportedPathSnafu { + path: relative_path.to_path_buf(), + } + .fail(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + remove_existing_path(&destination_path).with_context(|_| IoSnafu { + path: destination_path, + }) + } + Err(error) => Err(IoSnafu { path: source_path }.into_error(error)), + } +} + +fn materialize_dirty_tree( + source_root: &Path, + working_repo: &git2::Repository, + local_head: Oid, +) -> Result { + let (dirty_paths, untracked_count) = + collect_dirty_paths(source_root, |path| !is_generated_path(path))?; + if untracked_count > 0 { + info!("dirty mode ignores untracked files in the active content repo"); + } + + if dirty_paths.is_empty() { + return Ok(local_head); + } + + let working_root = working_repo + .workdir() + .with_context(|| UpdateTreeSnafu:: { + msg: "build repository workdir is unavailable".into(), + })?; + let mut index = working_repo.index().context(GitSnafu { + what: "open build repository index", + })?; + + for path in dirty_paths { + sync_dirty_path(source_root, working_root, &mut index, &path)?; + } + + index.write().context(GitSnafu { + what: "write build repository index", + })?; + let tree_id = index.write_tree().context(GitSnafu { + what: "write dirty materialization tree", + })?; + let tree = working_repo.find_tree(tree_id).context(GitSnafu { + what: "find dirty materialization tree", + })?; + + let sig = Signature::now("eips-build", "eips-build@eips-build.invalid").context(GitSnafu { + what: "dirty commit signature", + })?; + let parent = working_repo.find_commit(local_head).context(GitSnafu { + what: "find clean local head commit", + })?; + let dirty_head = working_repo + .commit( + Some("HEAD"), + &sig, + &sig, + "Dirty working tree materialization", + &tree, + &[&parent], + ) + .context(GitSnafu { + what: "commit dirty working tree materialization", + })?; + + info!( + "materialized tracked dirty changes from the active content repo into `{}`", + working_root.to_string_lossy() + ); + + Ok(dirty_head) +} + fn check_conflict(master_tree: &Tree, path: &Path, entry: &TreeEntry) -> Result<(), Error> { let original = match master_tree.get_path(path) { Err(_) => return Ok(()), @@ -108,7 +620,9 @@ fn check_conflict(master_tree: &Tree, path: &Path, entry: &TreeEntry) -> Result< pub struct Fresh { src_repo_use: RepositoryUse, + src_repo_path: PathBuf, src_repo_url: Url, + source_materialization: SourceMaterialization, working_repo: git2::Repository, } @@ -116,29 +630,41 @@ pub struct Fresh { impl Fresh { pub fn new( root_path: &Path, - build_path: &Path, + repo_path: &Path, src_repo_use: RepositoryUse, + source_materialization: SourceMaterialization, ) -> Result { - let root_path = absolute(root_path).context(IoSnafu { path: root_path })?; - check_dirty(&root_path)?; - let src_repo_url = Url::from_directory_path(&root_path) - .ok() - .context(PathUrlSnafu { path: root_path })?; + let root_path = absolute(root_path).with_context(|_| IoSnafu { path: root_path })?; + if source_materialization == SourceMaterialization::Clean { + check_dirty(&root_path)?; + } + let src_repo_url = + Url::from_directory_path(&root_path) + .ok() + .with_context(|| PathUrlSnafu { + path: root_path.clone(), + })?; debug!("source repository at `{src_repo_url}`"); - let working_repo = open_or_init(build_path)?; + let working_repo = open_or_init(repo_path)?; Ok(Self { working_repo, + src_repo_path: root_path, src_repo_url, src_repo_use, + source_materialization, }) } pub fn clone_src(self) -> Result { info!("cloning local repository"); - let master = fetch(&self.working_repo, self.src_repo_url.as_str(), "HEAD")?; + let master = fetch( + &self.working_repo, + self.src_repo_url.as_str(), + "+HEAD:refs/build-eips/source-head", + )?; self.working_repo .set_head_detached(master.id()) .context(GitSnafu { what: "detach" })?; @@ -167,9 +693,15 @@ impl Fresh { panic!("submodules not supported yet"); } - let local_head = master.id(); + let mut local_head = master.id(); drop(master); drop(branch); + + if self.source_materialization == SourceMaterialization::Dirty { + local_head = + materialize_dirty_tree(&self.src_repo_path, &self.working_repo, local_head)?; + } + Ok(SourceOnly { local_head, src_repo_use: self.src_repo_use, @@ -186,12 +718,16 @@ pub struct SourceOnly { } impl SourceOnly { + pub fn merge(&self) -> Result<(), Error> { + merge_sibling_repositories(&self.working_repo, &self.src_repo_use, self.local_head) + } + pub fn fetch_upstream(self) -> Result { info!("fetching latest {} repository", self.src_repo_use.title); let latest_master = fetch( &self.working_repo, self.src_repo_use.location.repository.as_str(), - "master", + "+master:refs/build-eips/upstream-head", )?; let upstream_head = latest_master.id(); drop(latest_master); @@ -265,41 +801,138 @@ impl SourceWithUpstream { Ok(changed_files) } - fn check_ignored(&self, tree: &Tree) -> Result<(), Error> { - let mut walk_error = None; - let walk_result = tree.walk(git2::TreeWalkMode::PreOrder, |a, b| { - if b.kind() != Some(ObjectType::Blob) { - return TreeWalkResult::Ok; + pub fn merge(&self) -> Result<(), Error> { + merge_sibling_repositories(&self.working_repo, &self.src_repo_use, self.local_head) + } +} + +fn check_ignored(working_repo: &git2::Repository, tree: &Tree) -> Result<(), Error> { + let mut walk_error = None; + let walk_result = tree.walk(git2::TreeWalkMode::PreOrder, |a, b| { + if b.kind() != Some(ObjectType::Blob) { + return TreeWalkResult::Ok; + } + + let path = match b.name() { + None => a.to_owned(), + Some(p) => format!("{a}{p}"), + }; + + debug!("checking if `{path}` is ignored"); + + match working_repo.is_path_ignored(&path) { + Ok(false) => TreeWalkResult::Ok, + Ok(true) => { + walk_error = Some( + UpdateTreeSnafu { + msg: format!("contains ignored path `{path}`"), + } + .build(), + ); + TreeWalkResult::Abort } + Err(e) => { + walk_error = Some( + GitSnafu { + what: "check ignored", + } + .into_error(e), + ); + TreeWalkResult::Abort + } + } + }); - let path = match b.name() { - None => a.to_owned(), - Some(p) => format!("{a}{p}"), - }; + if let Some(error) = walk_error { + return Err(error); + } + + walk_result.context(GitSnafu { + what: "traverse tree", + })?; + + Ok(()) +} + +fn merge_sibling_repositories( + working_repo: &git2::Repository, + repo_use: &RepositoryUse, + mut local_head: Oid, +) -> Result<(), Error> { + for (index, (other_kind, other_repo)) in repo_use + .other_repos + .iter() + .progress_ext("Merge Repos") + .enumerate() + { + let local_commit = working_repo.find_commit(local_head).context(GitSnafu { + what: "find local head commit", + })?; + let local_tree = local_commit.tree().context(GitSnafu { + what: "getting local head tree", + })?; + info!("fetching {other_kind} repository"); + // Local sibling overrides should follow the checked-out repo HEAD instead of assuming `master`. + let other_ref = format!("refs/build-eips/other-head-{index}"); + let other_refspec = if other_repo.scheme() == "file" { + format!("+HEAD:{other_ref}") + } else { + format!("+master:{other_ref}") + }; + let master_other = fetch(working_repo, other_repo.as_str(), &other_refspec)?; + let other_tree = master_other.tree().context(GitSnafu { + what: "getting other tree", + })?; - debug!("checking if `{path}` is ignored"); + let mut tree_builder = TreeUpdateBuilder::new(); + let prefix = format!("{}/", CONTENT_DIR); + let mut walk_error: Option = None; + let walk_result = other_tree.walk(git2::TreeWalkMode::PreOrder, |a, b| { + if !a.starts_with(&prefix) && (!a.is_empty() || b.name() != Some(CONTENT_DIR)) { + return TreeWalkResult::Skip; + } - match self.working_repo.is_path_ignored(&path) { - Ok(false) => TreeWalkResult::Ok, - Ok(true) => { + let name = match b.name() { + Some(n) => n, + None => { walk_error = Some( UpdateTreeSnafu { - msg: format!("contains ignored path `{path}`"), + msg: format!("tree entry without name in `{a}`"), } .build(), ); - TreeWalkResult::Abort + return TreeWalkResult::Abort; } - Err(e) => { + }; + + let path = format!("{}{}", a, name); + match b.kind() { + Some(ObjectType::Blob) => (), + Some(ObjectType::Tree) => return TreeWalkResult::Ok, + kind => { walk_error = Some( - GitSnafu { - what: "check ignored", + UpdateTreeSnafu { + msg: format!("unknown blob type `{kind:?}` for `{path}`"), } - .into_error(e), + .build(), ); - TreeWalkResult::Abort + return TreeWalkResult::Abort; } } + + if path == CONTENT_INDEX_PATH { + debug!("skip sibling homepage `{path}`"); + return TreeWalkResult::Ok; + } + + if let Err(e) = check_conflict(&local_tree, Path::new(&path), b) { + walk_error = Some(e); + return TreeWalkResult::Abort; + } + + debug!("upsert `{path}`"); + tree_builder.upsert(path, b.id(), FileMode::Blob); + TreeWalkResult::Ok }); if let Some(error) = walk_error { @@ -310,130 +943,53 @@ impl SourceWithUpstream { what: "traverse tree", })?; - Ok(()) - } + let merged_tree_oid = tree_builder + .create_updated(working_repo, &local_tree) + .context(GitSnafu { what: "build tree" })?; + let merged_tree = working_repo.find_tree(merged_tree_oid).unwrap(); - pub fn merge(&self) -> Result<(), Error> { - let repo_use = &self.src_repo_use; - let master_tree = self.local_head_tree()?; - let mut local_head = self.local_head; - for (other_kind, other_repo) in repo_use.other_repos.iter().progress_ext("Merge Repos") { - info!("fetching {other_kind} repository"); - let master_other = fetch( - &self.working_repo, - other_repo.as_str(), - "master:master-other", - )?; - let other_tree = master_other.tree().context(GitSnafu { - what: "getting other tree", - })?; + check_ignored(working_repo, &merged_tree)?; - let mut tree_builder = TreeUpdateBuilder::new(); - let prefix = format!("{}/", super::CONTENT_DIR); - let mut walk_error: Option = None; - let walk_result = other_tree.walk(git2::TreeWalkMode::PreOrder, |a, b| { - if !a.starts_with(&prefix) - && (!a.is_empty() || b.name() != Some(super::CONTENT_DIR)) - { - return TreeWalkResult::Skip; - } + let sig = + Signature::now("eips-build", "eips-build@eips-build.invalid").context(GitSnafu { + what: "commit signature", + })?; + let msg = format!("Merge {other_repo}"); + local_head = working_repo + .commit( + Some("HEAD"), + &sig, + &sig, + &msg, + &merged_tree, + &[&local_commit, &master_other], + ) + .context(GitSnafu { what: "committing" })?; - let name = match b.name() { - Some(n) => n, - None => { - walk_error = Some( - UpdateTreeSnafu { - msg: format!("tree entry without name in `{a}`"), - } - .build(), - ); - return TreeWalkResult::Abort; - } - }; - - let path = format!("{}{}", a, name); - match b.kind() { - Some(ObjectType::Blob) => (), - Some(ObjectType::Tree) => return TreeWalkResult::Ok, - kind => { - walk_error = Some( - UpdateTreeSnafu { - msg: format!("unknown blob type `{kind:?}` for `{path}`"), - } - .build(), - ); - return TreeWalkResult::Abort; - } - } + working_repo + .checkout_head(Some(CheckoutBuilder::default().force())) + .context(GitSnafu { + what: "checkout merged", + })?; - if let Err(e) = check_conflict(&master_tree, Path::new(&path), b) { - walk_error = Some(e); - return TreeWalkResult::Abort; + drop(merged_tree); + drop(other_tree); + drop(master_other); + drop(local_tree); + drop(local_commit); + match working_repo.find_reference(&other_ref) { + Ok(mut reference) => { + if let Err(error) = reference.delete() { + debug!("unable to delete temporary sibling ref `{other_ref}`: {error}"); } - - debug!("upsert `{path}`"); - tree_builder.upsert(path, b.id(), FileMode::Blob); - TreeWalkResult::Ok - }); - - if let Some(error) = walk_error { - return Err(error); } - - walk_result.context(GitSnafu { - what: "traverse tree", - })?; - - let merged_tree_oid = tree_builder - .create_updated(&self.working_repo, &master_tree) - .context(GitSnafu { what: "build tree" })?; - let merged_tree = self.working_repo.find_tree(merged_tree_oid).unwrap(); - - self.check_ignored(&merged_tree)?; - - let sig = Signature::now("eips-build", "eips-build@eips-build.invalid").context( - GitSnafu { - what: "commit signature", - }, - )?; - let msg = format!("Merge {other_repo}"); - let master = self - .working_repo - .find_commit(local_head) - .context(GitSnafu { - what: "find local head commit", - })?; - local_head = self - .working_repo - .commit( - Some("HEAD"), - &sig, - &sig, - &msg, - &merged_tree, - &[&master, &master_other], - ) - .context(GitSnafu { what: "committing" })?; - - self.working_repo - .checkout_head(Some(CheckoutBuilder::default().force())) - .context(GitSnafu { - what: "checkout merged", - })?; - - self.working_repo - .find_branch("master-other", BranchType::Local) - .context(GitSnafu { - what: "find master-other", - })? - .delete() - .context(GitSnafu { - what: "delete master-other", - })?; + Err(error) => { + debug!("temporary sibling ref `{other_ref}` was not deleted: {error}"); + } } - - Ok(()) } + + Ok(()) } fn fetch<'a>( @@ -442,7 +998,19 @@ fn fetch<'a>( refspec: &'_ str, ) -> Result, Error> { debug!("fetching repository at `{url}`"); - let mut remote = repo.remote_anonymous(url).context(GitSnafu { + let remote_name = "__build_eips_fetch"; + match repo.remote_delete(remote_name) { + Ok(()) => (), + Err(error) if error.code() == git2::ErrorCode::NotFound => (), + Err(error) => { + return Err(GitSnafu { + what: "deleting temporary remote", + } + .into_error(error)) + } + } + + let mut remote = repo.remote(remote_name, url).context(GitSnafu { what: "creating remote", })?; { @@ -455,14 +1023,24 @@ fn fetch<'a>( what: "fetching repo", })?; } + drop(remote); + repo.remote_delete(remote_name).context(GitSnafu { + what: "deleting temporary remote", + })?; + + let fetched_ref = refspec + .split_once(':') + .map(|(_, destination)| destination) + .filter(|destination| !destination.is_empty()) + .unwrap_or("FETCH_HEAD"); let commit = repo - .revparse_single("FETCH_HEAD") + .revparse_single(fetched_ref) .context(GitSnafu { - what: "revparse FETCH_HEAD", + what: "revparse fetched ref", })? .peel_to_commit() .context(GitSnafu { - what: "peel FETCH_HEAD", + what: "peel fetched ref", })?; Ok(commit) } @@ -479,36 +1057,3 @@ fn open_or_init(dir: &Path) -> Result { Ok(repo) } -impl Cache { - pub fn repo(&self, url: &str, commit: &str) -> Result { - let key = format!("git\0{url}"); - let dir = self.dir(&key)?; - - let repo = open_or_init(&dir)?; - let object = match repo.revparse_single(commit) { - Ok(c) => c, - Err(e) if e.code() == git2::ErrorCode::NotFound => { - fetch(&repo, url, "master")?; - repo.revparse_single(commit).context(GitSnafu { - what: "revparse cached commit", - })? - } - Err(e) => { - return Err(GitSnafu { - what: "revparse cached commit", - } - .into_error(e)) - } - }; - - repo.checkout_tree(&object, Some(CheckoutBuilder::new().force())) - .context(GitSnafu { - what: "checkout cached commit", - })?; - repo.set_head_detached(object.id()).context(GitSnafu { - what: "set detached head", - })?; - - Ok(dir) - } -} From d3135e1c6500582c3434bc7cab7b0cad65887b07 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:11:33 -0400 Subject: [PATCH 3/9] Add workspace init baseline Add workspace initialization from the selected active `Build.toml`. Clone missing sibling repositories from declared locations and initialize a missing theme checkout from the manifest repository and pin. Preserve existing usable checkouts and fail without deleting unusable paths. Write `.build-eips.toml` only when absent and generate `WORKSPACE.md` for the initialized workspace. --- src/cli.rs | 10 +++ src/context.rs | 11 ++- src/git.rs | 159 ++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 7 ++ src/workspace.rs | 156 ++++++++++++++++++++++++++++++++++++++++++ src/workspace_doc.md | 14 ++++ 6 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 src/workspace.rs create mode 100644 src/workspace_doc.md diff --git a/src/cli.rs b/src/cli.rs index ff93bbf..45610f8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -61,6 +61,16 @@ pub(crate) enum Operation { #[clap(long, value_enum, default_value_t)] format: ChangedFormat, }, + + /// Create workspace config, docs, build root, and missing local repos + Init { + /// Workspace root directory + path: PathBuf, + + /// Also clone template for proposal-family scaffold work + #[arg(long)] + template: bool, + }, } #[derive(Debug, clap::ValueEnum, Clone, Default)] diff --git a/src/context.rs b/src/context.rs index 1f53d4e..46d209b 100644 --- a/src/context.rs +++ b/src/context.rs @@ -6,7 +6,7 @@ //! Command context and path resolution helpers. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use snafu::{ResultExt, Whatever}; @@ -18,6 +18,15 @@ pub(crate) struct WorkspaceCommandContext { pub(crate) config_path: Option, } +pub(crate) fn resolve_input_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + let cwd = std::env::current_dir().whatever_context("unable to get current directory")?; + Ok(cwd.join(path)) + } +} + pub(crate) fn root(args: &Args) -> Result { match &args.root { None => find_root::find_root().whatever_context("cannot find repository root"), diff --git a/src/git.rs b/src/git.rs index 38c7d81..cd3f93d 100644 --- a/src/git.rs +++ b/src/git.rs @@ -7,6 +7,7 @@ use std::{ collections::BTreeSet, ffi::OsStr, + io::ErrorKind, path::{absolute, Path, PathBuf}, }; @@ -16,8 +17,8 @@ use crate::{ progress::{Git, ProgressIteratorExt}, }; use git2::{ - build::TreeUpdateBuilder, - Commit, FetchOptions, FileMode, ObjectType, Oid, Signature, Status, + build::{CheckoutBuilder, TreeUpdateBuilder}, + Commit, FetchOptions, FileMode, ObjectType, Oid, RepositoryOpenFlags, Signature, Status, StatusOptions, Tree, TreeEntry, TreeWalkResult, }; use log::{debug, info}; @@ -60,6 +61,25 @@ pub enum Error { DirtyUnsupportedPath { path: PathBuf, backtrace: Backtrace }, #[snafu(display("unable to update tree ({msg})"))] UpdateTree { msg: String, backtrace: Backtrace }, + #[snafu(display( + "workspace path `{}` already exists but is not a usable git repository", + path.to_string_lossy() + ))] + ExistingWorkspacePath { path: PathBuf, backtrace: Backtrace }, + #[snafu(display( + "workspace repository `{}` is unusable: {reason}", + path.to_string_lossy() + ))] + UnusableWorkspaceRepository { + path: PathBuf, + reason: &'static str, + backtrace: Backtrace, + }, + #[snafu(display( + "fresh workspace repository `{}` was missing before checkout", + path.to_string_lossy() + ))] + MissingFreshWorkspaceRepository { path: PathBuf, backtrace: Backtrace }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -72,6 +92,141 @@ pub fn repository_available(path: &Path) -> bool { git2::Repository::open(path).is_ok() } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloneOutcome { + Fresh, + Existing, +} + +fn ensure_usable_workspace_repository( + repository: &git2::Repository, + path: &Path, +) -> Result<(), Error> { + if repository.workdir().is_none() { + return UnusableWorkspaceRepositorySnafu { + path: path.to_path_buf(), + reason: "it is bare", + } + .fail(); + } + + if repository.head().is_err() { + return UnusableWorkspaceRepositorySnafu { + path: path.to_path_buf(), + reason: "it has no HEAD commit", + } + .fail(); + } + + Ok(()) +} + +fn open_existing_workspace_repository( + destination: &Path, +) -> Result, Error> { + match git2::Repository::open_ext( + destination, + RepositoryOpenFlags::NO_SEARCH, + &[] as &[&OsStr], + ) { + Ok(repository) => { + ensure_usable_workspace_repository(&repository, destination)?; + Ok(Some(repository)) + } + Err(error) if error.code() == git2::ErrorCode::NotFound => { + match std::fs::symlink_metadata(destination) { + Ok(_) => ExistingWorkspacePathSnafu { + path: destination.to_path_buf(), + } + .fail(), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(IoSnafu { + path: destination.to_path_buf(), + } + .into_error(error)), + } + } + Err(error) => match std::fs::symlink_metadata(destination) { + Ok(_) => ExistingWorkspacePathSnafu { + path: destination.to_path_buf(), + } + .fail(), + Err(metadata_error) if metadata_error.kind() == ErrorKind::NotFound => Err(GitSnafu { + what: "open existing workspace repository", + } + .into_error(error)), + Err(metadata_error) => Err(IoSnafu { + path: destination.to_path_buf(), + } + .into_error(metadata_error)), + }, + } +} + +pub fn clone_missing_repo(url: &str, destination: &Path) -> Result { + if open_existing_workspace_repository(destination)?.is_some() { + info!( + "using existing workspace repo `{}`", + destination.to_string_lossy() + ); + return Ok(CloneOutcome::Existing); + } + + info!("cloning `{url}` into `{}`", destination.to_string_lossy()); + let repository = git2::Repository::clone(url, destination).context(GitSnafu { + what: "clone workspace repository", + })?; + ensure_usable_workspace_repository(&repository, destination)?; + Ok(CloneOutcome::Fresh) +} + +pub fn checkout_fresh_clone_at_commit( + destination: &Path, + repository_url: &str, + commit: &str, +) -> Result<(), Error> { + let Some(repository) = open_existing_workspace_repository(destination)? else { + return MissingFreshWorkspaceRepositorySnafu { + path: destination.to_path_buf(), + } + .fail(); + }; + let commit = match repository + .revparse_single(commit) + .and_then(|object| object.peel_to_commit()) + { + Ok(commit) => commit, + Err(error) + if matches!( + error.code(), + git2::ErrorCode::NotFound | git2::ErrorCode::InvalidSpec + ) => + { + let refspec = format!("+{commit}:refs/build-eips/theme-pin"); + fetch(&repository, repository_url, &refspec)? + } + Err(error) => { + return Err(GitSnafu { + what: "resolve manifest theme commit", + } + .into_error(error)); + } + }; + + repository + .set_head_detached(commit.id()) + .context(GitSnafu { + what: "detach manifest theme commit", + })?; + repository + .checkout_head(Some(CheckoutBuilder::default().force())) + .context(GitSnafu { + what: "checkout manifest theme commit", + })?; + + Ok(()) +} + fn is_generated_path(path: &Path) -> bool { path.components() .next() diff --git a/src/main.rs b/src/main.rs index 0b86029..4264b87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ mod lint; mod markdown; mod print; mod progress; +mod workspace; mod zola; use std::path::{Path, PathBuf}; @@ -30,6 +31,7 @@ use crate::{ cli::{Args, Operation}, config::{Manifest, RepositoryUse}, layout::{BUILD_DIR, CONTENT_DIR, OUTPUT_DIR, REPO_DIR}, + workspace::init_workspace, }; fn lock(build_path: &Path) -> Result { @@ -170,6 +172,11 @@ fn run() -> Result<(), Whatever> { return Ok(()); } + if let Operation::Init { path, template } = &args.operation { + init_workspace(&args, path.clone(), *template)?; + return Ok(()); + } + let root_path = context::root(&args)?; let manifest_path = root_path.join(config::MANIFEST_FILE); diff --git a/src/workspace.rs b/src/workspace.rs new file mode 100644 index 0000000..213420a --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,156 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +//! Local workspace setup. + +use std::{ + fs::OpenOptions, + io::{ErrorKind, Write}, + path::{Path, PathBuf}, +}; + +use log::info; +use snafu::{ResultExt, Whatever}; +use url::Url; + +use crate::{ + cli::Args, + config::{self, ActiveRepo}, + context::{resolve_input_path, root}, + git, +}; + +const PROPOSAL_TEMPLATE_URL: &str = "https://github.com/eips-wg/template.git"; +const WORKSPACE_DOC_FILE: &str = "WORKSPACE.md"; + +struct WorkspaceInitToolingRepositories<'a> { + template: &'a Url, +} + +pub(crate) fn init_workspace( + args: &Args, + workspace_root: PathBuf, + include_template: bool, +) -> Result<(), Whatever> { + let template_repository = Url::parse(PROPOSAL_TEMPLATE_URL) + .whatever_context("invalid proposal template repository URL")?; + let repositories = WorkspaceInitToolingRepositories { + template: &template_repository, + }; + + init_workspace_with_repositories( + args, + workspace_root, + include_template, + &repositories, + ) +} + +fn init_workspace_with_repositories( + args: &Args, + workspace_root: PathBuf, + include_template: bool, + repositories: &WorkspaceInitToolingRepositories<'_>, +) -> Result<(), Whatever> { + let root_path = root(args) + .whatever_context("workspace init requires an active Build.toml repository root")?; + let active_repo = ActiveRepo::load(&root_path) + .whatever_context("unable to load active repository Build.toml")?; + let workspace_root = resolve_input_path(&workspace_root)?; + std::fs::create_dir_all(&workspace_root) + .whatever_context("unable to create workspace root directory")?; + let workspace_root = workspace_root + .canonicalize() + .whatever_context("unable to canonicalize workspace root directory")?; + + let repository_use = active_repo.repository_use; + let theme = active_repo.theme; + + for (sibling_id, sibling_url) in repository_use.other_repos { + let sibling_path = workspace_root.join(&sibling_id); + git::clone_missing_repo(sibling_url.as_str(), &sibling_path).with_whatever_context(|_| { + format!( + "unable to prepare workspace sibling repo `{sibling_id}` at `{}`; destination must be missing or a usable git repository", + sibling_path.to_string_lossy() + ) + })?; + } + + let theme_path = workspace_root.join(config::DEFAULT_THEME_DIR); + if git::clone_missing_repo(theme.repository.as_str(), &theme_path) + .with_whatever_context(|_| { + format!( + "unable to prepare workspace theme repo at `{}`; destination must be missing or a usable git repository", + theme_path.to_string_lossy() + ) + })? + == git::CloneOutcome::Fresh + { + git::checkout_fresh_clone_at_commit(&theme_path, theme.repository.as_str(), &theme.commit) + .with_whatever_context(|_| { + format!( + "unable to fetch or check out active Build.toml theme commit `{}`", + theme.commit + ) + })?; + } + + if include_template { + let template_path = workspace_root.join("template"); + git::clone_missing_repo(repositories.template.as_str(), &template_path) + .with_whatever_context(|_| { + format!( + "unable to prepare workspace template repo at `{}`; destination must be missing or a usable git repository", + template_path.to_string_lossy() + ) + })?; + } + + std::fs::create_dir_all(workspace_root.join(config::DEFAULT_BUILD_ROOT_BASE)) + .whatever_context("unable to create local build root")?; + + let config_path = workspace_root.join(config::LOCAL_CONFIG_FILE); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&config_path) + { + Ok(mut config_file) => { + config_file + .write_all(config::default_workspace_config_text().as_bytes()) + .whatever_context("unable to write workspace config")?; + } + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + info!( + "leaving existing workspace config `{}` in place", + config_path.to_string_lossy() + ); + } + Err(error) => { + return Err(error).whatever_context("unable to write workspace config"); + } + } + + write_workspace_doc(&workspace_root)?; + + Ok(()) +} + +fn workspace_doc_text() -> &'static str { + include_str!("workspace_doc.md") +} + +fn write_workspace_doc(workspace_root: &Path) -> Result<(), Whatever> { + let doc_path = workspace_root.join(WORKSPACE_DOC_FILE); + std::fs::write(&doc_path, workspace_doc_text()).with_whatever_context(|_| { + format!( + "unable to write workspace document `{}`", + doc_path.to_string_lossy() + ) + })?; + + Ok(()) +} diff --git a/src/workspace_doc.md b/src/workspace_doc.md new file mode 100644 index 0000000..b251071 --- /dev/null +++ b/src/workspace_doc.md @@ -0,0 +1,14 @@ +# build-eips Workspace + +This guide is generated as `WORKSPACE.md` by `build-eips init`. + +A workspace keeps proposal repositories, an editable local theme, generated build output, and workspace-local settings together. + +The active proposal repository must contain a valid `Build.toml`. Its locations define sibling proposal repositories, and `[theme]` supplies the repository and pin used when `init` creates a missing theme checkout. + +```sh +build-eips init .. +build-eips init .. --template +``` + +`init` preserves existing usable repositories and writes `.build-eips.toml` only when it is missing. From 5b804187a8fe21c7b9cc3e14ecdc8d097d55359e Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:11:56 -0400 Subject: [PATCH 4/9] Add workspace doctor Add `build-eips doctor` for validating workspace configuration, active `Build.toml`, managed sibling repositories, the local theme checkout, and required tools. Report ok/warn/fail diagnostics; invalid active manifests and unusable theme configuration fail, while expected sibling and theme-pin conditions can warn. Keep diagnostics read-only and leave execution and runtime behavior to later branches. --- src/cli.rs | 3 + src/git.rs | 376 +++++++++++++++++++++++++++ src/lint.rs | 19 ++ src/main.rs | 7 +- src/workspace.rs | 642 ++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 1042 insertions(+), 5 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 45610f8..99b7216 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -71,6 +71,9 @@ pub(crate) enum Operation { #[arg(long)] template: bool, }, + + /// Check workspace layout, local repos, and required tools + Doctor, } #[derive(Debug, clap::ValueEnum, Clone, Default)] diff --git a/src/git.rs b/src/git.rs index cd3f93d..7b68aba 100644 --- a/src/git.rs +++ b/src/git.rs @@ -92,6 +92,58 @@ pub fn repository_available(path: &Path) -> bool { git2::Repository::open(path).is_ok() } +/// Open a non-bare repository with a resolvable HEAD without modifying it. +pub fn open_usable_repository(path: &Path) -> Result { + let repository = + git2::Repository::open_ext(path, RepositoryOpenFlags::NO_SEARCH, &[] as &[&OsStr]) + .context(GitSnafu { + what: "open workspace repository", + })?; + ensure_usable_workspace_repository(&repository, path)?; + Ok(repository) +} + +/// Return the commit currently checked out by a repository. +pub fn head_commit_id(repository: &git2::Repository) -> Result { + let head = repository.head().context(GitSnafu { + what: "read repository HEAD", + })?; + let commit = head.peel_to_commit().context(GitSnafu { + what: "resolve repository HEAD commit", + })?; + Ok(commit.id()) +} + +/// Resolve a local commit-ish to its commit object ID without fetching. +pub fn resolve_commit_id(repository: &git2::Repository, commit: &str) -> Result { + let object = repository.revparse_single(commit).context(GitSnafu { + what: "resolve local commit-ish", + })?; + let commit = object.peel_to_commit().context(GitSnafu { + what: "resolve local commit object", + })?; + Ok(commit.id()) +} + +/// Return configured remote URLs without fetching or otherwise modifying a repository. +pub fn remote_urls(repository: &git2::Repository) -> Result, Error> { + let names = repository.remotes().context(GitSnafu { + what: "list repository remotes", + })?; + + names + .iter() + .flatten() + .map(|name| { + let remote = repository.find_remote(name).context(GitSnafu { + what: "read repository remote", + })?; + Ok(remote.url().map(ToOwned::to_owned)) + }) + .collect::, Error>>() + .map(|urls| urls.into_iter().flatten().collect()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CloneOutcome { Fresh, @@ -1212,3 +1264,327 @@ fn open_or_init(dir: &Path) -> Result { Ok(repo) } +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use git2::{IndexAddOption, Repository, Signature}; + use tempfile::TempDir; + + use super::{ + checkout_fresh_clone_at_commit, materialize_working_tree, sync_working_tree_paths, + tracked_working_tree_paths, Fresh, SourceMaterialization, + }; + use crate::config::{Location, RepositoryUse}; + + fn file_url(path: &Path) -> url::Url { + url::Url::from_directory_path(path).unwrap() + } + + fn write_file(root: &Path, relative: impl AsRef, contents: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); + } + + fn commit_all(repo: &Repository, message: &str) { + let mut index = repo.index().unwrap(); + index + .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) + .unwrap(); + index.write().unwrap(); + let tree_oid = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_oid).unwrap(); + let signature = Signature::now("build-eips test", "build-eips@example.test").unwrap(); + let parents = repo + .head() + .ok() + .and_then(|head| head.target()) + .map(|oid| repo.find_commit(oid).unwrap()) + .into_iter() + .collect::>(); + let parent_refs = parents.iter().collect::>(); + + repo.commit( + Some("HEAD"), + &signature, + &signature, + message, + &tree, + &parent_refs, + ) + .unwrap(); + } + + fn init_repo(path: &Path, files: &[(&str, &str)]) -> Repository { + std::fs::create_dir_all(path).unwrap(); + let repo = Repository::init(path).unwrap(); + repo.set_head("refs/heads/master").unwrap(); + for (relative, contents) in files { + write_file(path, relative, contents); + } + commit_all(&repo, "initial"); + repo + } + + fn stage_path(repo: &Repository, relative: &str) { + let mut index = repo.index().unwrap(); + index.add_path(Path::new(relative)).unwrap(); + index.write().unwrap(); + } + + #[test] + fn checkout_fresh_clone_missing_destination_returns_error() { + let temp = TempDir::new().unwrap(); + let destination = temp.path().join("missing"); + let error = + checkout_fresh_clone_at_commit(&destination, "https://example.test/theme.git", "main") + .unwrap_err(); + + assert!(matches!( + error, + super::Error::MissingFreshWorkspaceRepository { .. } + )); + } + + #[test] + fn checkout_fresh_clone_fetches_missing_manifest_commit() { + let temp = TempDir::new().unwrap(); + let source_path = temp.path().join("source"); + let source = init_repo( + &source_path, + &[( + "README.md", + "source +", + )], + ); + write_file( + &source_path, + "PINNED.md", + "pinned +", + ); + commit_all(&source, "pinned commit"); + let pinned_commit = source.head().unwrap().target().unwrap().to_string(); + let destination = temp.path().join("destination"); + init_repo( + &destination, + &[( + "README.md", + "destination +", + )], + ); + + checkout_fresh_clone_at_commit( + &destination, + file_url(&source_path).as_str(), + &pinned_commit, + ) + .unwrap(); + + let destination_repo = Repository::open(&destination).unwrap(); + assert_eq!( + destination_repo + .head() + .unwrap() + .target() + .unwrap() + .to_string(), + pinned_commit + ); + assert!(destination_repo + .find_reference("refs/build-eips/theme-pin") + .is_ok()); + assert_eq!( + std::fs::read_to_string(destination.join("PINNED.md")).unwrap(), + "pinned +" + ); + } + + #[test] + fn merge_skips_sibling_homepage_and_keeps_sibling_proposals() { + let temp = TempDir::new().unwrap(); + let active = temp.path().join("active"); + let sibling = temp.path().join("sibling"); + let prepared = temp.path().join("prepared"); + + init_repo( + &active, + &[ + ("content/_index.md", "active homepage\n"), + ("content/00555.md", "# Active proposal\n"), + ], + ); + init_repo( + &sibling, + &[ + ("content/_index.md", "sibling homepage\n"), + ("content/00678.md", "# Sibling proposal\n"), + ], + ); + + let mut other_repos = std::collections::HashMap::new(); + other_repos.insert("ERCs".to_owned(), file_url(&sibling)); + let active_url = file_url(&active); + let repository_use = RepositoryUse { + title: "EIPs".to_owned(), + location: Location { + repository: active_url, + base_url: "https://eips.example.test/".parse().unwrap(), + }, + other_repos, + }; + + Fresh::new( + &active, + &prepared, + repository_use, + SourceMaterialization::Clean, + ) + .unwrap() + .clone_src() + .unwrap() + .fetch_upstream() + .unwrap() + .merge() + .unwrap(); + + assert_eq!( + std::fs::read_to_string(prepared.join("content/_index.md")).unwrap(), + "active homepage\n" + ); + assert!(prepared.join("content/00678.md").is_file()); + } + + #[test] + fn materialize_working_tree_uses_tracked_theme_scope() { + let temp = TempDir::new().unwrap(); + let theme = temp.path().join("theme"); + let mounted = temp.path().join("repo/themes/eips-theme"); + let repo = init_repo( + &theme, + &[ + ("config/zola.toml", "title = 'theme'\n"), + ("build/generated.txt", "tracked build path\n"), + ("delete.txt", "delete me\n"), + ("staged.txt", "old staged\n"), + ("tracked.txt", "old tracked\n"), + ], + ); + + write_file(&theme, "tracked.txt", "unstaged tracked edit\n"); + write_file(&theme, "staged.txt", "staged tracked edit\n"); + stage_path(&repo, "staged.txt"); + write_file(&theme, "new-staged.txt", "new staged file\n"); + stage_path(&repo, "new-staged.txt"); + std::fs::remove_file(theme.join("delete.txt")).unwrap(); + let mut index = repo.index().unwrap(); + index.remove_path(Path::new("delete.txt")).unwrap(); + index.write().unwrap(); + write_file( + &theme, + "untracked.txt", + "ignored by theme materialization\n", + ); + + materialize_working_tree(&theme, &mounted).unwrap(); + + assert_eq!( + std::fs::read_to_string(mounted.join("config/zola.toml")).unwrap(), + "title = 'theme'\n" + ); + assert_eq!( + std::fs::read_to_string(mounted.join("build/generated.txt")).unwrap(), + "tracked build path\n" + ); + assert_eq!( + std::fs::read_to_string(mounted.join("tracked.txt")).unwrap(), + "unstaged tracked edit\n" + ); + assert_eq!( + std::fs::read_to_string(mounted.join("staged.txt")).unwrap(), + "staged tracked edit\n" + ); + assert_eq!( + std::fs::read_to_string(mounted.join("new-staged.txt")).unwrap(), + "new staged file\n" + ); + assert!(!mounted.join("delete.txt").exists()); + assert!(!mounted.join("untracked.txt").exists()); + } + + #[test] + fn newly_staged_theme_file_syncs_after_git_index_rescan() { + let temp = TempDir::new().unwrap(); + let theme = temp.path().join("theme"); + let mounted = temp.path().join("repo/themes/eips-theme"); + let repo = init_repo(&theme, &[("config/zola.toml", "title = 'theme'\n")]); + materialize_working_tree(&theme, &mounted).unwrap(); + let mut previous_dirty_paths = tracked_working_tree_paths(&theme) + .unwrap() + .into_iter() + .collect::>(); + + write_file(&theme, "templates/new.html", "new staged template\n"); + stage_path(&repo, "templates/new.html"); + let current_dirty_paths = tracked_working_tree_paths(&theme) + .unwrap() + .into_iter() + .collect::>(); + let affected_paths = previous_dirty_paths + .union(¤t_dirty_paths) + .cloned() + .collect(); + + sync_working_tree_paths(&theme, &mounted, &affected_paths).unwrap(); + previous_dirty_paths = current_dirty_paths; + + assert_eq!( + std::fs::read_to_string(mounted.join("templates/new.html")).unwrap(), + "new staged template\n" + ); + assert!(previous_dirty_paths.contains(Path::new("templates/new.html"))); + } + + #[cfg(target_family = "unix")] + #[test] + fn tracked_theme_symlinks_are_materialized_as_symlinks() { + let temp = TempDir::new().unwrap(); + let theme = temp.path().join("theme"); + let mounted = temp.path().join("repo/themes/eips-theme"); + std::fs::create_dir_all(&theme).unwrap(); + let repo = Repository::init(&theme).unwrap(); + repo.set_head("refs/heads/master").unwrap(); + write_file(&theme, "target.txt", "target\n"); + std::os::unix::fs::symlink("target.txt", theme.join("linked.txt")).unwrap(); + commit_all(&repo, "initial"); + + materialize_working_tree(&theme, &mounted).unwrap(); + + assert!(std::fs::symlink_metadata(mounted.join("linked.txt")) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!( + std::fs::read_link(mounted.join("linked.txt")).unwrap(), + PathBuf::from("target.txt") + ); + } + + #[test] + fn materialize_working_tree_requires_git_repository() { + let temp = TempDir::new().unwrap(); + let theme = temp.path().join("theme"); + let mounted = temp.path().join("repo/themes/eips-theme"); + std::fs::create_dir_all(&theme).unwrap(); + + let error = materialize_working_tree(&theme, &mounted).unwrap_err(); + + assert!(error.to_string().contains("unable to open root repository")); + } +} diff --git a/src/lint.rs b/src/lint.rs index a433b37..7d05cac 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -255,6 +255,25 @@ fn version_cmp( Ok(()) } +fn eipw_config_path(theme_path: &Path) -> PathBuf { + theme_path.join("config").join("eipw.toml") +} + +/// Check whether a theme's eipw configuration uses a compatible schema. +pub fn eipw_schema_status(theme_path: &Path) -> Result<(), Error> { + let config_path = eipw_config_path(theme_path); + let toml_file = Toml::file_exact(&config_path); + let file_version = Figment::new() + .merge(toml_file) + .extract::() + .context(ConfigSnafu)? + .schema_version; + let application_version = DefaultOptions::::schema_version(); + + version_cmp(file_version, application_version)?; + Ok(()) +} + #[tokio::main(flavor = "current_thread")] pub async fn eipw( theme_repo: &str, diff --git a/src/main.rs b/src/main.rs index 4264b87..d35c73e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,7 @@ use crate::{ cli::{Args, Operation}, config::{Manifest, RepositoryUse}, layout::{BUILD_DIR, CONTENT_DIR, OUTPUT_DIR, REPO_DIR}, - workspace::init_workspace, + workspace::{doctor_workspace, init_workspace}, }; fn lock(build_path: &Path) -> Result { @@ -177,6 +177,11 @@ fn run() -> Result<(), Whatever> { return Ok(()); } + if let Operation::Doctor = &args.operation { + doctor_workspace(&args)?; + return Ok(()); + } + let root_path = context::root(&args)?; let manifest_path = root_path.join(config::MANIFEST_FILE); diff --git a/src/workspace.rs b/src/workspace.rs index 213420a..fb0f963 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -4,32 +4,665 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -//! Local workspace setup. +//! Local workspace setup and diagnostics. use std::{ + fmt, fs::OpenOptions, io::{ErrorKind, Write}, path::{Path, PathBuf}, }; use log::info; -use snafu::{ResultExt, Whatever}; +use snafu::{Report, ResultExt, Whatever}; use url::Url; use crate::{ cli::Args, - config::{self, ActiveRepo}, - context::{resolve_input_path, root}, + config::{self, ActiveRepo, LoadedWorkspaceConfig, Manifest}, + context::{resolve_input_path, root, WorkspaceCommandContext}, git, }; const PROPOSAL_TEMPLATE_URL: &str = "https://github.com/eips-wg/template.git"; const WORKSPACE_DOC_FILE: &str = "WORKSPACE.md"; +#[derive(Debug, Clone, Copy)] +enum DoctorStatus { + Ok, + Warn, + Fail, +} + +#[derive(Debug, Default)] +struct DoctorReport { + warnings: usize, + failures: usize, +} + struct WorkspaceInitToolingRepositories<'a> { template: &'a Url, } +impl fmt::Display for DoctorStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Ok => "ok", + Self::Warn => "warn", + Self::Fail => "fail", + }; + + f.write_str(label) + } +} + +impl DoctorReport { + fn record(&mut self, status: DoctorStatus, message: impl AsRef) { + match status { + DoctorStatus::Ok => (), + DoctorStatus::Warn => self.warnings += 1, + DoctorStatus::Fail => self.failures += 1, + } + + println!("[{status}] {}", message.as_ref()); + } +} + +#[cfg(unix)] +fn is_command_candidate(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + path.metadata() + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(all(not(unix), not(windows)))] +fn is_command_candidate(path: &Path) -> bool { + path.is_file() +} + +#[cfg(windows)] +fn is_command_candidate(path: &Path) -> bool { + path.is_file() +} + +fn command_path(command: &str) -> Option { + let path = std::env::var_os("PATH")?; + + #[cfg(not(windows))] + let candidates = [command.to_owned()]; + + #[cfg(windows)] + { + use std::ffi::OsString; + + let mut candidates = vec![command.to_owned()]; + let command = OsString::from(command); + let path_exts = std::env::var_os("PATHEXT") + .unwrap_or_default() + .to_string_lossy() + .split(';') + .filter(|ext| !ext.is_empty()) + .map(|ext| format!("{}{}", command.to_string_lossy(), ext)) + .collect::>(); + candidates.extend(path_exts); + std::env::split_paths(&path).find_map(|entry| { + candidates + .iter() + .map(|candidate| entry.join(candidate)) + .find(|candidate| is_command_candidate(candidate)) + }) + } + + #[cfg(not(windows))] + { + std::env::split_paths(&path).find_map(|entry| { + candidates + .iter() + .map(|candidate| entry.join(candidate)) + .find(|candidate| is_command_candidate(candidate)) + }) + } +} + +fn check_workspace_repo( + report: &mut DoctorReport, + workspace_root: &Path, + name: &str, +) -> Option { + let path = workspace_root.join(name); + match git::open_usable_repository(&path) { + Ok(repository) => { + report.record( + DoctorStatus::Ok, + format!( + "found usable workspace repo `{}` at `{}`", + name, + path.to_string_lossy() + ), + ); + Some(repository) + } + Err(_) if !path.exists() => { + report.record( + DoctorStatus::Fail, + format!( + "expected workspace repo `{}` at `{}`", + name, + path.to_string_lossy() + ), + ); + None + } + Err(error) => { + report.record( + DoctorStatus::Fail, + format!( + "expected `{}` to be a usable git repository at `{}`: {}", + name, + path.to_string_lossy(), + Report::from_error(error) + ), + ); + None + } + } +} + +fn check_sibling_manifest_id( + report: &mut DoctorReport, + sibling_path: &Path, + expected_repo_id: &str, +) { + let manifest_path = sibling_path.join(config::MANIFEST_FILE); + match Manifest::load(&manifest_path) { + Ok(manifest) if manifest.name == *expected_repo_id => report.record( + DoctorStatus::Ok, + format!("sibling `{expected_repo_id}` Build.toml name matches workspace key"), + ), + Ok(manifest) => report.record( + DoctorStatus::Fail, + format!( + "sibling `{expected_repo_id}` Build.toml declares name `{}`", + manifest.name + ), + ), + Err(config::Error::Io { source, .. }) + if matches!( + source.kind(), + ErrorKind::NotFound | ErrorKind::NotADirectory + ) => report.record( + DoctorStatus::Warn, + format!( + "sibling `{expected_repo_id}` has no Build.toml yet; this is a transitional rollout warning" + ), + ), + Err(error) => report.record( + DoctorStatus::Fail, + format!( + "sibling `{expected_repo_id}` Build.toml could not be loaded: {}", + Report::from_error(error) + ), + ), + } +} + +fn check_theme_zola_config(report: &mut DoctorReport, theme_path: &Path) { + let zola_config = theme_path.join("config").join("zola.toml"); + match std::fs::read_to_string(&zola_config) { + Ok(contents) => match toml::from_str::(&contents) { + Ok(_) => report.record( + DoctorStatus::Ok, + format!( + "workspace theme Zola config parses at `{}`", + zola_config.to_string_lossy() + ), + ), + Err(error) => report.record( + DoctorStatus::Fail, + format!( + "workspace theme Zola config is invalid at `{}`: {error}", + zola_config.to_string_lossy() + ), + ), + }, + Err(error) => report.record( + DoctorStatus::Fail, + format!( + "workspace theme Zola config could not be read at `{}`: {error}", + zola_config.to_string_lossy() + ), + ), + } +} + +fn check_theme_eipw_schema(report: &mut DoctorReport, theme_path: &Path) { + match crate::lint::eipw_schema_status(theme_path) { + Ok(()) => report.record( + DoctorStatus::Ok, + "workspace theme eipw config schema is compatible", + ), + Err(error) => report.record( + DoctorStatus::Fail, + format!("workspace theme eipw config is not usable: {error}"), + ), + } +} + +fn normalized_repository_path(path: &str) -> String { + path.trim_matches('/') + .strip_suffix(".git") + .unwrap_or(path.trim_matches('/')) + .to_owned() +} + +fn repository_identity(value: &str) -> Option<(String, String)> { + if let Ok(url) = Url::parse(value) { + return Some(( + url.host_str()?.to_ascii_lowercase(), + normalized_repository_path(url.path()), + )); + } + + let (user_host, path) = value.split_once(':')?; + let (_, host) = user_host.rsplit_once('@')?; + Some((host.to_ascii_lowercase(), normalized_repository_path(path))) +} + +fn theme_remote_matches_manifest(remote: &str, repository: &Url) -> bool { + remote == repository.as_str() + || matches!( + ( + repository_identity(remote), + repository_identity(repository.as_str()) + ), + (Some(remote), Some(manifest)) if remote == manifest + ) +} + +fn check_theme_remote( + report: &mut DoctorReport, + theme_repo: &git2::Repository, + theme_repository: &Url, +) { + match git::remote_urls(theme_repo) { + Ok(remotes) + if remotes + .iter() + .any(|remote| theme_remote_matches_manifest(remote, theme_repository)) => + { + report.record( + DoctorStatus::Ok, + "workspace theme has a remote matching active Build.toml theme.repository", + ); + } + Ok(remotes) => report.record( + DoctorStatus::Warn, + format!( + "workspace theme has no remote matching active Build.toml theme.repository `{theme_repository}` (checked {} configured remote(s))", + remotes.len() + ), + ), + Err(error) => report.record( + DoctorStatus::Warn, + format!("workspace theme remotes could not be inspected: {error}"), + ), + } +} + +fn check_theme_pin(report: &mut DoctorReport, theme_repo: &git2::Repository, manifest_pin: &str) { + let head = match git::head_commit_id(theme_repo) { + Ok(head) => head, + Err(error) => { + report.record( + DoctorStatus::Fail, + format!("workspace theme HEAD could not be resolved: {error}"), + ); + return; + } + }; + + match git::resolve_commit_id(theme_repo, manifest_pin) { + Ok(pin) if pin == head => report.record( + DoctorStatus::Ok, + format!("workspace theme HEAD matches active Build.toml pin `{manifest_pin}`"), + ), + Ok(pin) => report.record( + DoctorStatus::Warn, + format!( + "workspace theme HEAD `{head}` does not match active Build.toml pin `{manifest_pin}` (resolved locally as `{pin}`)" + ), + ), + Err(error) => report.record( + DoctorStatus::Warn, + format!( + "active Build.toml theme pin `{manifest_pin}` could not be resolved locally; doctor did not fetch it: {error}" + ), + ), + } +} + +fn check_theme_readiness( + report: &mut DoctorReport, + workspace_root: &Path, + theme_repository: &Url, + theme_pin: &str, +) { + let theme_path = workspace_root.join(config::DEFAULT_THEME_DIR); + let Some(theme_repo) = check_workspace_repo(report, workspace_root, config::DEFAULT_THEME_DIR) + else { + return; + }; + + check_theme_zola_config(report, &theme_path); + check_theme_eipw_schema(report, &theme_path); + check_theme_remote(report, &theme_repo, theme_repository); + check_theme_pin(report, &theme_repo, theme_pin); +} + +fn doctor_root(args: &Args) -> Result { + root(args) +} + +fn check_tool(report: &mut DoctorReport, command: &str, why: &str) -> bool { + match command_path(command) { + Some(path) => { + report.record( + DoctorStatus::Ok, + format!( + "found required tool `{}` at `{}`", + command, + path.to_string_lossy() + ), + ); + true + } + None => { + report.record( + DoctorStatus::Fail, + format!("missing required tool `{}`: {}", command, why), + ); + false + } + } +} + +#[cfg(windows)] +fn check_default_windows_build_eips_path(report: &mut DoctorReport) { + let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") else { + return; + }; + + let install_dir = PathBuf::from(local_app_data).join("build-eips").join("bin"); + let build_eips_path = install_dir.join("build-eips.exe"); + + if build_eips_path.is_file() { + report.record( + DoctorStatus::Warn, + format!( + "found build-eips at the default user-local install path `{}`, but `{}` is not on PATH", + build_eips_path.to_string_lossy(), + install_dir.to_string_lossy() + ), + ); + } +} + +#[cfg(not(windows))] +fn check_default_windows_build_eips_path(_report: &mut DoctorReport) {} + +#[cfg(not(windows))] +fn check_optional_download_tool(report: &mut DoctorReport) { + let curl = command_path("curl"); + let wget = command_path("wget"); + + record_optional_download_tool(report, curl.as_deref(), wget.as_deref()); +} + +#[cfg(not(windows))] +fn record_optional_download_tool( + report: &mut DoctorReport, + curl: Option<&Path>, + wget: Option<&Path>, +) { + match (curl, wget) { + (Some(path), _) => report.record( + DoctorStatus::Ok, + format!( + "found front-door download helper `curl` at `{}`", + path.to_string_lossy() + ), + ), + (None, Some(path)) => report.record( + DoctorStatus::Ok, + format!( + "found front-door download helper `wget` at `{}`", + path.to_string_lossy() + ), + ), + (None, None) => report.record( + DoctorStatus::Warn, + "missing both `curl` and `wget`; `scripts/dev-setup` will not be able to download a release binary", + ), + } +} + +#[cfg(not(windows))] +fn check_front_door_archive_tool(report: &mut DoctorReport) { + let tar = command_path("tar"); + record_front_door_archive_tool(report, tar.as_deref()); +} + +#[cfg(not(windows))] +fn record_front_door_archive_tool(report: &mut DoctorReport, tar: Option<&Path>) { + match tar { + Some(path) => report.record( + DoctorStatus::Ok, + format!( + "found front-door archive tool `tar` at `{}`", + path.to_string_lossy() + ), + ), + None => report.record( + DoctorStatus::Warn, + "missing `tar`; `scripts/dev-setup` will not be able to unpack the release binary", + ), + } +} + +#[cfg(not(windows))] +fn check_front_door_setup_tools(report: &mut DoctorReport) { + check_optional_download_tool(report); + check_front_door_archive_tool(report); +} + +#[cfg(windows)] +fn check_front_door_setup_tools(_report: &mut DoctorReport) {} + +fn collect_doctor_report(args: &Args, check_tools: bool) -> Result { + let context = WorkspaceCommandContext::load(args)?; + let mut report = DoctorReport::default(); + let (root_path, active_repo) = match doctor_root(args) { + Ok(root_path) => match ActiveRepo::load(&root_path) { + Ok(active_repo) => { + report.record( + DoctorStatus::Ok, + format!("loaded active repo Build.toml for `{}`", active_repo.title), + ); + report.record( + DoctorStatus::Ok, + format!( + "Build.toml parses at `{}`", + root_path.join(config::MANIFEST_FILE).to_string_lossy() + ), + ); + (Some(root_path), Some(active_repo)) + } + Err(error) => { + report.record( + DoctorStatus::Fail, + format!("active repo Build.toml could not be loaded: {error}"), + ); + (Some(root_path), None) + } + }, + Err(error) => { + report.record( + DoctorStatus::Fail, + format!( + "active repo root could not be resolved: {}", + Report::from_error(error) + ), + ); + (None, None) + } + }; + + match context.config_path.as_ref() { + Some(path) => report.record( + DoctorStatus::Ok, + format!( + "found workspace config candidate `{}`", + path.to_string_lossy() + ), + ), + None => report.record( + DoctorStatus::Fail, + format!( + "could not find `{}` while searching upward from `{}`", + config::LOCAL_CONFIG_FILE, + context.search_from.to_string_lossy() + ), + ), + } + + let parsed_config = context + .config_path + .as_deref() + .map(LoadedWorkspaceConfig::from_path) + .transpose(); + + match parsed_config { + Ok(Some(config)) => { + report.record( + DoctorStatus::Ok, + format!( + "workspace config parses at `{}`", + config.config_path().to_string_lossy() + ), + ); + + let workspace_root = config.workspace_root(); + if workspace_root.is_dir() { + report.record( + DoctorStatus::Ok, + format!( + "workspace root exists at `{}`", + workspace_root.to_string_lossy() + ), + ); + } else { + report.record( + DoctorStatus::Fail, + format!( + "workspace root is missing at `{}`", + workspace_root.to_string_lossy() + ), + ); + } + + if let (Some(root_path), Some(active_repo)) = (root_path.as_ref(), active_repo.as_ref()) + { + let expected_root = workspace_root.join(&active_repo.title); + if root_path == &expected_root { + report.record( + DoctorStatus::Ok, + format!( + "active repo `{}` is checked out at `{}`", + &active_repo.title, + expected_root.to_string_lossy() + ), + ); + } else { + report.record( + DoctorStatus::Fail, + format!( + "active repo `{}` should be checked out at `{}`, found `{}`", + &active_repo.title, + expected_root.to_string_lossy(), + root_path.to_string_lossy() + ), + ); + } + + check_workspace_repo(&mut report, workspace_root, &active_repo.title); + for sibling_id in &active_repo.sibling_ids { + let sibling_path = workspace_root.join(sibling_id); + if check_workspace_repo(&mut report, workspace_root, sibling_id).is_some() { + check_sibling_manifest_id(&mut report, &sibling_path, sibling_id); + } + } + + check_theme_readiness( + &mut report, + workspace_root, + &active_repo.theme.repository, + &active_repo.theme.commit, + ); + } else { + report.record( + DoctorStatus::Warn, + "workspace repo layout checks were skipped because active Build.toml was unavailable", + ); + } + } + Err(error) => { + report.record( + DoctorStatus::Fail, + format!( + "workspace config could not be parsed: {}", + Report::from_error(error) + ), + ); + } + Ok(None) => (), + } + + if check_tools { + if !check_tool( + &mut report, + "build-eips", + "workspace bootstrap and build-eips commands expect `build-eips` on PATH", + ) { + check_default_windows_build_eips_path(&mut report); + } + check_tool( + &mut report, + "git", + "workspace bootstrap and build-eips commands expect git to be available", + ); + check_tool( + &mut report, + "zola", + "build, check, and serve commands need a working zola binary", + ); + check_front_door_setup_tools(&mut report); + } + + Ok(report) +} + +pub(crate) fn doctor_workspace(args: &Args) -> Result<(), Whatever> { + let report = collect_doctor_report(args, true)?; + + if report.failures > 0 { + snafu::whatever!("doctor found {} failing check(s)", report.failures); + } + + Ok(()) +} + pub(crate) fn init_workspace( args: &Args, workspace_root: PathBuf, @@ -154,3 +787,4 @@ fn write_workspace_doc(workspace_root: &Path) -> Result<(), Whatever> { Ok(()) } + From e6ee6b6df784f75d2dfb6f57a43a1393749aaeed Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:12:16 -0400 Subject: [PATCH 5/9] Resolve workspace execution policy Resolve build, check, and serve source policy around local active sources, clean mode, remote siblings, build roots, and base URL precedence. Keep `--clean` and `--remote-siblings` as source controls and limit `--only` to supported local dirty modes. Route runtime commands through one resolved execution policy. --- src/cli.rs | 87 ++++++++++++++++++-- src/execution.rs | 206 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 3 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 src/execution.rs diff --git a/src/cli.rs b/src/cli.rs index 99b7216..95a50fe 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,8 +9,9 @@ use std::path::{Path, PathBuf}; use clap::{Parser, Subcommand}; +use url::Url; -use crate::{lint, print}; +use crate::print; /// Build script for Ethereum EIPs and ERCs. #[derive(Parser, Debug)] @@ -20,11 +21,33 @@ pub(crate) struct Args { #[clap(short = 'C')] pub(crate) root: Option, + /// Use the configured remote sibling content repositories + #[clap(long)] + pub(crate) remote_siblings: bool, + + /// Write build artifacts under BUILD_ROOT instead of the default location + #[clap(long)] + pub(crate) build_root: Option, + #[clap(subcommand)] pub(crate) operation: Operation, } -#[derive(Debug, Subcommand)] +#[derive(Debug, Clone, Default, PartialEq, Eq, clap::Args)] +pub(crate) struct BaseUrlCliArgs { + /// Override the rendered-site base URL for this command + #[arg(long, value_parser = clap::value_parser!(Url))] + pub(crate) base_url: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, clap::Args)] +pub(crate) struct CleanCliArgs { + /// Ignore tracked working-tree changes in the active repo + #[arg(long)] + pub(crate) clean: bool, +} + +#[derive(Debug, Clone, Subcommand)] pub(crate) enum Operation { /// Print various useful things, like available lints Print { @@ -35,13 +58,19 @@ pub(crate) enum Operation { /// Build the project and output HTML Build { #[command(flatten)] - eipw: lint::CmdArgs, + base_url: BaseUrlCliArgs, + + #[command(flatten)] + clean: CleanCliArgs, }, /// Build the project and launch a web server to preview it Serve { #[command(flatten)] - eipw: lint::CmdArgs, + base_url: BaseUrlCliArgs, + + #[command(flatten)] + clean: CleanCliArgs, }, /// Remove temporary and output files @@ -50,7 +79,7 @@ pub(crate) enum Operation { /// Analyze the repository and report errors, but don't build HTML files Check { #[command(flatten)] - eipw: lint::CmdArgs, + clean: CleanCliArgs, }, /// List files changed since the last commit common to both the local and upstream repositories @@ -76,6 +105,54 @@ pub(crate) enum Operation { Doctor, } +#[derive(Debug, Clone)] +pub(crate) enum RuntimeOperation { + Build, + Serve, + Clean, + Check, + Changed { all: bool, format: ChangedFormat }, +} + +impl Operation { + pub(crate) fn base_url_cli_args(&self) -> BaseUrlCliArgs { + match self { + Self::Build { base_url, .. } | Self::Serve { base_url, .. } => base_url.clone(), + _ => BaseUrlCliArgs::default(), + } + } + + pub(crate) fn clean_cli_args(&self) -> CleanCliArgs { + match self { + Self::Build { clean, .. } | Self::Serve { clean, .. } | Self::Check { clean } => clean.clone(), + _ => CleanCliArgs::default(), + } + } + + pub(crate) fn is_plain_site_command(&self) -> bool { + matches!(self, Self::Build { .. } | Self::Serve { .. } | Self::Check { .. }) + } + + pub(crate) fn runtime_operation(&self) -> Option { + match self { + Self::Print { .. } | Self::Init { .. } | Self::Doctor => None, + Self::Build { .. } => Some(RuntimeOperation::Build), + Self::Serve { .. } => Some(RuntimeOperation::Serve), + Self::Clean => Some(RuntimeOperation::Clean), + Self::Check { .. } => Some(RuntimeOperation::Check), + Self::Changed { all, format } => Some(RuntimeOperation::Changed { all: *all, format: format.clone() }), + } + } + + pub(crate) fn is_workspace_lifecycle_command(&self) -> bool { + matches!(self, Self::Init { .. } | Self::Doctor) + } + + pub(crate) fn is_print_command(&self) -> bool { + matches!(self, Self::Print { .. }) + } +} + #[derive(Debug, clap::ValueEnum, Clone, Default)] pub(crate) enum ChangedFormat { #[default] diff --git a/src/execution.rs b/src/execution.rs new file mode 100644 index 0000000..41c8059 --- /dev/null +++ b/src/execution.rs @@ -0,0 +1,206 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +//! Execution source and path resolution. + +use std::{ + path::{Path, PathBuf}, +}; + +use log::{debug, info}; +use snafu::{OptionExt, ResultExt, Whatever}; +use url::Url; + +use crate::{ + cli::{Args, Operation}, + config::{self, ActiveRepo, LoadedWorkspaceConfig, RepositoryUse}, + context::{resolve_input_path, root}, + git, + layout::BUILD_DIR, +}; + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedExecution { + pub(crate) root_path: PathBuf, + pub(crate) build_path: PathBuf, + pub(crate) repository_use: RepositoryUse, + pub(crate) source_materialization: git::SourceMaterialization, + pub(crate) base_url_override: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SelectedSource { + WorkspaceLocal, + Remote, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExecutionSettings { + pub(crate) build_root: Option, + pub(crate) allow_dirty: bool, + pub(crate) sibling: SelectedSource, +} + +fn has_execution_override_flags(args: &Args) -> bool { + args.remote_siblings || args.build_root.is_some() +} + +pub(crate) fn validate_non_execution_command_flags(args: &Args) -> Result<(), Whatever> { + if args.operation.is_workspace_lifecycle_command() && has_execution_override_flags(args) { + snafu::whatever!("execution override flags cannot be used with `init` or `doctor`"); + } + + if args.operation.is_print_command() && has_execution_override_flags(args) { + snafu::whatever!("execution override flags cannot be used with `print`"); + } + + Ok(()) +} + +fn remote_source_override(force_remote: bool) -> Option { + force_remote.then_some(SelectedSource::Remote) +} + +fn format_sibling_ids(sibling_ids: &[String]) -> String { + sibling_ids.join(", ") +} + +fn local_repo_url(path: &Path) -> Result { + Url::from_directory_path(path) + .ok() + .whatever_context("unable to convert local sibling repository path into a file URL") +} + +fn apply_sibling_sources( + repository_use: &mut RepositoryUse, + sibling_ids: &[String], + workspace_config: Option<&LoadedWorkspaceConfig>, + sibling: &SelectedSource, +) -> Result<(), Whatever> { + match sibling { + SelectedSource::Remote => Ok(()), + SelectedSource::WorkspaceLocal => { + if sibling_ids.is_empty() { + return Ok(()); + } + + let workspace_config = workspace_config.whatever_context( + "workspace-local sibling selection requires a workspace config", + )?; + let mut missing = Vec::new(); + let mut local_repositories = Vec::new(); + + for repo_id in sibling_ids { + let path = workspace_config.local_repo_path(repo_id); + if git::repository_available(&path) { + local_repositories.push((repo_id.clone(), local_repo_url(&path)?)); + } else { + missing.push(repo_id.clone()); + } + } + + if !missing.is_empty() { + snafu::whatever!( + "workspace-local sibling selection requires all declared sibling repos; missing or invalid sibling repo(s): {}", + format_sibling_ids(&missing) + ); + } + + for (repo_id, url) in local_repositories { + repository_use.other_repos.insert(repo_id, url); + } + + Ok(()) + } + } +} + +fn build_path( + root_path: &Path, + repository_use: &RepositoryUse, + workspace_config: Option<&LoadedWorkspaceConfig>, + build_root: Option<&Path>, +) -> PathBuf { + build_root + .map(Path::to_path_buf) + .or_else(|| { + workspace_config.map(|workspace_config| { + workspace_config.workspace_build_root(&repository_use.title) + }) + }) + .unwrap_or_else(|| root_path.join(BUILD_DIR)) +} + +fn resolve_base_url_override( + args: &Args, + workspace_config: Option<&LoadedWorkspaceConfig>, +) -> Result, Whatever> { + if let Some(base_url) = args.operation.base_url_cli_args().base_url { + return Ok(Some(base_url)); + } + + Ok(workspace_config.and_then(|config| config.site_settings().base_url.clone())) +} + +fn clean_active_source_requested(args: &Args) -> bool { + args.operation.clean_cli_args().clean || matches!(args.operation, Operation::Changed { .. }) +} + +pub(crate) fn resolve_execution(args: &Args) -> Result { + let root_path = root(args)?; + + if clean_active_source_requested(args) { + git::check_dirty(&root_path) + .whatever_context("clean active-source mode requires a clean active checkout")?; + } + + let active_repo = ActiveRepo::load(&root_path) + .whatever_context("unable to load active repository Build.toml")?; + let sibling_ids = active_repo.sibling_ids.clone(); + let workspace_config = LoadedWorkspaceConfig::discover(&root_path) + .whatever_context("unable to load workspace config")?; + + if let Some(workspace_config) = workspace_config.as_ref() { + debug!( + "using workspace config `{}`", + workspace_config.config_path().to_string_lossy() + ); + } + + let settings = resolve_execution_settings(args, &sibling_ids, workspace_config.as_ref())?; + let mut repository_use = active_repo.repository_use; + apply_sibling_sources( + &mut repository_use, + &sibling_ids, + workspace_config.as_ref(), + &settings.sibling, + )?; + + let build_path = build_path( + &root_path, + &repository_use, + workspace_config.as_ref(), + settings.build_root.as_deref(), + ); + let source_materialization = if settings.allow_dirty { + info!( + "dirty mode is enabled; tracked working-tree changes from the active content repo will be materialized into the build input" + ); + git::SourceMaterialization::Dirty + } else { + git::SourceMaterialization::Clean + }; + let base_url_override = resolve_base_url_override(args, workspace_config.as_ref())?; + + Ok(ResolvedExecution { + root_path, + build_path, + repository_use, + source_materialization, + base_url_override, + }) +} + diff --git a/src/main.rs b/src/main.rs index d35c73e..f48fc49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod changed; mod cli; mod config; mod context; +mod execution; mod find_root; mod git; mod github; From 07eec885134b53db4882738359b8901ce1a65231 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:13:14 -0400 Subject: [PATCH 6/9] Run Zola with workspace theme Run Zola with the editable `workspace/theme` checkout. Materialize tracked local theme files into prepared `themes/eips-theme`, load Zola and eipw configuration from that local checkout, and keep runtime commands independent of manifest network access. --- Cargo.toml | 2 - src/cache.rs | 84 ------------------------------------ src/execution.rs | 56 ++++++++++++++++++++++++ src/layout.rs | 20 ++++++++- src/lint.rs | 76 ++++----------------------------- src/main.rs | 1 - src/zola.rs | 108 ++++++++++++++++++++++++++--------------------- 7 files changed, 144 insertions(+), 203 deletions(-) delete mode 100644 src/cache.rs diff --git a/Cargo.toml b/Cargo.toml index 4529c21..2a48446 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,6 @@ depends = "$auto, libc6, libssl3, zlib1g, libgcc-s1, libgit2-1.5, git" [dependencies] chrono = "0.4.42" clap = { version = "4.5.53", features = ["cargo", "derive"] } -directories = "6.0.0" duct = "1.1.1" eipw-lint = { version = "0.10.0", features = [ "tokio", "schema-version" ] } eipw-snippets = "0.2.0" @@ -40,7 +39,6 @@ regex = "1.12.2" semver = {version = "1.0.27", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" -sha3 = "0.10.8" snafu = { version = "0.8.9", features = ["rust_1_81"] } tokio = { version = "1.48.0", features = ["fs", "rt", "macros"] } toml = "0.9.10" diff --git a/src/cache.rs b/src/cache.rs deleted file mode 100644 index 9c7eb2d..0000000 --- a/src/cache.rs +++ /dev/null @@ -1,84 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -use std::{io::ErrorKind, path::PathBuf, sync::Arc}; - -use directories::ProjectDirs; -use fslock::LockFile; -use log::{debug, info}; -use sha3::{Digest, Sha3_256}; -use snafu::{Backtrace, IntoError, OptionExt, Report, ResultExt, Snafu}; - -#[derive(Debug, Snafu)] -pub enum Error { - #[snafu(display("unable to discover application directories (unset $HOME?)"))] - Directories { backtrace: Backtrace }, - #[snafu(display("unable to access the path `{}`", path.to_string_lossy()))] - Fs { - path: PathBuf, - backtrace: Backtrace, - source: std::io::Error, - }, -} - -#[derive(Debug)] -struct Inner { - _lock: LockFile, - dir: PathBuf, -} - -#[derive(Debug, Clone)] -pub struct Cache(Arc); - -impl Cache { - pub fn open() -> Result { - debug!("opening local file cache"); - - let dirs = - ProjectDirs::from("org.ethereum", "eips", "eips-build").context(DirectoriesSnafu)?; - let cache_path = dirs.cache_dir(); - if let Err(e) = std::fs::create_dir_all(cache_path) { - debug!( - "got while creating cache directory: {}", - Report::from_error(e) - ); - } - - let lock_path = cache_path.join(".lock"); - let mut lock = LockFile::open(&lock_path).context(FsSnafu { path: &lock_path })?; - - let locked = lock - .try_lock_with_pid() - .context(FsSnafu { path: &lock_path })?; - - if !locked { - info!("waiting on cache directory..."); - lock.lock_with_pid().context(FsSnafu { path: &lock_path })?; - } - - Ok(Self(Arc::new(Inner { - _lock: lock, - dir: cache_path.into(), - }))) - } - - pub fn dir(&self, key: &str) -> Result { - let mut hasher = Sha3_256::new(); - hasher.update(key.as_bytes()); - let hash = hasher.finalize(); - let hash_text = format!("{:x}", hash); - let path = self.0.dir.join(hash_text); - - debug!("creating cache directory `{}`", path.to_string_lossy()); - match std::fs::create_dir(&path) { - Ok(()) => (), - Err(e) if e.kind() == ErrorKind::AlreadyExists => (), - Err(e) => return Err(FsSnafu { path }.into_error(e)), - } - - Ok(path) - } -} diff --git a/src/execution.rs b/src/execution.rs index 41c8059..f06b458 100644 --- a/src/execution.rs +++ b/src/execution.rs @@ -7,6 +7,7 @@ //! Execution source and path resolution. use std::{ + io::ErrorKind, path::{Path, PathBuf}, }; @@ -27,10 +28,19 @@ pub(crate) struct ResolvedExecution { pub(crate) root_path: PathBuf, pub(crate) build_path: PathBuf, pub(crate) repository_use: RepositoryUse, + pub(crate) theme_path: Option, pub(crate) source_materialization: git::SourceMaterialization, pub(crate) base_url_override: Option, } +impl ResolvedExecution { + pub(crate) fn theme_path(&self) -> Result<&Path, Whatever> { + self.theme_path + .as_deref() + .whatever_context("the selected command requires a resolved workspace-local theme") + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum SelectedSource { WorkspaceLocal, @@ -134,6 +144,49 @@ fn build_path( .unwrap_or_else(|| root_path.join(BUILD_DIR)) } +fn operation_requires_theme(operation: &Operation) -> bool { + matches!( + operation, + Operation::Build { .. } + | Operation::Serve { .. } + | Operation::Check { .. } + | Operation::Editorial { .. } + ) +} + +fn resolve_theme_path( + workspace_config: Option<&LoadedWorkspaceConfig>, + operation: &Operation, +) -> Result, Whatever> { + if !operation_requires_theme(operation) { + return Ok(None); + } + + let workspace_config = workspace_config.with_whatever_context(|| { + format!( + "the selected command requires a workspace config with a local theme, but no `{}` was found.\n\nRun:\n build-eips init \n\nThen retry from that workspace.", + config::LOCAL_CONFIG_FILE + ) + })?; + let theme_path = workspace_config.local_theme_path(); + + match std::fs::metadata(&theme_path) { + Ok(_) => Ok(Some(theme_path)), + Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => { + snafu::whatever!( + "workspace-local theme path `{}` does not exist.\n\nRun `build-eips init ` to bootstrap the workspace, or\nclone/update the theme repository at the configured path.", + theme_path.to_string_lossy() + ); + } + Err(error) => { + snafu::whatever!( + "unable to access workspace-local theme path `{}`: {error}", + theme_path.to_string_lossy() + ); + } + } +} + fn resolve_base_url_override( args: &Args, workspace_config: Option<&LoadedWorkspaceConfig>, @@ -171,6 +224,8 @@ pub(crate) fn resolve_execution(args: &Args) -> Result Result PathBuf { + build_path.join(OUTPUT_DIR) +} + +pub(crate) fn mounted_theme_path(project_path: &Path) -> PathBuf { + project_path.join("themes").join("eips-theme") +} + +pub(crate) fn theme_config_path(theme_path: &Path) -> PathBuf { + [theme_path, Path::new("config"), Path::new("zola.toml")] + .iter() + .collect() +} diff --git a/src/lint.rs b/src/lint.rs index 7d05cac..1d48054 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -11,7 +11,6 @@ use clap::ValueEnum; use log::debug; use semver::{Comparator, Op, VersionReq}; -use crate::cache::Cache; use crate::progress::ProgressIteratorExt; use eipw_lint::reporters::{AdditionalHelp, Count, Json, Reporter, Text}; @@ -51,11 +50,6 @@ pub enum Error { source: std::io::Error, }, #[snafu(transparent)] - Git { - #[snafu(backtrace)] - source: crate::git::Error, - }, - #[snafu(transparent)] SchemaVersion { #[snafu(backtrace)] source: SchemaVersionError, @@ -92,17 +86,9 @@ struct Config { eipw: eipw_lint::config::DefaultOptions, } -#[derive(Debug, clap::Args, Serialize, Deserialize)] +#[derive(Debug, Clone, clap::Args, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub struct CmdArgs { - /// Disable linting entirely - #[arg(long, exclusive(true))] - no_lint: bool, - - /// Restrict linting to specific files and/or directories (relative to project root) - #[clap(required(false))] - sources: Vec, - /// Lint output format #[clap(long, value_enum, default_value_t)] format: Format, @@ -276,37 +262,18 @@ pub fn eipw_schema_status(theme_path: &Path) -> Result<(), Error> { #[tokio::main(flavor = "current_thread")] pub async fn eipw( - theme_repo: &str, - theme_rev: &str, - cache: &Cache, - root_dir: &Path, + theme_path: &Path, repo_dir: &Path, - changed_paths: Vec, + sources: Vec, opts: CmdArgs, ) -> Result<(), Error> { - if opts.no_lint { - return Ok(()); - } - let mut stdout = std::io::stdout(); - let mut config_path = cache.repo(theme_repo, theme_rev)?; - - config_path.push("config"); - config_path.push("eipw.toml"); + eipw_schema_status(theme_path)?; + let config_path = eipw_config_path(theme_path); let toml_file = Toml::file_exact(&config_path); - let file_version = Figment::new() - .merge(&toml_file) - .extract::() - .context(ConfigSnafu)? - .schema_version; - - let application_version = DefaultOptions::::schema_version(); - - version_cmp(file_version, application_version)?; - let config: Config = Figment::new() .merge(DefaultOptions::::figment()) .merge(toml_file) @@ -320,36 +287,8 @@ pub async fn eipw( .await .context(FsSnafu { path: repo_dir })?; - let paths = if opts.sources.is_empty() { - changed_paths - } else { - let root_dir = tokio::fs::canonicalize(root_dir) - .await - .context(FsSnafu { path: root_dir })?; - let mut repo_relative_sources = Vec::with_capacity(opts.sources.len()); - for source in &opts.sources { - let root_relative_source = root_dir.join(source); - let full_source = tokio::fs::canonicalize(&root_relative_source) - .await - .context(FsSnafu { - path: root_relative_source, - })?; - - let relative_source = match full_source.strip_prefix(&root_dir) { - Ok(r) => r, - Err(e) => { - let err = std::io::Error::new(std::io::ErrorKind::NotFound, e); - return Err(FsSnafu { path: full_source }.into_error(err)); - } - }; - - repo_relative_sources.push(repo_dir.join(relative_source)); - } - - repo_relative_sources - }; - - let sources = collect_sources(paths).await?; + let sources: Vec<_> = sources.iter().map(|source| repo_dir.join(source)).collect(); + let sources = collect_sources(sources).await?; let reporter = match opts.format { Format::Json => EitherReporter::Json(Json::default()), @@ -414,3 +353,4 @@ pub async fn eipw( Ok(()) } + diff --git a/src/main.rs b/src/main.rs index f48fc49..6afa762 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,6 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -mod cache; mod changed; mod cli; mod config; diff --git a/src/zola.rs b/src/zola.rs index fc65638..4cb9329 100644 --- a/src/zola.rs +++ b/src/zola.rs @@ -15,7 +15,10 @@ use semver::Version; use snafu::{ensure, Backtrace, IntoError, Report, ResultExt, Snafu}; use url::Url; -use crate::{cache::Cache, git}; +use crate::{ + config::ServerBinding, + layout::{mounted_theme_path, theme_config_path}, +}; const MINIMUM_VERSION: Version = Version::new(0, 22, 1); @@ -38,8 +41,11 @@ fn symlink_dir(original: &Path, link: &Path) -> Result<(), std::io::Error> { } fn force_symlink_dir(original: &Path, link: &Path) -> Result<(), std::io::Error> { - match std::fs::remove_file(link) { - Ok(()) => (), + match std::fs::symlink_metadata(link) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + std::fs::remove_dir_all(link)?; + } + Ok(_) => std::fs::remove_file(link)?, Err(e) if e.kind() == ErrorKind::NotFound => (), Err(e) => return Err(e), } @@ -47,6 +53,19 @@ fn force_symlink_dir(original: &Path, link: &Path) -> Result<(), std::io::Error> symlink_dir(original, link) } +fn mount_theme(theme_dir: &Path, project_path: &Path) -> Result { + let mounted_theme_path = mounted_theme_path(project_path); + if theme_dir == mounted_theme_path { + return Ok(mounted_theme_path); + } + + if let Some(parent) = mounted_theme_path.parent() { + std::fs::create_dir_all(parent)?; + } + force_symlink_dir(theme_dir, &mounted_theme_path)?; + Ok(mounted_theme_path) +} + #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("could not find zola binary (requires at least version {MINIMUM_VERSION})"))] @@ -71,11 +90,6 @@ pub enum Error { backtrace: Backtrace, source: std::io::Error, }, - #[snafu(context(false))] - Git { - #[snafu(backtrace)] - source: git::Error, - }, } pub fn find_zola() -> Result<(), Error> { @@ -96,21 +110,14 @@ pub fn find_zola() -> Result<(), Error> { Ok(()) } -pub fn check( - theme_repo: &str, - theme_rev: &str, - cache: &Cache, - project_path: &Path, -) -> Result<(), Error> { +pub fn check(theme_dir: &Path, project_path: &Path) -> Result<(), Error> { let args = ["check", "--drafts", "--skip-external-links"]; - spawn_log(theme_repo, theme_rev, cache, project_path, args)?; + spawn_log(theme_dir, project_path, args)?; Ok(()) } pub fn build( - theme_repo: &str, - theme_rev: &str, - cache: &Cache, + theme_dir: &Path, project_path: &Path, output_path: &Path, base_url: &str, @@ -120,7 +127,7 @@ pub fn build( .map(OsString::from) .into_iter() .chain(std::iter::once(output_path.into())); - spawn_log(theme_repo, theme_rev, cache, project_path, args)?; + spawn_log(theme_dir, project_path, args)?; if let Ok(url) = Url::from_file_path(output_path) { info!("HTML output to: {}", url); } @@ -128,23 +135,44 @@ pub fn build( } pub fn serve( - theme_repo: &str, - theme_rev: &str, - cache: &Cache, + theme_dir: &Path, project_path: &Path, output_path: &Path, + server_binding: &ServerBinding, + base_url_override: Option<&Url>, ) -> Result<(), Error> { // TODO: Properly kill the child process when we receive ctrl-c. - warn!("live reloading is not implemented"); remove_output(output_path); - let args = ["serve", "--drafts", "-o"] - .map(OsString::from) - .into_iter() - .chain(std::iter::once(output_path.into())); - spawn_log(theme_repo, theme_rev, cache, project_path, args)?; + let args = serve_args(server_binding, output_path, base_url_override); + spawn_log(theme_dir, project_path, args)?; Ok(()) } +fn serve_args( + server_binding: &ServerBinding, + output_path: &Path, + base_url_override: Option<&Url>, +) -> Vec { + let mut args = ["serve", "--drafts", "--fast", "--force", "--interface"] + .map(OsString::from) + .to_vec(); + + args.push(OsString::from(server_binding.interface.to_string())); + args.push(OsString::from("--port")); + args.push(OsString::from(server_binding.port.to_string())); + + if let Some(base_url) = base_url_override { + args.extend([ + OsString::from("-u"), + OsString::from(base_url.as_str()), + OsString::from("--no-port-append"), + ]); + } + + args.extend([OsString::from("-o"), output_path.as_os_str().to_os_string()]); + args +} + fn remove_output(output_path: &Path) { if let Err(e) = std::fs::remove_dir_all(output_path) { debug!( @@ -154,13 +182,7 @@ fn remove_output(output_path: &Path) { } } -fn spawn_log( - theme_repo: &str, - theme_rev: &str, - cache: &Cache, - project_path: &Path, - args: U, -) -> Result<(), Error> +fn spawn_log(theme_dir: &Path, project_path: &Path, args: U) -> Result<(), Error> where U: IntoIterator, I: Into, @@ -173,18 +195,9 @@ where find_zola()?; - let theme_dir = cache.repo(theme_repo, theme_rev)?; - - let mut themes_dir = project_path.join("themes"); - if let Err(e) = std::fs::create_dir(&themes_dir) { - debug!("got while creating themes dir: {}", Report::from_error(e)); - } - themes_dir.push("eips-theme"); - force_symlink_dir(&theme_dir, &themes_dir).context(FsSnafu { path: &themes_dir })?; - - let config_path: PathBuf = [&theme_dir, Path::new("config"), Path::new("zola.toml")] - .iter() - .collect(); + let mounted_theme_path = + mount_theme(theme_dir, project_path).context(FsSnafu { path: theme_dir })?; + let config_path = theme_config_path(&mounted_theme_path); let prefix = [OsString::from("-c"), config_path.into()].into_iter(); let args = prefix.chain(args.into_iter().map(Into::into)); @@ -221,3 +234,4 @@ where Ok(()) } + From 1338d71f6fd8cde5803998c680c8ca72fbb42a46 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:13:43 -0400 Subject: [PATCH 7/9] Add prepared runtime build pipeline Add prepared runtime build pipeline. Materialize active and sibling sources according to resolved execution policy, preprocess merged proposal content, prepare the local theme mount, and invoke runtime checks and rendering from `pipeline.rs`. Keep serve watch and sync behavior in the serve runtime branch. --- src/main.rs | 1 + src/pipeline.rs | 118 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 src/pipeline.rs diff --git a/src/main.rs b/src/main.rs index 6afa762..9beefb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod github; mod layout; mod lint; mod markdown; +mod pipeline; mod print; mod progress; mod workspace; diff --git a/src/pipeline.rs b/src/pipeline.rs new file mode 100644 index 0000000..8f91835 --- /dev/null +++ b/src/pipeline.rs @@ -0,0 +1,118 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +//! Prepared Zola runtime pipeline. + +use std::path::{Path, PathBuf}; + +use snafu::{OptionExt, ResultExt, Whatever}; +use crate::{ + config::RepositoryUse, + execution::ResolvedExecution, + git, + layout::{mounted_theme_path, output_path, CONTENT_DIR, REPO_DIR}, + markdown, + zola, +}; + +fn prepare_theme_for_zola( + theme_path: PathBuf, + repo_path: &Path, +) -> Result { + let mounted_theme_dir = mounted_theme_path(repo_path); + git::materialize_working_tree(&theme_path, &mounted_theme_dir) + .whatever_context("unable to materialize workspace-local theme")?; + Ok(mounted_theme_dir) +} + +fn prepare_runtime_source( + root_path: &Path, + repo_path: &Path, + repository_use: &RepositoryUse, + source_materialization: git::SourceMaterialization, +) -> Result<(), Whatever> { + let source = git::Fresh::new( + root_path, + repo_path, + repository_use.clone(), + source_materialization, + ) + .whatever_context("initializing build repo")? + .clone_src() + .whatever_context("cloning source repo")?; + + source + .merge() + .whatever_context("unable to merge ERC/EIP repositories")?; + + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct Prepared { + repo_path: PathBuf, + output_path: PathBuf, + repository_use: RepositoryUse, + theme_path: PathBuf, + source_materialization: git::SourceMaterialization, +} + +impl Prepared { + pub(crate) fn prepare(resolved: ResolvedExecution) -> Result { + zola::find_zola().whatever_context("unable to find suitable zola binary")?; + + let ResolvedExecution { + root_path, + build_path, + repository_use, + theme_path, + source_materialization, + } = resolved; + let theme_path = + theme_path.whatever_context("Zola runtime requires a workspace-local theme path")?; + + let repo_path = build_path.join(REPO_DIR); + let content_path = repo_path.join(CONTENT_DIR); + let output_path = output_path(&build_path); + + prepare_runtime_source( + &root_path, + &repo_path, + &repository_use, + source_materialization, + )?; + + markdown::preprocess(&content_path, None) + .whatever_context("unable to preprocess markdown")?; + let theme_path = prepare_theme_for_zola(theme_path, &repo_path)?; + + Ok(Prepared { + repository_use, + theme_path, + repo_path, + output_path, + source_materialization, + }) + } + + pub(crate) fn build(self) -> Result<(), Whatever> { + let base_url = &self.repository_use.location.base_url; + zola::build( + &self.theme_path, + &self.repo_path, + &self.output_path, + base_url.as_str(), + ) + .whatever_context("zola build failed")?; + Ok(()) + } + + pub(crate) fn check(self) -> Result<(), Whatever> { + zola::check(&self.theme_path, &self.repo_path).whatever_context("zola check failed")?; + Ok(()) + } +} + From 273bc3cb5ee28aa0cfbc9957dd6ce65ea2f5d498 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:14:09 -0400 Subject: [PATCH 8/9] Add serve runtime Add server binding resolution and serve-only host/port flags for local Zola serve commands. Run Zola serve with the resolved server binding, optional base URL override, fast/force serve flags, and generated output directory. Add dirty serve watching for dirty active-repo paths and local theme changes. Clean mode disables active-repo sync but keeps theme sync. --- Cargo.toml | 1 + src/cli.rs | 26 ++- src/execution.rs | 28 ++- src/pipeline.rs | 68 ++++++- src/serve.rs | 494 +++++++++++++++++++++++++++++++++++++++++++++++ src/zola.rs | 184 ++++++++++++++++++ 6 files changed, 793 insertions(+), 8 deletions(-) create mode 100644 src/serve.rs diff --git a/Cargo.toml b/Cargo.toml index 2a48446..2f03e2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ indicatif = "0.18.3" indicatif-log-bridge = "0.2.3" lazy_static = "1.5.0" log = { version = "0.4.29", features = ["std"] } +notify = "6.1.1" pulldown-cmark = "0.13.0" pulldown-cmark-to-cmark = "22.0.0" regex = "1.12.2" diff --git a/src/cli.rs b/src/cli.rs index 95a50fe..5770b82 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,10 @@ //! Clap command surface and command helper methods. -use std::path::{Path, PathBuf}; +use std::{ + net::IpAddr, + path::{Path, PathBuf}, +}; use clap::{Parser, Subcommand}; use url::Url; @@ -33,6 +36,17 @@ pub(crate) struct Args { pub(crate) operation: Operation, } +#[derive(Debug, Clone, Default, PartialEq, Eq, clap::Args)] +pub(crate) struct ServerCliArgs { + /// IP interface for the local server to bind + #[arg(long)] + pub(crate) interface: Option, + + /// Port for the local server to bind + #[arg(long)] + pub(crate) port: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, clap::Args)] pub(crate) struct BaseUrlCliArgs { /// Override the rendered-site base URL for this command @@ -66,6 +80,9 @@ pub(crate) enum Operation { /// Build the project and launch a web server to preview it Serve { + #[command(flatten)] + server: ServerCliArgs, + #[command(flatten)] base_url: BaseUrlCliArgs, @@ -115,6 +132,13 @@ pub(crate) enum RuntimeOperation { } impl Operation { + pub(crate) fn server_cli_args(&self) -> ServerCliArgs { + match self { + Self::Serve { server, .. } => server.clone(), + _ => ServerCliArgs::default(), + } + } + pub(crate) fn base_url_cli_args(&self) -> BaseUrlCliArgs { match self { Self::Build { base_url, .. } | Self::Serve { base_url, .. } => base_url.clone(), diff --git a/src/execution.rs b/src/execution.rs index f06b458..5a0b8c6 100644 --- a/src/execution.rs +++ b/src/execution.rs @@ -16,8 +16,8 @@ use snafu::{OptionExt, ResultExt, Whatever}; use url::Url; use crate::{ - cli::{Args, Operation}, - config::{self, ActiveRepo, LoadedWorkspaceConfig, RepositoryUse}, + cli::{Args, Operation, ServerCliArgs}, + config::{self, ActiveRepo, LoadedWorkspaceConfig, RepositoryUse, ServerBinding}, context::{resolve_input_path, root}, git, layout::BUILD_DIR, @@ -30,6 +30,7 @@ pub(crate) struct ResolvedExecution { pub(crate) repository_use: RepositoryUse, pub(crate) theme_path: Option, pub(crate) source_materialization: git::SourceMaterialization, + pub(crate) server_binding: ServerBinding, pub(crate) base_url_override: Option, } @@ -187,6 +188,25 @@ fn resolve_theme_path( } } +fn resolve_server_binding( + workspace_config: Option<&LoadedWorkspaceConfig>, + server_cli: &ServerCliArgs, +) -> ServerBinding { + let mut binding = workspace_config + .map(|workspace_config| ServerBinding::from(workspace_config.server_settings())) + .unwrap_or_default(); + + if let Some(interface) = server_cli.interface { + binding.interface = interface; + } + + if let Some(port) = server_cli.port { + binding.port = port; + } + + binding +} + fn resolve_base_url_override( args: &Args, workspace_config: Option<&LoadedWorkspaceConfig>, @@ -256,6 +276,10 @@ pub(crate) fn resolve_execution(args: &Args) -> Result Result { +) -> Result<(PathBuf, LocalThemeServeSync), Whatever> { let mounted_theme_dir = mounted_theme_path(repo_path); git::materialize_working_tree(&theme_path, &mounted_theme_dir) .whatever_context("unable to materialize workspace-local theme")?; - Ok(mounted_theme_dir) + let theme_index_path = git::index_path(&theme_path) + .whatever_context("unable to resolve workspace-local theme Git index path")?; + + Ok(( + mounted_theme_dir.clone(), + LocalThemeServeSync { + theme_source_root: theme_path, + mounted_theme_dir, + theme_index_path, + }, + )) } fn prepare_runtime_source( @@ -57,7 +70,11 @@ pub(crate) struct Prepared { output_path: PathBuf, repository_use: RepositoryUse, theme_path: PathBuf, + local_theme_sync: Option, + source_root: PathBuf, source_materialization: git::SourceMaterialization, + server_binding: ServerBinding, + base_url_override: Option, } impl Prepared { @@ -70,6 +87,8 @@ impl Prepared { repository_use, theme_path, source_materialization, + server_binding, + base_url_override, } = resolved; let theme_path = theme_path.whatever_context("Zola runtime requires a workspace-local theme path")?; @@ -87,19 +106,26 @@ impl Prepared { markdown::preprocess(&content_path, None) .whatever_context("unable to preprocess markdown")?; - let theme_path = prepare_theme_for_zola(theme_path, &repo_path)?; + let (theme_path, local_theme_sync) = prepare_theme_for_zola(theme_path, &repo_path)?; Ok(Prepared { repository_use, theme_path, + local_theme_sync: Some(local_theme_sync), repo_path, output_path, + source_root: root_path, source_materialization, + server_binding, + base_url_override, }) } pub(crate) fn build(self) -> Result<(), Whatever> { - let base_url = &self.repository_use.location.base_url; + let base_url = self + .base_url_override + .as_ref() + .unwrap_or(&self.repository_use.location.base_url); zola::build( &self.theme_path, &self.repo_path, @@ -110,6 +136,38 @@ impl Prepared { Ok(()) } + pub(crate) fn serve(self) -> Result<(), Whatever> { + let sync_config = serve_sync_config( + self.source_materialization, + &self.source_root, + &self.repo_path, + self.local_theme_sync.clone(), + ); + let dirty_watcher = if sync_config.has_targets() { + Some( + DirtyServeWatcher::start(sync_config) + .whatever_context("unable to start dirty serve watcher")?, + ) + } else { + None + }; + + let result = zola::serve( + &self.theme_path, + &self.repo_path, + &self.output_path, + &self.server_binding, + self.base_url_override.as_ref(), + ) + .whatever_context("zola serve failed"); + + if let Some(dirty_watcher) = dirty_watcher { + dirty_watcher.stop(); + } + + result + } + pub(crate) fn check(self) -> Result<(), Whatever> { zola::check(&self.theme_path, &self.repo_path).whatever_context("zola check failed")?; Ok(()) diff --git a/src/serve.rs b/src/serve.rs new file mode 100644 index 0000000..cf347cb --- /dev/null +++ b/src/serve.rs @@ -0,0 +1,494 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +//! Dirty active-repo and local-theme serve synchronization. + +use std::{ + collections::BTreeSet, + ffi::OsStr, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, RecvTimeoutError}, + Arc, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use log::{debug, info, warn}; +use notify::{Event, RecursiveMode, Watcher}; +use snafu::{Report, ResultExt, Whatever}; + +use crate::{git, layout::CONTENT_DIR, markdown}; + +#[derive(Debug)] +pub(crate) struct DirtyServeWatcher { + stop: Arc, + thread: JoinHandle<()>, +} + +#[derive(Debug, Clone)] +struct ActiveRepoServeSync { + source_root: PathBuf, + build_repo_path: PathBuf, +} + +#[derive(Debug, Clone)] +pub(crate) struct LocalThemeServeSync { + pub(crate) theme_source_root: PathBuf, + pub(crate) mounted_theme_dir: PathBuf, + pub(crate) theme_index_path: PathBuf, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ServeSyncConfig { + active_repo: Option, + local_theme: Option, +} + +impl ServeSyncConfig { + pub(crate) fn has_targets(&self) -> bool { + self.active_repo.is_some() || self.local_theme.is_some() + } +} + +impl DirtyServeWatcher { + pub(crate) fn start(sync_config: ServeSyncConfig) -> Result { + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + let (ready_tx, ready_rx) = mpsc::channel(); + let thread = + thread::spawn(move || dirty_serve_sync_loop(sync_config, stop_thread, ready_tx)); + + match ready_rx + .recv() + .whatever_context("dirty serve watcher exited before initialization")? + { + Ok(()) => Ok(Self { stop, thread }), + Err(message) => { + stop.store(true, Ordering::Relaxed); + let _ = thread.join(); + snafu::whatever!("{message}"); + } + } + } + + pub(crate) fn stop(self) { + self.stop.store(true, Ordering::Relaxed); + let _ = self.thread.join(); + } +} + +fn path_is_watched_source_path(root_path: &Path, path: &Path) -> bool { + let Ok(relative_path) = path.strip_prefix(root_path) else { + return false; + }; + + relative_path + .components() + .next() + .map(|component| component.as_os_str() != OsStr::new(".git")) + .unwrap_or(false) +} + +fn event_has_watched_source_path(root_path: &Path, event: &Event) -> bool { + event + .paths + .iter() + .any(|path| path_is_watched_source_path(root_path, path)) +} + +fn index_lock_path(index_path: &Path) -> Option { + let file_name = index_path.file_name()?.to_string_lossy(); + Some(index_path.with_file_name(format!("{file_name}.lock"))) +} + +fn event_has_theme_index_path(index_path: &Path, event: &Event) -> bool { + let lock_path = index_lock_path(index_path); + event.paths.iter().any(|path| { + path == index_path + || lock_path + .as_ref() + .map(|lock_path| path == lock_path) + .unwrap_or(false) + }) +} + +fn sync_dirty_serve_state( + source_root: &Path, + build_repo_path: &Path, + previous_dirty_paths: &mut BTreeSet, +) -> Result<(), Whatever> { + let current_dirty_paths: BTreeSet<_> = git::working_tree_paths(source_root) + .whatever_context("unable to list tracked dirty paths for dirty serve")? + .into_iter() + .collect(); + + let affected_paths: BTreeSet<_> = previous_dirty_paths + .union(¤t_dirty_paths) + .cloned() + .collect(); + + if affected_paths.is_empty() { + *previous_dirty_paths = current_dirty_paths; + return Ok(()); + } + + git::sync_materialized_paths(source_root, build_repo_path, &affected_paths) + .whatever_context("unable to synchronize tracked paths into the materialized repo")?; + markdown::preprocess(&build_repo_path.join(CONTENT_DIR)) + .whatever_context("unable to preprocess synchronized markdown during dirty serve")?; + + info!( + "synchronized {} tracked path(s) into the materialized repo for dirty serve", + affected_paths.len() + ); + + *previous_dirty_paths = current_dirty_paths; + Ok(()) +} + +fn capture_active_dirty_paths(source_root: &Path) -> Result, Whatever> { + Ok(git::working_tree_paths(source_root) + .whatever_context("unable to list tracked dirty paths for dirty serve")? + .into_iter() + .collect()) +} + +fn sync_theme_serve_state( + theme_source_root: &Path, + mounted_theme_dir: &Path, + previous_dirty_paths: &mut BTreeSet, +) -> Result<(), Whatever> { + let current_dirty_paths: BTreeSet<_> = git::tracked_working_tree_paths(theme_source_root) + .whatever_context("unable to list tracked dirty paths for local theme serve")? + .into_iter() + .collect(); + + let affected_paths: BTreeSet<_> = previous_dirty_paths + .union(¤t_dirty_paths) + .cloned() + .collect(); + + if affected_paths.is_empty() { + *previous_dirty_paths = current_dirty_paths; + return Ok(()); + } + + git::sync_working_tree_paths(theme_source_root, mounted_theme_dir, &affected_paths) + .whatever_context("unable to synchronize tracked local theme paths")?; + + info!( + "synchronized {} tracked path(s) into the mounted local theme for serve", + affected_paths.len() + ); + + *previous_dirty_paths = current_dirty_paths; + Ok(()) +} + +fn watch_theme_index( + watcher: &mut notify::RecommendedWatcher, + theme_index_path: &Path, +) -> Result<(), String> { + let file_result = watcher.watch(theme_index_path, RecursiveMode::NonRecursive); + let Some(parent) = theme_index_path.parent() else { + return file_result.map_err(|file_error| { + format!( + "unable to watch local theme Git index `{}`: {file_error}", + theme_index_path.to_string_lossy() + ) + }); + }; + let parent_result = watcher.watch(parent, RecursiveMode::NonRecursive); + + match (file_result, parent_result) { + (Ok(()), Ok(())) => Ok(()), + (Ok(()), Err(parent_error)) => { + debug!( + "unable to watch local theme Git index parent `{}`: {parent_error}", + parent.to_string_lossy() + ); + Ok(()) + } + (Err(file_error), Ok(())) => { + debug!( + "using local theme Git index parent watch for `{}` after file watch failed: {file_error}", + theme_index_path.to_string_lossy() + ); + Ok(()) + } + (Err(file_error), Err(parent_error)) => Err(format!( + "unable to watch local theme Git index `{}`: {file_error}; fallback watch on `{}` also failed: {parent_error}", + theme_index_path.to_string_lossy(), + parent.to_string_lossy() + )), + } +} + +fn dirty_serve_sync_loop( + sync_config: ServeSyncConfig, + stop: Arc, + ready_tx: mpsc::Sender>, +) { + let (event_tx, event_rx) = mpsc::channel(); + let mut watcher = match notify::recommended_watcher(move |result| { + let _ = event_tx.send(result); + }) { + Ok(watcher) => watcher, + Err(error) => { + let _ = ready_tx.send(Err(format!("unable to start dirty serve watcher: {error}"))); + return; + } + }; + + if let Some(active_repo) = &sync_config.active_repo { + if let Err(error) = watcher.watch(&active_repo.source_root, RecursiveMode::Recursive) { + let _ = ready_tx.send(Err(format!( + "unable to watch `{}` for dirty serve changes: {error}", + active_repo.source_root.to_string_lossy() + ))); + return; + } + } + + if let Some(local_theme) = &sync_config.local_theme { + if let Err(error) = watcher.watch(&local_theme.theme_source_root, RecursiveMode::Recursive) + { + let _ = ready_tx.send(Err(format!( + "unable to watch local theme `{}` for serve changes: {error}", + local_theme.theme_source_root.to_string_lossy() + ))); + return; + } + + if let Err(message) = watch_theme_index(&mut watcher, &local_theme.theme_index_path) { + let _ = ready_tx.send(Err(message)); + return; + } + } + + let mut previous_active_dirty_paths: BTreeSet<_> = match &sync_config.active_repo { + Some(active_repo) => match capture_active_dirty_paths(&active_repo.source_root) { + Ok(paths) => paths, + Err(error) => { + let _ = ready_tx.send(Err(format!( + "unable to capture initial dirty serve state: {}", + Report::from_error(error) + ))); + return; + } + }, + None => BTreeSet::new(), + }; + + let mut previous_theme_dirty_paths: BTreeSet<_> = match &sync_config.local_theme { + Some(local_theme) => { + if let Err(error) = git::materialize_working_tree( + &local_theme.theme_source_root, + &local_theme.mounted_theme_dir, + ) { + let _ = ready_tx.send(Err(format!( + "unable to synchronize initial local theme state after watcher setup: {}", + Report::from_error(error) + ))); + return; + } + + match git::tracked_working_tree_paths(&local_theme.theme_source_root) { + Ok(paths) => paths.into_iter().collect(), + Err(error) => { + let _ = ready_tx.send(Err(format!( + "unable to capture initial local theme dirty state: {}", + Report::from_error(error) + ))); + return; + } + } + } + None => BTreeSet::new(), + }; + + if let Some(active_repo) = &sync_config.active_repo { + info!( + "watching `{}` for dirty serve changes", + active_repo.source_root.to_string_lossy() + ); + } + if let Some(local_theme) = &sync_config.local_theme { + info!( + "watching `{}` for local theme serve changes", + local_theme.theme_source_root.to_string_lossy() + ); + } + let _ = ready_tx.send(Ok(())); + + while !stop.load(Ordering::Relaxed) { + let first_event = match event_rx.recv_timeout(Duration::from_millis(250)) { + Ok(event) => Some(event), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => break, + }; + + let Some(first_event) = first_event else { + continue; + }; + + let mut saw_active_event = false; + let mut saw_theme_event = false; + + match first_event { + Ok(event) => { + if let Some(active_repo) = &sync_config.active_repo { + saw_active_event |= + event_has_watched_source_path(&active_repo.source_root, &event); + } + if let Some(local_theme) = &sync_config.local_theme { + saw_theme_event |= + event_has_watched_source_path(&local_theme.theme_source_root, &event) + || event_has_theme_index_path(&local_theme.theme_index_path, &event); + } + } + Err(error) => { + warn!("filesystem watcher error: {error}"); + } + } + + loop { + match event_rx.recv_timeout(Duration::from_millis(75)) { + Ok(Ok(event)) => { + if let Some(active_repo) = &sync_config.active_repo { + saw_active_event |= + event_has_watched_source_path(&active_repo.source_root, &event); + } + if let Some(local_theme) = &sync_config.local_theme { + saw_theme_event |= + event_has_watched_source_path(&local_theme.theme_source_root, &event) + || event_has_theme_index_path( + &local_theme.theme_index_path, + &event, + ); + } + } + Ok(Err(error)) => warn!("filesystem watcher error: {error}"), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => return, + } + } + + if saw_active_event { + if let Some(active_repo) = &sync_config.active_repo { + if let Err(error) = sync_dirty_serve_state( + &active_repo.source_root, + &active_repo.build_repo_path, + &mut previous_active_dirty_paths, + ) { + warn!( + "unable to synchronize dirty serve changes: {}", + Report::from_error(error) + ); + } + } + } + + if saw_theme_event { + if let Some(local_theme) = &sync_config.local_theme { + if let Err(error) = sync_theme_serve_state( + &local_theme.theme_source_root, + &local_theme.mounted_theme_dir, + &mut previous_theme_dirty_paths, + ) { + warn!( + "unable to synchronize local theme serve changes: {}", + Report::from_error(error) + ); + } + } + } + } +} + +pub(crate) fn serve_sync_config( + source_materialization: git::SourceMaterialization, + source_root: &Path, + repo_path: &Path, + local_theme_sync: Option, +) -> ServeSyncConfig { + ServeSyncConfig { + active_repo: (source_materialization == git::SourceMaterialization::Dirty).then(|| { + ActiveRepoServeSync { + source_root: source_root.to_path_buf(), + build_repo_path: repo_path.to_path_buf(), + } + }), + local_theme: local_theme_sync, + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use notify::{Event, EventKind}; + use tempfile::TempDir; + + use crate::git::SourceMaterialization; + + use super::{event_has_theme_index_path, serve_sync_config, LocalThemeServeSync}; + + fn fake_theme_sync(root: &Path) -> LocalThemeServeSync { + LocalThemeServeSync { + theme_source_root: root.join("theme"), + mounted_theme_dir: root.join("repo/themes/eips-theme"), + theme_index_path: root.join("theme/.git/index"), + } + } + + #[test] + fn local_theme_index_events_trigger_rescan() { + let index_path = PathBuf::from("/workspace/theme/.git/index"); + let index_event = Event::new(EventKind::Any).add_path(index_path.clone()); + let lock_event = + Event::new(EventKind::Any).add_path(PathBuf::from("/workspace/theme/.git/index.lock")); + let unrelated_event = + Event::new(EventKind::Any).add_path(PathBuf::from("/workspace/theme/.git/config")); + + assert!(event_has_theme_index_path(&index_path, &index_event)); + assert!(event_has_theme_index_path(&index_path, &lock_event)); + assert!(!event_has_theme_index_path(&index_path, &unrelated_event)); + } + + #[test] + fn local_serve_syncs_theme_and_dirty_active_repo() { + let temp = TempDir::new().unwrap(); + + let sync_config = serve_sync_config( + SourceMaterialization::Dirty, + &temp.path().join("Core"), + &temp.path().join(".local-build/Core/repo"), + Some(fake_theme_sync(temp.path())), + ); + + assert!(sync_config.active_repo.is_some()); + assert!(sync_config.local_theme.is_some()); + } + + #[test] + fn clean_local_serve_keeps_theme_sync_but_disables_active_repo_dirty_sync() { + let temp = TempDir::new().unwrap(); + + let sync_config = serve_sync_config( + SourceMaterialization::Clean, + &temp.path().join("Core"), + &temp.path().join(".local-build/Core/repo"), + Some(fake_theme_sync(temp.path())), + ); + + assert!(sync_config.active_repo.is_none()); + assert!(sync_config.local_theme.is_some()); + } +} diff --git a/src/zola.rs b/src/zola.rs index 4cb9329..ea8d821 100644 --- a/src/zola.rs +++ b/src/zola.rs @@ -235,3 +235,187 @@ where Ok(()) } +#[cfg(test)] +mod tests { + use std::{ + ffi::OsString, + fs, + path::{Path, PathBuf}, + process::{Command, ExitStatus}, + }; + + use crate::{ + config::ServerBinding, + layout::{mounted_theme_path, theme_config_path}, + }; + use tempfile::TempDir; + + use super::{find_zola, mount_theme, serve_args}; + + fn write_file(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, contents).unwrap(); + } + + fn zola_build_status(project_root: &Path) -> ExitStatus { + Command::new("zola") + .arg("build") + .arg("--drafts") + .current_dir(project_root) + .status() + .unwrap() + } + + #[test] + fn zola_rejects_missing_internal_links_but_accepts_external_links() { + if find_zola().is_err() { + eprintln!("skipping zola link behavior fixture because zola is not installed"); + return; + } + + let temp = TempDir::new().unwrap(); + let internal = temp.path().join("internal"); + write_file( + &internal, + "config.toml", + "base_url = \"https://example.test\"\n", + ); + write_file( + &internal, + "content/_index.md", + "+++\ntitle = \"Internal\"\n+++\n[Missing](@/missing.md)\n", + ); + let external = temp.path().join("external"); + write_file( + &external, + "config.toml", + "base_url = \"https://example.test\"\n", + ); + write_file( + &external, + "content/_index.md", + "+++\ntitle = \"External\"\n+++\n[External](https://eips.ethereum.org/EIPS/eip-1)\n", + ); + + assert!(!zola_build_status(&internal).success()); + assert!(zola_build_status(&external).success()); + } + + #[test] + fn serve_args_include_configured_interface_and_port() { + let server_binding = ServerBinding { + interface: "0.0.0.0".parse().unwrap(), + port: 8080, + }; + + assert_eq!( + serve_args(&server_binding, Path::new("/tmp/build-output"), None), + vec![ + OsString::from("serve"), + OsString::from("--drafts"), + OsString::from("--fast"), + OsString::from("--force"), + OsString::from("--interface"), + OsString::from("0.0.0.0"), + OsString::from("--port"), + OsString::from("8080"), + OsString::from("-o"), + OsString::from("/tmp/build-output"), + ] + ); + } + + #[test] + fn serve_args_include_base_url_override_when_present() { + let server_binding = ServerBinding { + interface: "127.0.0.1".parse().unwrap(), + port: 1111, + }; + let base_url = "http://127.0.0.1:1111".parse().unwrap(); + + assert_eq!( + serve_args( + &server_binding, + Path::new("/tmp/build-output"), + Some(&base_url) + ), + vec![ + OsString::from("serve"), + OsString::from("--drafts"), + OsString::from("--fast"), + OsString::from("--force"), + OsString::from("--interface"), + OsString::from("127.0.0.1"), + OsString::from("--port"), + OsString::from("1111"), + OsString::from("-u"), + OsString::from("http://127.0.0.1:1111/"), + OsString::from("--no-port-append"), + OsString::from("-o"), + OsString::from("/tmp/build-output"), + ] + ); + } + + #[test] + fn mounted_theme_paths_are_under_project_themes_directory() { + let project_path = PathBuf::from("/tmp/project"); + let mounted_theme = mounted_theme_path(&project_path); + + assert_eq!( + mounted_theme, + PathBuf::from("/tmp/project/themes/eips-theme") + ); + assert_eq!( + theme_config_path(&mounted_theme), + PathBuf::from("/tmp/project/themes/eips-theme/config/zola.toml") + ); + } + + #[test] + fn mount_theme_does_not_symlink_mounted_local_theme_onto_itself() { + let temp = TempDir::new().unwrap(); + let project_path = temp.path().join("repo"); + let mounted_theme = mounted_theme_path(&project_path); + fs::create_dir_all(mounted_theme.join("config")).unwrap(); + fs::write(mounted_theme.join("config/zola.toml"), "title = 'local'\n").unwrap(); + + let result = mount_theme(&mounted_theme, &project_path).unwrap(); + + assert_eq!(result, mounted_theme); + assert!(mounted_theme.join("config/zola.toml").is_file()); + assert!(!fs::symlink_metadata(&mounted_theme) + .unwrap() + .file_type() + .is_symlink()); + } + + #[cfg(target_family = "unix")] + #[test] + fn theme_mount_replaces_prior_real_mounted_theme_directory() { + let temp = TempDir::new().unwrap(); + let project_path = temp.path().join("repo"); + let source_theme = temp.path().join("source-theme"); + fs::create_dir_all(source_theme.join("config")).unwrap(); + fs::write(source_theme.join("config/zola.toml"), "title = 'source'\n").unwrap(); + + let mounted_theme = mounted_theme_path(&project_path); + fs::create_dir_all(&mounted_theme).unwrap(); + fs::write(mounted_theme.join("stale.txt"), "stale").unwrap(); + + let result = mount_theme(&source_theme, &project_path).unwrap(); + + assert_eq!(result, mounted_theme); + assert!(fs::symlink_metadata(&mounted_theme) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!( + fs::read_to_string(theme_config_path(&mounted_theme)).unwrap(), + "title = 'source'\n" + ); + } +} From 3107a1f5c176f55b7bf64983e9757a11bc552cd8 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Sun, 21 Jun 2026 17:14:27 -0400 Subject: [PATCH 9/9] Add static preview runtime Add build-eips preview for serving the existing resolved output directory without rebuilding or starting dirty sync. Reuse server binding resolution and preview-only host/port flags, and report missing output before binding the local server. Add a tiny_http static file server with safe path resolution, index-file fallback, basic content types, and preview path tests. --- Cargo.lock | 202 +++++++++++++++++++++++++++++-------- Cargo.toml | 1 + src/cli.rs | 10 +- src/main.rs | 1 + src/preview.rs | 263 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 435 insertions(+), 42 deletions(-) create mode 100644 src/preview.rs diff --git a/Cargo.lock b/Cargo.lock index aa9d0aa..ee2306e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "atomic" version = "0.6.1" @@ -185,6 +191,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -237,7 +249,6 @@ dependencies = [ "chrono", "citationberg", "clap", - "directories", "duct", "eipw-lint", "eipw-preamble", @@ -252,15 +263,16 @@ dependencies = [ "iref", "lazy_static", "log", + "notify", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", "semver", "serde", "serde_json", - "sha3", "snafu", "tempfile", + "tiny_http", "tokio", "toml 0.9.11+spec-1.1.0", "toml_datetime 0.7.5+spec-1.1.0", @@ -341,6 +353,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "ciborium" version = "0.2.2" @@ -467,6 +485,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crunchy" version = "0.2.4" @@ -533,22 +566,13 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys 0.5.0", -] - [[package]] name = "dirs" version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys 0.4.1", + "dirs-sys", ] [[package]] @@ -559,22 +583,10 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users 0.4.6", + "redox_users", "windows-sys 0.48.0", ] -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.5.2", - "windows-sys 0.61.2", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -805,6 +817,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -853,6 +876,15 @@ dependencies = [ "num", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fslock" version = "0.2.1" @@ -936,7 +968,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "libgit2-sys", "log", @@ -1051,6 +1083,12 @@ dependencies = [ "match_token", ] +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1221,6 +1259,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "iref" version = "3.2.2" @@ -1340,6 +1398,26 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1372,8 +1450,9 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", + "redox_syscall 0.7.4", ] [[package]] @@ -1501,6 +1580,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -1517,6 +1608,25 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.10.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num" version = "0.4.3" @@ -1686,7 +1796,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1846,7 +1956,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags", + "bitflags 2.10.0", "getopts", "memchr", "pulldown-cmark-escape", @@ -1920,29 +2030,27 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] -name = "redox_users" -version = "0.4.6" +name = "redox_syscall" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", + "bitflags 2.10.0", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 1.0.69", ] [[package]] @@ -2026,7 +2134,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -2104,7 +2212,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cssparser", "derive_more", "fxhash", @@ -2514,6 +2622,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 2f03e2f..4bbfb89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ semver = {version = "1.0.27", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.148" snafu = { version = "0.8.9", features = ["rust_1_81"] } +tiny_http = "0.12.0" tokio = { version = "1.48.0", features = ["fs", "rt", "macros"] } toml = "0.9.10" toml_datetime = { version = "0.7.5", features = ["serde"] } diff --git a/src/cli.rs b/src/cli.rs index 5770b82..7fb88c4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -78,6 +78,12 @@ pub(crate) enum Operation { clean: CleanCliArgs, }, + /// Serve the existing built output without rebuilding it + Preview { + #[command(flatten)] + server: ServerCliArgs, + }, + /// Build the project and launch a web server to preview it Serve { #[command(flatten)] @@ -129,12 +135,13 @@ pub(crate) enum RuntimeOperation { Clean, Check, Changed { all: bool, format: ChangedFormat }, + Preview, } impl Operation { pub(crate) fn server_cli_args(&self) -> ServerCliArgs { match self { - Self::Serve { server, .. } => server.clone(), + Self::Serve { server, .. } | Self::Preview { server } => server.clone(), _ => ServerCliArgs::default(), } } @@ -162,6 +169,7 @@ impl Operation { Self::Print { .. } | Self::Init { .. } | Self::Doctor => None, Self::Build { .. } => Some(RuntimeOperation::Build), Self::Serve { .. } => Some(RuntimeOperation::Serve), + Self::Preview { .. } => Some(RuntimeOperation::Preview), Self::Clean => Some(RuntimeOperation::Clean), Self::Check { .. } => Some(RuntimeOperation::Check), Self::Changed { all, format } => Some(RuntimeOperation::Changed { all: *all, format: format.clone() }), diff --git a/src/main.rs b/src/main.rs index 9beefb6..6431ccd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod layout; mod lint; mod markdown; mod pipeline; +mod preview; mod print; mod progress; mod workspace; diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 0000000..b57901c --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,263 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +use std::{ + fs::File, + io::ErrorKind, + path::{Component, Path, PathBuf}, +}; + +use log::info; +use snafu::{ResultExt, Whatever}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; + +use crate::config::ServerBinding; + +const INDEX_HTML: &str = "index.html"; + +pub fn serve(output_path: &Path, server_binding: &ServerBinding) -> Result<(), Whatever> { + if !output_path.is_dir() { + snafu::whatever!( + "preview output directory `{}` is missing; run `build-eips build` for this profile first", + output_path.to_string_lossy() + ); + } + + let server = match Server::http((server_binding.interface, server_binding.port)) { + Ok(server) => server, + Err(error) => { + snafu::whatever!("unable to bind preview server on {server_binding}: {error}") + } + }; + + info!( + "serving static preview from `{}` at http://{server_binding}/", + output_path.to_string_lossy() + ); + + for request in server.incoming_requests() { + handle_request(output_path, request)?; + } + + Ok(()) +} + +fn handle_request(output_path: &Path, request: Request) -> Result<(), Whatever> { + match *request.method() { + Method::Get | Method::Head => {} + _ => { + request + .respond(Response::empty(StatusCode(405))) + .whatever_context("unable to send preview method error response")?; + return Ok(()); + } + } + + let Some(paths) = resolve_request_paths(output_path, request.url()) else { + request + .respond(Response::empty(StatusCode(400))) + .whatever_context("unable to send preview bad request response")?; + return Ok(()); + }; + + let Some((path, file)) = open_preview_asset(paths)? else { + request + .respond(Response::empty(StatusCode(404))) + .whatever_context("unable to send preview not found response")?; + return Ok(()); + }; + + let response = if let Some(value) = content_type(&path) { + Response::from_file(file).with_header(content_type_header(value)) + } else { + Response::from_file(file) + }; + + request + .respond(response) + .with_whatever_context(|e| format!("unable to send preview response: {e}"))?; + + Ok(()) +} + +fn open_preview_asset(paths: Vec) -> Result, Whatever> { + for path in paths { + let file = match File::open(&path) { + Ok(file) => file, + Err(error) + if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => + { + continue; + } + Err(error) => { + snafu::whatever!( + "unable to open preview asset `{}`: {error}", + path.to_string_lossy() + ); + } + }; + + if !file + .metadata() + .with_whatever_context(|e| { + format!( + "unable to inspect preview asset `{}`: {e}", + path.to_string_lossy() + ) + })? + .is_file() + { + continue; + } + + return Ok(Some((path, file))); + } + + Ok(None) +} + +fn resolve_request_paths(output_path: &Path, url: &str) -> Option> { + let raw_path = url.split('?').next().unwrap_or("/"); + let mut resolved = output_path.to_path_buf(); + let mut saw_normal_component = false; + + for component in Path::new(raw_path.trim_start_matches('/')).components() { + match component { + Component::CurDir | Component::RootDir => {} + Component::Normal(component) => { + saw_normal_component = true; + resolved.push(component); + } + Component::ParentDir | Component::Prefix(_) => return None, + } + } + + if raw_path.ends_with('/') || !saw_normal_component { + return Some(vec![resolved.join(INDEX_HTML)]); + } + + if resolved.extension().is_none() { + return Some(vec![resolved.join(INDEX_HTML), resolved]); + } + + Some(vec![resolved]) +} + +fn content_type(path: &Path) -> Option<&'static str> { + match path.extension().and_then(|extension| extension.to_str()) { + Some("css") => Some("text/css; charset=utf-8"), + Some("gif") => Some("image/gif"), + Some("htm" | "html") => Some("text/html; charset=utf-8"), + Some("ico") => Some("image/x-icon"), + Some("jpeg" | "jpg") => Some("image/jpeg"), + Some("js") => Some("application/javascript; charset=utf-8"), + Some("json") => Some("application/json; charset=utf-8"), + Some("mjs") => Some("application/javascript; charset=utf-8"), + Some("png") => Some("image/png"), + Some("svg") => Some("image/svg+xml"), + Some("txt") => Some("text/plain; charset=utf-8"), + Some("webp") => Some("image/webp"), + Some("xml") => Some("application/xml; charset=utf-8"), + _ => None, + } +} + +fn content_type_header(value: &str) -> Header { + Header::from_bytes(b"Content-Type", value.as_bytes()) + .expect("hard-coded content-type headers must be valid") +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use tempfile::TempDir; + + use super::{open_preview_asset, resolve_request_paths, INDEX_HTML}; + + fn write_file(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); + } + + fn candidates(root: &Path, url: &str) -> Vec { + resolve_request_paths(root, url).unwrap() + } + + fn selected_path(root: &Path, url: &str) -> Option { + open_preview_asset(candidates(root, url)) + .unwrap() + .map(|(path, _file)| path) + } + + #[test] + fn slash_path_resolves_to_index_candidate() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + assert_eq!( + candidates(root, "/foo/"), + vec![root.join("foo").join(INDEX_HTML)] + ); + } + + #[test] + fn extensionless_path_prefers_index_then_file_candidate() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + assert_eq!( + candidates(root, "/foo"), + vec![root.join("foo").join(INDEX_HTML), root.join("foo")] + ); + } + + #[test] + fn extension_path_resolves_to_file_candidate() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + assert_eq!(candidates(root, "/foo.css"), vec![root.join("foo.css")]); + } + + #[test] + fn parent_traversal_is_rejected() { + let temp = TempDir::new().unwrap(); + + assert!(resolve_request_paths(temp.path(), "/../secret.txt").is_none()); + } + + #[test] + fn extensionless_path_serves_index_when_present() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write_file(root, "foo/index.html", "index"); + + assert_eq!( + selected_path(root, "/foo"), + Some(root.join("foo").join(INDEX_HTML)) + ); + } + + #[test] + fn extensionless_path_serves_file_without_index() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write_file(root, "foo", "file"); + + assert_eq!(selected_path(root, "/foo"), Some(root.join("foo"))); + } + + #[test] + fn missing_path_returns_no_asset() { + let temp = TempDir::new().unwrap(); + + assert!(selected_path(temp.path(), "/missing").is_none()); + } +}