From 509657a1eac7e2e2263fe631ac0503a718020599 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Tue, 14 Apr 2026 23:05:30 -0400 Subject: [PATCH 1/3] Add local workspace and site build support Add the local workspace model for the multi-repo build system. This adds workspace config and profile support, workspace init, refresh, and doctor commands, generated helper tasks, and the local site build path against sibling repos and theme inputs. The site commands support normal local usage, parity checks, standard clone and git worktree checkouts, and explicit dirty builds from the active content repo. --- src/config.rs | 385 +++++++++++++++++++- src/find_root.rs | 2 +- src/git.rs | 467 +++++++++++++++++++++--- src/lint.rs | 10 +- src/main.rs | 900 +++++++++++++++++++++++++++++++++++++++++++---- src/zola.rs | 29 +- 6 files changed, 1651 insertions(+), 142 deletions(-) diff --git a/src/config.rs b/src/config.rs index 26c4ad4..e806b0a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,11 +4,64 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use std::collections::HashMap; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; use serde::{Deserialize, Serialize}; +use snafu::{Backtrace, OptionExt, ResultExt, Snafu}; use url::Url; +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_PROFILE: &str = "workspace"; + +#[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(display("unable to parse workspace config `{}`", path.to_string_lossy()))] + Parse { + path: PathBuf, + source: toml::de::Error, + backtrace: Backtrace, + }, + + #[snafu(display("cannot use `--profile {profile}` without a workspace config"))] + ProfileWithoutConfig { + profile: String, + backtrace: Backtrace, + }, + + #[snafu(display( + "workspace config `{}` does not define profile `{profile}`", + path.to_string_lossy() + ))] + MissingProfile { + path: PathBuf, + profile: String, + backtrace: Backtrace, + }, + + #[snafu(display( + "workspace config profile `{profile}` sets incompatible values for `{local}` and `{remote}`" + ))] + ConflictingProfileSwitch { + path: PathBuf, + profile: String, + local: &'static str, + remote: &'static str, + backtrace: Backtrace, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Theme { /// Where to fetch the theme from. @@ -43,6 +96,81 @@ pub struct Config { pub locations: Locations, } +#[derive(Debug, Clone, Default)] +pub struct LocalOverrides { + pub theme_path: Option, + pub other_repo_path: Option, + pub build_root: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct WorkspaceConfig { + pub default_profile: Option, + pub build_root_base: PathBuf, + pub profiles: HashMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LocalProfile { + pub staging: bool, + pub use_local_theme: bool, + pub use_local_sibling: bool, + pub allow_dirty: bool, +} + +#[derive(Debug, Clone)] +pub struct LoadedWorkspaceConfig { + path: PathBuf, + workspace_root: PathBuf, + config: WorkspaceConfig, +} + +#[derive(Debug, Clone)] +pub struct SelectedProfile { + pub name: String, + pub profile: LocalProfile, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct RawLocalProfile { + staging: bool, + use_local_theme: Option, + use_local_sibling: Option, + use_remote_theme: Option, + use_remote_sibling: Option, + allow_dirty: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +struct RawWorkspaceConfig { + default_profile: Option, + build_root_base: PathBuf, + profiles: HashMap, +} + +impl Default for WorkspaceConfig { + fn default() -> Self { + Self { + default_profile: Some(DEFAULT_PROFILE.into()), + build_root_base: DEFAULT_BUILD_ROOT_BASE.into(), + profiles: HashMap::new(), + } + } +} + +impl Default for RawWorkspaceConfig { + fn default() -> Self { + Self { + default_profile: Some(DEFAULT_PROFILE.into()), + build_root_base: DEFAULT_BUILD_ROOT_BASE.into(), + profiles: HashMap::new(), + } + } +} + impl Config { pub fn production() -> Self { let mut locations = HashMap::new(); @@ -106,3 +234,258 @@ impl Config { } } } + +impl LoadedWorkspaceConfig { + pub fn load( + explicit: Option<&Path>, + search_from: &Path, + ) -> Result, WorkspaceError> { + match explicit { + Some(path) => Self::from_path(path).map(Some), + None => Self::discover(search_from), + } + } + + pub fn from_path(path: &Path) -> Result { + let path = path.canonicalize().context(FsSnafu { + path: path.to_path_buf(), + })?; + let contents = std::fs::read_to_string(&path).context(FsSnafu { path: &path })?; + let raw = + toml::from_str::(&contents).context(ParseSnafu { path: &path })?; + let workspace_root = path + .parent() + .expect("workspace config should always have a parent") + .to_path_buf(); + + let mut profiles = HashMap::with_capacity(raw.profiles.len()); + for (name, raw_profile) in raw.profiles { + let profile = LocalProfile::from_raw(&path, &name, raw_profile)?; + profiles.insert(name, profile); + } + + Ok(Self { + path, + workspace_root, + config: WorkspaceConfig { + default_profile: raw.default_profile, + build_root_base: raw.build_root_base, + profiles, + }, + }) + } + + pub fn discover(start: &Path) -> Result, WorkspaceError> { + match discover_path(start) { + Some(path) => Self::from_path(&path).map(Some), + None => Ok(None), + } + } + + pub fn selected_profile( + &self, + requested: Option<&str>, + ) -> Result, WorkspaceError> { + let name = requested + .map(str::to_owned) + .or_else(|| self.config.default_profile.clone()); + + let Some(name) = name else { + return Ok(None); + }; + + let profile = self + .config + .profiles + .get(&name) + .cloned() + .context(MissingProfileSnafu { + path: self.path.clone(), + profile: name.clone(), + })?; + + Ok(Some(SelectedProfile { name, profile })) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn workspace_root(&self) -> &Path { + &self.workspace_root + } + + pub fn build_root_for(&self, repo_name: &str) -> PathBuf { + self.resolve_path(&self.config.build_root_base) + .join(repo_name) + } + + 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) + } + + fn resolve_path(&self, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + self.workspace_root.join(path) + } + } +} + +pub fn discover_path(start: &Path) -> Option { + let mut current = Some(start); + + while let Some(candidate) = current { + let path = candidate.join(LOCAL_CONFIG_FILE); + if path.is_file() { + return Some(path); + } + + current = candidate.parent(); + } + + None +} + +impl LocalProfile { + fn from_raw(path: &Path, profile: &str, raw: RawLocalProfile) -> Result { + Ok(Self { + staging: raw.staging, + use_local_theme: resolve_profile_switch( + path, + profile, + raw.use_local_theme, + raw.use_remote_theme, + "use_local_theme", + "use_remote_theme", + )?, + use_local_sibling: resolve_profile_switch( + path, + profile, + raw.use_local_sibling, + raw.use_remote_sibling, + "use_local_sibling", + "use_remote_sibling", + )?, + allow_dirty: raw.allow_dirty, + }) + } +} + +fn resolve_profile_switch( + path: &Path, + profile: &str, + local: Option, + remote: Option, + local_name: &'static str, + remote_name: &'static str, +) -> Result { + match (local, remote) { + (Some(local), Some(remote)) if local == !remote => Ok(local), + (Some(_), Some(_)) => ConflictingProfileSwitchSnafu { + path: path.to_path_buf(), + profile: profile.to_owned(), + local: local_name, + remote: remote_name, + } + .fail(), + (Some(local), None) => Ok(local), + (None, Some(remote)) => Ok(!remote), + (None, None) => Ok(false), + } +} + +pub fn selected_profile( + config: Option<&LoadedWorkspaceConfig>, + requested: Option<&str>, +) -> Result, WorkspaceError> { + match config { + Some(config) => config.selected_profile(requested), + None => match requested { + Some(profile) => ProfileWithoutConfigSnafu { + profile: profile.to_owned(), + } + .fail(), + None => Ok(None), + }, + } +} + +pub fn default_workspace_config_text() -> &'static str { + r#"default_profile = "workspace" +build_root_base = ".local-build" + +[profiles.workspace] +staging = true +use_local_theme = true +use_local_sibling = true + +[profiles.parity] +staging = true +use_remote_theme = true +use_remote_sibling = true + +[profiles.dirty] +staging = true +use_local_theme = true +use_local_sibling = true +allow_dirty = true +"# +} + +#[cfg(test)] +mod tests { + use super::{default_workspace_config_text, LoadedWorkspaceConfig, LOCAL_CONFIG_FILE}; + + #[test] + fn parses_default_workspace_config() { + let dir = std::env::temp_dir().join(format!( + "build-eips-config-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(LOCAL_CONFIG_FILE); + std::fs::write(&path, default_workspace_config_text()).unwrap(); + + let config = LoadedWorkspaceConfig::from_path(&path).unwrap(); + let workspace = config + .selected_profile(Some("workspace")) + .unwrap() + .unwrap() + .profile; + let parity = config + .selected_profile(Some("parity")) + .unwrap() + .unwrap() + .profile; + let dirty = config + .selected_profile(Some("dirty")) + .unwrap() + .unwrap() + .profile; + + assert!(workspace.staging); + assert!(workspace.use_local_theme); + assert!(workspace.use_local_sibling); + + assert!(parity.staging); + assert!(!parity.use_local_theme); + assert!(!parity.use_local_sibling); + + assert!(dirty.staging); + assert!(dirty.use_local_theme); + assert!(dirty.use_local_sibling); + assert!(dirty.allow_dirty); + + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/find_root.rs b/src/find_root.rs index c2db1e0..46ed10e 100644 --- a/src/find_root.rs +++ b/src/find_root.rs @@ -34,7 +34,7 @@ pub enum Error { pub fn is_root(path: &Path) -> Result<(), Error> { let git = path.join(".git"); let contents = path.join(CONTENT_DIR); - if git.is_dir() && contents.is_dir() { + if (git.is_dir() || git.is_file()) && contents.is_dir() { Ok(()) } else { NoRootSnafu.fail() diff --git a/src/git.rs b/src/git.rs index 357743f..2394712 100644 --- a/src/git.rs +++ b/src/git.rs @@ -5,6 +5,7 @@ */ use std::{ + collections::BTreeSet, collections::HashMap, ffi::OsStr, path::{absolute, Path, PathBuf}, @@ -17,13 +18,15 @@ use crate::{ }; use git2::{ build::{CheckoutBuilder, TreeUpdateBuilder}, - BranchType, Commit, FetchOptions, FileMode, ObjectType, Oid, RepositoryOpenFlags, Signature, + Commit, FetchOptions, FileMode, ObjectType, Oid, RepositoryOpenFlags, 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; + #[derive(Debug, Snafu)] pub enum Error { #[snafu(display("cannot convert path into URL (`{}`)", path.to_string_lossy()))] @@ -47,8 +50,21 @@ pub enum Error { }, #[snafu(display("unable to determine which repository is being built (none match)"))] NoIdentify { backtrace: Backtrace }, - #[snafu(display("working tree or index has uncommitted modifications"))] - Dirty { backtrace: Backtrace }, + #[snafu(display("{message}"))] + Dirty { + message: String, + 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))] @@ -58,6 +74,12 @@ pub enum Error { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceMaterialization { + Clean, + Dirty, +} + #[derive(Debug, Clone)] pub struct RepositoryUse { pub title: String, @@ -117,25 +139,350 @@ impl Locations { } } +impl RepositoryUse { + pub fn only_other_repo(&self) -> Option<(&str, &Url)> { + let mut repos = self.other_repos.iter(); + let next = repos.next()?; + if repos.next().is_some() { + None + } else { + Some((next.0.as_str(), next.1)) + } + } +} + +fn is_generated_path(path: &Path) -> bool { + path.components() + .next() + .map(|component| component.as_os_str() == OsStr::new(super::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 clean/default 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( + "Use `--profile dirty` or `--allow-dirty` to include tracked local changes, and commit/stash/remove any untracked files first.", + )); + } else { + lines.push(String::from( + "Use `--profile dirty` or `--allow-dirty` to include tracked local changes, or commit/stash them first.", + )); + } + + lines.join("\n") +} + pub fn check_dirty(root_path: &Path) -> Result<(), Error> { + let (tracked_paths, untracked_count) = collect_dirty_paths(root_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) -> 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 mut statuses = statuses.iter().filter(|x| { - x.path() - .map(|x| !x.trim_end_matches('/').ends_with(super::BUILD_DIR)) - .unwrap_or(false) - }); - if statuses.next().is_some() { - DirtySnafu.fail() + 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 !is_generated_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| !is_generated_path(path)) + { + paths.insert(old_path.to_path_buf()); + } + if let Some(new_path) = delta + .new_file() + .path() + .filter(|path| !is_generated_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| !is_generated_path(path)) + { + paths.insert(old_path.to_path_buf()); + } + if let Some(new_path) = delta + .new_file() + .path() + .filter(|path| !is_generated_path(path)) + { + paths.insert(new_path.to_path_buf()); + } + } + + if !is_generated_path(&path) { + paths.insert(path); + } + } + + Ok((paths, untracked_count)) +} + +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 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).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).context(IoSnafu { + path: parent.to_path_buf(), + })?; + } + + remove_existing_path(&working_path).context(IoSnafu { + path: working_path.clone(), + })?; + + if metadata.file_type().is_symlink() { + copy_symlink(&source_path, &working_path).context(IoSnafu { + path: working_path.clone(), + })?; + } else { + std::fs::copy(&source_path, &working_path).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).context(IoSnafu { + path: working_path.clone(), + })?; + remove_index_path(index, relative_path)?; + Ok(()) + } + 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)?; + 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().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> { @@ -170,19 +517,29 @@ 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, } impl Fresh { - pub fn new(root_path: &Path, build_path: &Path, locations: &Locations) -> Result { + pub fn new( + root_path: &Path, + build_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_use = locations.identify_repository(&root_path)?; + if source_materialization == SourceMaterialization::Clean { + check_dirty(&root_path)?; + } let src_repo_url = Url::from_directory_path(&root_path) .ok() - .context(PathUrlSnafu { path: root_path })?; + .context(PathUrlSnafu { + path: root_path.clone(), + })?; debug!("source repository at `{src_repo_url}`"); @@ -190,14 +547,20 @@ impl Fresh { 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" })?; @@ -226,9 +589,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, @@ -250,7 +619,7 @@ impl SourceOnly { 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); @@ -378,11 +747,13 @@ impl SourceWithUpstream { 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", - )?; + // Local sibling overrides should follow the checked-out repo HEAD instead of assuming `master`. + let other_refspec = if other_repo.scheme() == "file" { + "HEAD:refs/build-eips/other-head" + } else { + "master:refs/build-eips/other-head" + }; + let master_other = fetch(&self.working_repo, other_repo.as_str(), other_refspec)?; let other_tree = master_other.tree().context(GitSnafu { what: "getting other tree", })?; @@ -479,16 +850,6 @@ impl SourceWithUpstream { .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", - })?; } Ok(()) @@ -501,7 +862,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", })?; { @@ -514,14 +887,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) } diff --git a/src/lint.rs b/src/lint.rs index a433b37..228de2c 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -11,8 +11,8 @@ use clap::ValueEnum; use log::debug; use semver::{Comparator, Op, VersionReq}; -use crate::cache::Cache; use crate::progress::ProgressIteratorExt; +use crate::{cache::Cache, ThemeSource}; use eipw_lint::reporters::{AdditionalHelp, Count, Json, Reporter, Text}; use eipw_lint::Linter; @@ -257,8 +257,7 @@ fn version_cmp( #[tokio::main(flavor = "current_thread")] pub async fn eipw( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, root_dir: &Path, repo_dir: &Path, @@ -271,7 +270,10 @@ pub async fn eipw( let mut stdout = std::io::stdout(); - let mut config_path = cache.repo(theme_repo, theme_rev)?; + let mut config_path = match theme { + ThemeSource::Remote { repository, commit } => cache.repo(repository, commit)?, + ThemeSource::Local { path } => path.to_path_buf(), + }; config_path.push("config"); config_path.push("eipw.toml"); diff --git a/src/main.rs b/src/main.rs index 6244467..3f14b19 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,14 +23,24 @@ use std::{ use clap::{Parser, Subcommand}; use fslock::LockFile; use log::{debug, info}; -use snafu::{Report, ResultExt, Whatever}; +use snafu::{OptionExt, Report, ResultExt, Whatever}; +use url::Url; -use crate::config::Config; +use crate::config::{Config, LoadedWorkspaceConfig, LocalOverrides}; const CONTENT_DIR: &str = "content"; const BUILD_DIR: &str = "build"; const REPO_DIR: &str = "repo"; const OUTPUT_DIR: &str = "output"; +const JUSTFILE_NAME: &str = "justfile"; +const PLATFORM_PREPROCESSOR_URL: &str = "https://github.com/eips-wg/preprocessor.git"; +const PLATFORM_EIPW_URL: &str = "https://github.com/ethereum/eipw.git"; + +#[derive(Debug, Clone)] +pub(crate) enum ThemeSource { + Remote { repository: String, commit: String }, + Local { path: PathBuf }, +} /// Build script for Ethereum EIPs and ERCs. #[derive(Parser, Debug)] @@ -44,6 +54,30 @@ struct Args { #[clap(long = "staging")] staging: bool, + /// Load workspace defaults from CONFIG instead of auto-discovering `.build-eips.toml` + #[clap(long)] + config: Option, + + /// Use the named local profile from `.build-eips.toml` + #[clap(long)] + profile: Option, + + /// Use a local theme checkout instead of the configured remote theme + #[clap(long)] + theme_path: Option, + + /// Use a local checkout for the sibling content repository + #[clap(long)] + other_repo_path: Option, + + /// Write build artifacts under BUILD_ROOT instead of the default location + #[clap(long)] + build_root: Option, + + /// Use tracked working-tree changes from the active content repo without requiring a commit + #[clap(long)] + allow_dirty: bool, + #[clap(subcommand)] operation: Operation, } @@ -85,6 +119,31 @@ enum Operation { #[clap(long, value_enum, default_value_t)] format: ChangedFormat, }, + + /// Manage local multi-repo workspace state + Workspace { + #[command(subcommand)] + command: WorkspaceCommand, + }, +} + +#[derive(Debug, Subcommand, Clone)] +enum WorkspaceCommand { + /// Create the local workspace config and clone any missing sibling repositories + Init { + /// Workspace root directory + path: PathBuf, + + /// Also clone preprocessor and eipw for platform development + #[arg(long)] + platform_dev: bool, + }, + + /// Regenerate generated workspace helper files + Refresh, + + /// Check whether the local workspace is ready for the local daily workflow + Doctor, } #[derive(Debug, clap::ValueEnum, Clone, Default)] @@ -95,6 +154,41 @@ enum ChangedFormat { Json, } +#[derive(Debug, Clone)] +struct ResolvedExecution { + root_path: PathBuf, + build_path: PathBuf, + repository_use: git::RepositoryUse, + theme: ThemeSource, + source_materialization: git::SourceMaterialization, +} + +#[derive(Debug, Clone, Copy)] +enum GeneratedFileState { + Created, + Updated, + Current, +} + +#[derive(Debug, Clone, Copy)] +enum DoctorStatus { + Ok, + Warn, + Fail, +} + +#[derive(Debug, Default)] +struct DoctorReport { + warnings: usize, + failures: usize, +} + +#[derive(Debug, Clone)] +struct WorkspaceCommandContext { + search_from: PathBuf, + config_path: Option, +} + impl ChangedFormat { fn print_sep(files: &[&Path], sep: &str) { let files: Vec<_> = files @@ -129,6 +223,38 @@ impl ChangedFormat { } } +impl GeneratedFileState { + fn verb(self) -> &'static str { + match self { + Self::Created => "generated", + Self::Updated => "refreshed", + Self::Current => "already current", + } + } +} + +impl DoctorStatus { + fn label(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warn => "warn", + Self::Fail => "fail", + } + } +} + +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.label(), message.as_ref()); + } +} + fn lock(build_path: &Path) -> Result { let lock_path = build_path.join(".lock"); let mut lock_file = @@ -145,33 +271,585 @@ fn lock(build_path: &Path) -> Result { Ok(lock_file) } +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)) + } +} + fn root(args: &Args) -> Result { let dir = match &args.root { None => find_root::find_root().whatever_context("cannot find repository root")?, - Some(p) => p.to_path_buf(), + Some(path) => { + find_root::is_root(path).whatever_context("invalid root directory")?; + path.canonicalize() + .whatever_context("unable to canonicalize root directory")? + } }; find_root::is_root(&dir).whatever_context("invalid root directory")?; Ok(dir) } -fn make_build_dir(root: &Path) -> Result { - let build_path = root.join(BUILD_DIR); - if let Err(e) = std::fs::create_dir_all(&build_path) { +fn workspace_search_start(args: &Args) -> Result { + match &args.root { + Some(path) => { + let path = resolve_input_path(path)?; + path.canonicalize() + .whatever_context("unable to canonicalize workspace search path") + } + None => std::env::current_dir().whatever_context("unable to get current directory"), + } +} + +fn load_workspace_command_context(args: &Args) -> Result { + let search_from = workspace_search_start(args)?; + let config_path = match args.config.as_deref() { + Some(path) => Some(resolve_input_path(path)?), + None => config::discover_path(&search_from), + }; + + Ok(WorkspaceCommandContext { + search_from, + config_path, + }) +} + +fn generated_justfile_text() -> &'static str { + r#"# Generated by `build-eips workspace refresh`. +default: + @just --list + +check: + build-eips -C "{{ invocation_directory() }}" check + +build: + build-eips -C "{{ invocation_directory() }}" build + +serve: + build-eips -C "{{ invocation_directory() }}" serve + +parity-check: + build-eips -C "{{ invocation_directory() }}" --profile parity check + +parity-build: + build-eips -C "{{ invocation_directory() }}" --profile parity build + +parity-serve: + build-eips -C "{{ invocation_directory() }}" --profile parity serve + +dirty-check: + build-eips -C "{{ invocation_directory() }}" --profile dirty check + +dirty-build: + build-eips -C "{{ invocation_directory() }}" --profile dirty build + +dirty-serve: + build-eips -C "{{ invocation_directory() }}" --profile dirty serve +"# +} + +fn sync_generated_file(path: &Path, contents: &str) -> Result { + match std::fs::read_to_string(path) { + Ok(existing) if existing == contents => Ok(GeneratedFileState::Current), + Ok(_) => { + std::fs::write(path, contents) + .whatever_context("unable to update generated workspace helper")?; + Ok(GeneratedFileState::Updated) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::write(path, contents) + .whatever_context("unable to write generated workspace helper")?; + Ok(GeneratedFileState::Created) + } + Err(error) => snafu::whatever!( + "unable to read generated workspace helper `{}`: {}", + path.to_string_lossy(), + Report::from_error(error) + ), + } +} + +fn refresh_workspace(args: &Args) -> Result<(), Whatever> { + let context = load_workspace_command_context(args)?; + let loaded_config = context + .config_path + .as_deref() + .map(LoadedWorkspaceConfig::from_path) + .transpose() + .whatever_context("unable to load workspace config")?; + let config = loaded_config + .as_ref() + .whatever_context("unable to find workspace config `.build-eips.toml`")?; + let justfile_path = config.workspace_root().join(JUSTFILE_NAME); + let state = sync_generated_file(&justfile_path, generated_justfile_text())?; + info!("{} `{}`", state.verb(), justfile_path.to_string_lossy()); + Ok(()) +} + +fn command_path(command: &str) -> Option { + let path = std::env::var_os("PATH")?; + + #[cfg(not(windows))] + let candidates = vec![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| candidate.is_file()) + }) + } + + #[cfg(not(windows))] + { + std::env::split_paths(&path).find_map(|entry| { + candidates + .iter() + .map(|candidate| entry.join(candidate)) + .find(|candidate| candidate.is_file()) + }) + } +} + +fn check_workspace_repo(report: &mut DoctorReport, workspace_root: &Path, name: &str) { + let path = workspace_root.join(name); + if !path.exists() { + report.record( + DoctorStatus::Fail, + format!( + "expected workspace repo `{}` at `{}`", + name, + path.to_string_lossy() + ), + ); + return; + } + + match git2::Repository::open(&path) { + Ok(_) => report.record( + DoctorStatus::Ok, + format!( + "found workspace repo `{}` at `{}`", + name, + path.to_string_lossy() + ), + ), + Err(_) => report.record( + DoctorStatus::Fail, + format!( + "expected `{}` to be a git repository at `{}`", + name, + path.to_string_lossy() + ), + ), + } +} + +fn check_tool(report: &mut DoctorReport, command: &str, why: &str) { + match command_path(command) { + Some(path) => report.record( + DoctorStatus::Ok, + format!( + "found required tool `{}` at `{}`", + command, + path.to_string_lossy() + ), + ), + None => report.record( + DoctorStatus::Fail, + format!("missing required tool `{}`: {}", command, why), + ), + } +} + +fn check_optional_download_tool(report: &mut DoctorReport) { + let curl = command_path("curl"); + let wget = command_path("wget"); + + 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", + ), + } +} + +fn doctor_workspace(args: &Args) -> Result<(), Whatever> { + let context = load_workspace_command_context(args)?; + let mut report = DoctorReport::default(); + + match context.config_path.as_ref() { + Some(path) if path.is_file() => report.record( + DoctorStatus::Ok, + format!( + "found workspace config candidate `{}`", + path.to_string_lossy() + ), + ), + Some(path) => report.record( + DoctorStatus::Fail, + format!("expected workspace config at `{}`", 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 = match context.config_path.as_deref() { + Some(path) if path.is_file() => Some(LoadedWorkspaceConfig::from_path(path)).transpose(), + Some(_) | None => Ok(None), + }; + + if let Ok(Some(config)) = parsed_config.as_ref() { + report.record( + DoctorStatus::Ok, + format!( + "workspace config parses at `{}`", + 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() + ), + ); + } + + for repo_name in ["EIPs", "ERCs", config::DEFAULT_THEME_DIR] { + check_workspace_repo(&mut report, workspace_root, repo_name); + } + + let justfile_path = workspace_root.join(JUSTFILE_NAME); + match std::fs::read_to_string(&justfile_path) { + Ok(existing) if existing == generated_justfile_text() => report.record( + DoctorStatus::Ok, + format!( + "generated helper `{}` is current", + justfile_path.to_string_lossy() + ), + ), + Ok(_) => report.record( + DoctorStatus::Fail, + format!( + "generated helper `{}` is stale; run `build-eips workspace refresh`", + justfile_path.to_string_lossy() + ), + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => report.record( + DoctorStatus::Fail, + format!( + "generated helper `{}` is missing; run `build-eips workspace refresh`", + justfile_path.to_string_lossy() + ), + ), + Err(error) => report.record( + DoctorStatus::Fail, + format!( + "unable to read generated helper `{}`: {}", + justfile_path.to_string_lossy(), + Report::from_error(error) + ), + ), + } + } else if let Err(error) = parsed_config { + report.record( + DoctorStatus::Fail, + format!( + "workspace config could not be parsed: {}", + Report::from_error(error) + ), + ); + report.record( + DoctorStatus::Warn, + "workspace layout checks were skipped because the workspace config could not be parsed", + ); + } else if context.config_path.is_some() { + report.record( + DoctorStatus::Fail, + "workspace config could not be parsed, so workspace layout checks were skipped", + ); + } else { + report.record( + DoctorStatus::Warn, + "workspace layout checks were skipped because no workspace config was found", + ); + } + + check_tool( + &mut report, + "build-eips", + "`just` recipes call `build-eips` directly, so install the release binary or put your dev build on PATH", + ); + check_tool( + &mut report, + "git", + "workspace init, refresh, and daily builds expect git to be available", + ); + check_tool( + &mut report, + "zola", + "daily build, check, and serve commands need a working zola binary", + ); + check_tool( + &mut report, + "just", + "local daily commands use the generated workspace justfile", + ); + check_optional_download_tool(&mut report); + + match command_path("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", + ), + } + + if report.failures > 0 { + snafu::whatever!( + "workspace doctor found {} failing check(s)", + report.failures + ); + } + + Ok(()) +} + +fn make_build_dir(build_path: &Path) -> Result { + if let Err(e) = std::fs::create_dir_all(build_path) { debug!( "got while creating build directory: {}", Report::from_error(e) ); } - Ok(build_path) + Ok(build_path.to_path_buf()) +} + +fn apply_local_other_repo( + repository_use: &mut git::RepositoryUse, + path: &Path, +) -> Result<(), Whatever> { + repository_use + .only_other_repo() + .whatever_context("local sibling overrides require exactly one sibling repository")?; + + let url = Url::from_directory_path(path) + .ok() + .whatever_context("unable to convert local sibling repository path into a file URL")?; + + for repository in repository_use.other_repos.values_mut() { + *repository = url.clone(); + } + + Ok(()) +} + +fn build_path( + root_path: &Path, + repository_use: &git::RepositoryUse, + workspace_config: Option<&LoadedWorkspaceConfig>, + overrides: &LocalOverrides, +) -> PathBuf { + overrides + .build_root + .clone() + .or_else(|| { + workspace_config + .map(|workspace_config| workspace_config.build_root_for(&repository_use.title)) + }) + .unwrap_or_else(|| root_path.join(BUILD_DIR)) +} + +fn theme_source( + baseline: &Config, + workspace_config: Option<&LoadedWorkspaceConfig>, + selected_profile: Option<&config::SelectedProfile>, + overrides: &LocalOverrides, +) -> ThemeSource { + let theme_path = + overrides + .theme_path + .clone() + .or_else(|| match (workspace_config, selected_profile) { + (Some(workspace_config), Some(profile)) if profile.profile.use_local_theme => { + Some(workspace_config.local_theme_path()) + } + _ => None, + }); + + match theme_path { + Some(path) => ThemeSource::Local { path }, + None => ThemeSource::Remote { + repository: baseline.theme.repository.to_string(), + commit: baseline.theme.commit.clone(), + }, + } +} + +fn resolve_execution(args: &Args) -> Result { + let root_path = root(args)?; + let workspace_config = LoadedWorkspaceConfig::load(args.config.as_deref(), &root_path) + .whatever_context("unable to load workspace config")?; + let selected_profile = + config::selected_profile(workspace_config.as_ref(), args.profile.as_deref()) + .whatever_context("unable to select workspace profile")?; + + if let Some(workspace_config) = workspace_config.as_ref() { + debug!( + "using workspace config `{}`", + workspace_config.path().to_string_lossy() + ); + } + + if let Some(profile) = selected_profile.as_ref() { + info!("using workspace profile `{}`", profile.name); + } + + let overrides = LocalOverrides { + theme_path: args + .theme_path + .as_deref() + .map(resolve_input_path) + .transpose()?, + other_repo_path: args + .other_repo_path + .as_deref() + .map(resolve_input_path) + .transpose()?, + build_root: args + .build_root + .as_deref() + .map(resolve_input_path) + .transpose()?, + }; + + let use_staging = args.staging + || selected_profile + .as_ref() + .map(|profile| profile.profile.staging) + .unwrap_or(false); + let allow_dirty = args.allow_dirty + || selected_profile + .as_ref() + .map(|profile| profile.profile.allow_dirty) + .unwrap_or(false); + let baseline = if use_staging { + Config::staging() + } else { + Config::production() + }; + + let mut repository_use = baseline + .locations + .identify_repository(&root_path) + .whatever_context("cannot identify repository use")?; + + let other_repo_path = overrides.other_repo_path.clone().or_else(|| { + match (workspace_config.as_ref(), selected_profile.as_ref()) { + (Some(workspace_config), Some(profile)) if profile.profile.use_local_sibling => { + let (other_name, _) = repository_use.only_other_repo()?; + Some(workspace_config.local_repo_path(other_name)) + } + _ => None, + } + }); + + if let Some(path) = other_repo_path { + apply_local_other_repo(&mut repository_use, &path)?; + } + + let build_path = build_path( + &root_path, + &repository_use, + workspace_config.as_ref(), + &overrides, + ); + let theme = theme_source( + &baseline, + workspace_config.as_ref(), + selected_profile.as_ref(), + &overrides, + ); + let source_materialization = if 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 + }; + + Ok(ResolvedExecution { + root_path, + build_path, + repository_use, + theme, + source_materialization, + }) } #[derive(Debug)] struct Prepared { cache: cache::Cache, - root_path: PathBuf, repo_path: PathBuf, output_path: PathBuf, - config: Config, + repository_use: git::RepositoryUse, + theme: ThemeSource, } impl Prepared { @@ -213,24 +891,32 @@ impl Prepared { p == OsStr::new("") } - fn prepare( - eipw: lint::CmdArgs, - config: Config, - root_path: PathBuf, - build_path: PathBuf, - ) -> Result { + fn prepare(eipw: lint::CmdArgs, resolved: ResolvedExecution) -> Result { zola::find_zola().whatever_context("unable to find suitable zola binary")?; + let ResolvedExecution { + root_path, + build_path, + repository_use, + theme, + source_materialization, + } = resolved; + let repo_path = build_path.join(REPO_DIR); let content_path = repo_path.join(CONTENT_DIR); let output_path = build_path.join(OUTPUT_DIR); - let both = git::Fresh::new(&root_path, &repo_path, &config.locations) - .whatever_context("initializing build repo")? - .clone_src() - .whatever_context("cloning source repo")? - .fetch_upstream() - .whatever_context("fetching upstream repo")?; + let both = git::Fresh::new( + &root_path, + &repo_path, + repository_use.clone(), + 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() @@ -245,22 +931,14 @@ impl Prepared { let cache = cache::Cache::open().whatever_context("unable to open cache")?; - lint::eipw( - config.theme.repository.as_str(), - &config.theme.commit, - &cache, - &root_path, - &repo_path, - changed_files, - eipw, - ) - .whatever_context("linting failed")?; + lint::eipw(&theme, &cache, &root_path, &repo_path, changed_files, eipw) + .whatever_context("linting failed")?; markdown::preprocess(&content_path).whatever_context("unable to preprocess markdown")?; Ok(Prepared { - config, - root_path, + repository_use, + theme, cache, repo_path, output_path, @@ -268,95 +946,163 @@ impl Prepared { } fn build(self) -> Result<(), Whatever> { - let repository_use = self - .config - .locations - .identify_repository(&self.root_path) - .whatever_context("cannot identify repository use")?; zola::build( - self.config.theme.repository.as_str(), - &self.config.theme.commit, + &self.theme, &self.cache, &self.repo_path, &self.output_path, - repository_use.location.base_url.as_str(), + self.repository_use.location.base_url.as_str(), ) .whatever_context("zola build failed")?; Ok(()) } fn serve(self) -> Result<(), Whatever> { - zola::serve( - self.config.theme.repository.as_str(), - &self.config.theme.commit, - &self.cache, - &self.repo_path, - &self.output_path, - ) - .whatever_context("zola serve failed")?; + zola::serve(&self.theme, &self.cache, &self.repo_path, &self.output_path) + .whatever_context("zola serve failed")?; Ok(()) } fn check(self) -> Result<(), Whatever> { - zola::check( - self.config.theme.repository.as_str(), - &self.config.theme.commit, - &self.cache, - &self.repo_path, - ) - .whatever_context("zola check failed")?; + zola::check(&self.theme, &self.cache, &self.repo_path) + .whatever_context("zola check failed")?; Ok(()) } } -fn run() -> Result<(), Whatever> { - let args = Args::parse(); - if let Operation::Print { print } = args.operation { - print::print(print); +fn clone_missing_repo(url: &str, destination: &Path) -> Result<(), Whatever> { + if destination.exists() { + git2::Repository::open(destination) + .whatever_context("expected existing workspace repo path to be a git repository")?; + info!( + "using existing workspace repo `{}`", + destination.to_string_lossy() + ); return Ok(()); } - let config = if args.staging { - Config::staging() + info!("cloning `{url}` into `{}`", destination.to_string_lossy()); + git2::Repository::clone(url, destination).whatever_context("unable to clone workspace repo")?; + Ok(()) +} + +fn init_workspace(args: &Args, path: PathBuf, platform_dev: bool) -> Result<(), Whatever> { + let root_path = root(args)?; + let workspace_root = resolve_input_path(&path)?; + 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")?; + + // Workspace init is a local-dev bootstrap path, so it intentionally uses staging URLs. + let workspace_config = Config::staging(); + let repository_use = workspace_config + .locations + .identify_repository(&root_path) + .whatever_context("cannot identify repository use")?; + + let expected_root = workspace_root.join(&repository_use.title); + if root_path != expected_root { + snafu::whatever!( + "workspace init expects the active repository at `{}`, found `{}`", + expected_root.to_string_lossy(), + root_path.to_string_lossy(), + ); + } + + let (other_name, other_url) = repository_use + .only_other_repo() + .whatever_context("workspace init requires exactly one sibling repository")?; + clone_missing_repo(other_url.as_str(), &workspace_root.join(other_name))?; + clone_missing_repo( + workspace_config.theme.repository.as_str(), + &workspace_root.join(config::DEFAULT_THEME_DIR), + )?; + + if platform_dev { + clone_missing_repo( + PLATFORM_PREPROCESSOR_URL, + &workspace_root.join("preprocessor"), + )?; + clone_missing_repo(PLATFORM_EIPW_URL, &workspace_root.join("eipw"))?; + } + + 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); + if config_path.exists() { + info!( + "leaving existing workspace config `{}` in place", + config_path.to_string_lossy() + ); } else { - Config::production() - }; + std::fs::write(&config_path, config::default_workspace_config_text()) + .whatever_context("unable to write workspace config")?; + } + + Ok(()) +} + +fn run() -> Result<(), Whatever> { + let args = Args::parse(); - let root_path = root(&args)?; - let build_path = make_build_dir(&root_path)?; + if let Operation::Print { print } = &args.operation { + print::print(print.clone()); + return Ok(()); + } + + if let Operation::Workspace { command } = &args.operation { + match command.clone() { + WorkspaceCommand::Init { path, platform_dev } => { + init_workspace(&args, path, platform_dev)? + } + WorkspaceCommand::Refresh => refresh_workspace(&args)?, + WorkspaceCommand::Doctor => doctor_workspace(&args)?, + } + return Ok(()); + } + let resolved = resolve_execution(&args)?; + let build_path = make_build_dir(&resolved.build_path)?; let mut lock_file = lock(&build_path)?; match args.operation { - Operation::Print { .. } => unreachable!(), + Operation::Print { .. } | Operation::Workspace { .. } => unreachable!(), Operation::Clean => { // TODO: There's a race condition here. Maybe we move the lockfile to the repository // root? lock_file .unlock() .whatever_context("unable to unlock build directory")?; - std::fs::remove_dir_all(build_path) + std::fs::remove_dir_all(&build_path) .whatever_context("unable to remove build directory")?; return Ok(()); } Operation::Check { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.check()?; + Prepared::prepare(eipw, resolved)?.check()?; } Operation::Build { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.build()?; + Prepared::prepare(eipw, resolved)?.build()?; } Operation::Serve { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.serve()?; + Prepared::prepare(eipw, resolved)?.serve()?; } Operation::Changed { all, format } => { let repo_path = build_path.join(REPO_DIR); - let both = git::Fresh::new(&root_path, &repo_path, &config.locations) - .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() diff --git a/src/zola.rs b/src/zola.rs index fc65638..ac164be 100644 --- a/src/zola.rs +++ b/src/zola.rs @@ -15,7 +15,7 @@ use semver::Version; use snafu::{ensure, Backtrace, IntoError, Report, ResultExt, Snafu}; use url::Url; -use crate::{cache::Cache, git}; +use crate::{cache::Cache, git, ThemeSource}; const MINIMUM_VERSION: Version = Version::new(0, 22, 1); @@ -96,20 +96,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: &ThemeSource, cache: &Cache, 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, cache, project_path, args)?; Ok(()) } pub fn build( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, project_path: &Path, output_path: &Path, @@ -120,7 +114,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, cache, project_path, args)?; if let Ok(url) = Url::from_file_path(output_path) { info!("HTML output to: {}", url); } @@ -128,8 +122,7 @@ pub fn build( } pub fn serve( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, project_path: &Path, output_path: &Path, @@ -141,7 +134,7 @@ pub fn serve( .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, cache, project_path, args)?; Ok(()) } @@ -155,8 +148,7 @@ fn remove_output(output_path: &Path) { } fn spawn_log( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, project_path: &Path, args: U, @@ -173,7 +165,10 @@ where find_zola()?; - let theme_dir = cache.repo(theme_repo, theme_rev)?; + let theme_dir = match theme { + ThemeSource::Remote { repository, commit } => cache.repo(repository, commit)?, + ThemeSource::Local { path } => path.to_path_buf(), + }; let mut themes_dir = project_path.join("themes"); if let Err(e) = std::fs::create_dir(&themes_dir) { From 13364453a3458e24ae8aef424268bdb333b59300 Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Tue, 14 Apr 2026 22:28:08 -0400 Subject: [PATCH 2/3] Add editorial commands and local serving Add explicit editorial commands and keep them separate from the site command surface. This adds targeted editorial selection, dirty live serving, and static preview for the local multi-repo workflow. --- Cargo.lock | 170 +++++++++++++- Cargo.toml | 2 + src/git.rs | 35 +++ src/lint.rs | 49 +---- src/main.rs | 571 +++++++++++++++++++++++++++++++++++++++++------- src/markdown.rs | 79 ++++++- src/preview.rs | 147 +++++++++++++ src/zola.rs | 3 +- 8 files changed, 926 insertions(+), 130 deletions(-) create mode 100644 src/preview.rs diff --git a/Cargo.lock b/Cargo.lock index dbca5a3..5e6b99a 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" @@ -252,6 +264,7 @@ dependencies = [ "iref", "lazy_static", "log", + "notify", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", @@ -260,6 +273,7 @@ dependencies = [ "serde_json", "sha3", "snafu", + "tiny_http", "tokio", "toml 0.9.11+spec-1.1.0", "toml_datetime 0.7.5+spec-1.1.0", @@ -340,6 +354,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" @@ -466,6 +486,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" @@ -798,6 +833,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" @@ -846,6 +892,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" @@ -929,7 +984,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", @@ -1044,6 +1099,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" @@ -1214,6 +1275,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" @@ -1333,6 +1414,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" @@ -1341,9 +1442,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libgit2-sys" @@ -1365,8 +1466,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]] @@ -1488,6 +1590,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" @@ -1504,6 +1618,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" @@ -1673,7 +1806,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1833,7 +1966,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", @@ -1907,7 +2040,16 @@ 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_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.10.0", ] [[package]] @@ -2078,7 +2220,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", @@ -2475,6 +2617,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 d87e3e7..6e34335 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,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" @@ -42,6 +43,7 @@ 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"] } +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/git.rs b/src/git.rs index 2394712..e8b8cfe 100644 --- a/src/git.rs +++ b/src/git.rs @@ -303,6 +303,41 @@ fn collect_dirty_paths(root_path: &Path) -> Result<(BTreeSet, usize), E Ok((paths, untracked_count)) } +pub fn working_tree_paths(root_path: &Path) -> Result, Error> { + let (paths, _) = collect_dirty_paths(root_path)?; + Ok(paths.into_iter().collect()) +} + +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().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() => { diff --git a/src/lint.rs b/src/lint.rs index 228de2c..cf05ee6 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -92,17 +92,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, @@ -259,15 +251,10 @@ fn version_cmp( pub async fn eipw( theme: &ThemeSource, cache: &Cache, - root_dir: &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 = match theme { @@ -303,36 +290,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()), diff --git a/src/main.rs b/src/main.rs index 3f14b19..a719247 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,18 +11,28 @@ mod git; mod github; mod lint; mod markdown; +mod preview; mod print; mod progress; mod zola; use std::{ + collections::BTreeSet, ffi::OsStr, path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{self, RecvTimeoutError}, + Arc, + }, + thread::{self, JoinHandle}, + time::Duration, }; use clap::{Parser, Subcommand}; use fslock::LockFile; -use log::{debug, info}; +use log::{debug, info, warn}; +use notify::{Event, RecursiveMode, Watcher}; use snafu::{OptionExt, Report, ResultExt, Whatever}; use url::Url; @@ -91,25 +101,19 @@ enum Operation { }, /// Build the project and output HTML - Build { - #[command(flatten)] - eipw: lint::CmdArgs, - }, + Build, /// Build the project and launch a web server to preview it - Serve { - #[command(flatten)] - eipw: lint::CmdArgs, - }, + Serve, + + /// Serve the existing built output without rebuilding it + Preview, /// Remove temporary and output files Clean, /// Analyze the repository and report errors, but don't build HTML files - Check { - #[command(flatten)] - eipw: lint::CmdArgs, - }, + Check, /// List files changed since the last commit common to both the local and upstream repositories Changed { @@ -120,6 +124,12 @@ enum Operation { format: ChangedFormat, }, + /// Run targeted editorial validation with eipw + Editorial { + #[command(subcommand)] + command: EditorialCommand, + }, + /// Manage local multi-repo workspace state Workspace { #[command(subcommand)] @@ -146,6 +156,46 @@ enum WorkspaceCommand { Doctor, } +#[derive(Debug, Subcommand, Clone)] +enum EditorialCommand { + /// Run eipw on explicitly selected proposal targets + Lint { + #[command(flatten)] + selectors: EditorialSelectorArgs, + + #[command(flatten)] + eipw: lint::CmdArgs, + }, + + /// Run targeted editorial validation, then the runtime check path + Build { + #[command(flatten)] + selectors: EditorialSelectorArgs, + + #[command(flatten)] + eipw: lint::CmdArgs, + }, +} + +#[derive(Debug, clap::Args, Clone)] +struct EditorialSelectorArgs { + /// Repo-relative proposal path(s), such as `content/07949.md` + #[arg(value_name = "PATH")] + paths: Vec, + + /// Read repo-relative proposal paths from BATCH, one per line + #[arg(long)] + batch: Option, + + /// Select tracked dirty proposal files from the active content repo + #[arg(long)] + working_tree: bool, + + /// Select proposal files changed versus the upstream merge-base + #[arg(long)] + against_upstream: bool, +} + #[derive(Debug, clap::ValueEnum, Clone, Default)] enum ChangedFormat { #[default] @@ -255,6 +305,15 @@ impl DoctorReport { } } +impl EditorialSelectorArgs { + fn selector_count(&self) -> usize { + usize::from(!self.paths.is_empty()) + + usize::from(self.batch.is_some()) + + usize::from(self.working_tree) + + usize::from(self.against_upstream) + } +} + fn lock(build_path: &Path) -> Result { let lock_path = build_path.join(".lock"); let mut lock_file = @@ -331,6 +390,9 @@ build: serve: build-eips -C "{{ invocation_directory() }}" serve +preview: + build-eips -C "{{ invocation_directory() }}" preview + parity-check: build-eips -C "{{ invocation_directory() }}" --profile parity check @@ -340,14 +402,23 @@ parity-build: parity-serve: build-eips -C "{{ invocation_directory() }}" --profile parity serve -dirty-check: - build-eips -C "{{ invocation_directory() }}" --profile dirty check +parity-preview: + build-eips -C "{{ invocation_directory() }}" --profile parity preview dirty-build: build-eips -C "{{ invocation_directory() }}" --profile dirty build dirty-serve: build-eips -C "{{ invocation_directory() }}" --profile dirty serve + +dirty-preview: + build-eips -C "{{ invocation_directory() }}" --profile dirty preview + +editorial-lint: + build-eips -C "{{ invocation_directory() }}" editorial lint --working-tree + +editorial-build: + build-eips -C "{{ invocation_directory() }}" editorial build --working-tree "# } @@ -715,6 +786,10 @@ fn build_path( .unwrap_or_else(|| root_path.join(BUILD_DIR)) } +fn output_path(build_path: &Path) -> PathBuf { + build_path.join(OUTPUT_DIR) +} + fn theme_source( baseline: &Config, workspace_config: Option<&LoadedWorkspaceConfig>, @@ -843,55 +918,353 @@ fn resolve_execution(args: &Args) -> Result { }) } -#[derive(Debug)] -struct Prepared { - cache: cache::Cache, - repo_path: PathBuf, - output_path: PathBuf, - repository_use: git::RepositoryUse, - theme: ThemeSource, +fn is_proposal_path(path: &Path) -> bool { + let mut path = path.to_path_buf(); + + match path.file_name() { + Some(name) if name == "index.md" => { + path.pop(); + } + Some(_) + if path + .extension() + .map(|extension| extension == "md") + .unwrap_or(false) => + { + path.set_extension(""); + } + None | Some(_) => return false, + } + + match path.file_name().and_then(OsStr::to_str) { + None => return false, + Some(name) if name.parse::().is_err() => return false, + Some(_) => { + path.pop(); + } + } + + match path.file_name() { + Some(name) if name == CONTENT_DIR => { + path.pop(); + } + _ => return false, + } + + path == OsStr::new("") } -impl Prepared { - fn is_proposal_path(p: PathBuf) -> bool { - // Only lint `content/00001.md` and `content/00001/index.md` files. - let mut p = p.to_path_buf(); - - // 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(""); +fn repo_relative_path(root_path: &Path, path: &Path) -> Result { + if path.is_absolute() { + snafu::whatever!( + "editorial selectors require repo-relative proposal paths, got `{}`", + path.to_string_lossy() + ); + } + + let full_path = root_path.join(path); + let canonical = full_path.canonicalize().whatever_context(format!( + "unable to resolve editorial target `{}`", + full_path.to_string_lossy() + ))?; + + let relative = canonical + .strip_prefix(root_path) + .whatever_context(format!( + "editorial target `{}` escapes the active repository root", + path.to_string_lossy() + ))? + .to_path_buf(); + + Ok(relative) +} + +fn validate_editorial_targets( + root_path: &Path, + paths: Vec, + strict: bool, +) -> Result, Whatever> { + let mut unique = BTreeSet::new(); + let mut targets = Vec::new(); + + for path in paths { + if path.is_absolute() { + snafu::whatever!( + "editorial selectors require repo-relative proposal paths, got `{}`", + path.to_string_lossy() + ); + } + + if !strict && !root_path.join(&path).exists() { + continue; + } + + let relative = repo_relative_path(root_path, &path)?; + + if !is_proposal_path(&relative) { + if strict { + snafu::whatever!( + "editorial target `{}` is not a supported proposal path", + relative.to_string_lossy() + ); } - None | Some(_) => return false, + continue; } - // 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(); + if unique.insert(relative.clone()) { + targets.push(relative); + } + } + + if strict && targets.is_empty() { + snafu::whatever!("editorial selector resolved no proposal files"); + } + + Ok(targets) +} + +fn read_editorial_batch(path: &Path) -> Result, Whatever> { + let contents = + std::fs::read_to_string(path).whatever_context("unable to read editorial batch file")?; + let mut paths = Vec::new(); + + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + paths.push(PathBuf::from(line)); + } + + Ok(paths) +} + +fn editorial_targets( + selectors: &EditorialSelectorArgs, + resolved: &ResolvedExecution, +) -> Result, Whatever> { + if selectors.selector_count() != 1 { + snafu::whatever!( + "choose exactly one editorial selector: explicit proposal paths, `--batch`, `--working-tree`, or `--against-upstream`" + ); + } + + let raw_targets = if !selectors.paths.is_empty() { + selectors.paths.clone() + } else if let Some(batch) = selectors.batch.as_deref() { + let batch = resolve_input_path(batch)?; + read_editorial_batch(&batch)? + } else if selectors.working_tree { + git::working_tree_paths(&resolved.root_path) + .whatever_context("unable to resolve working-tree editorial targets")? + } else { + let repo_path = resolved.build_path.join(REPO_DIR); + git::Fresh::new( + &resolved.root_path, + &repo_path, + resolved.repository_use.clone(), + resolved.source_materialization, + ) + .whatever_context("initializing build repo for editorial target selection")? + .clone_src() + .whatever_context("cloning source repo for editorial target selection")? + .fetch_upstream() + .whatever_context("fetching upstream repo for editorial target selection")? + .changed_files() + .whatever_context("unable to list editorial targets against upstream")? + }; + + let strict = !selectors.paths.is_empty() || selectors.batch.is_some(); + validate_editorial_targets(&resolved.root_path, raw_targets, strict) +} + +#[derive(Debug)] +struct DirtyServeWatcher { + stop: Arc, + thread: JoinHandle<()>, +} + +impl DirtyServeWatcher { + fn start(source_root: PathBuf, build_repo_path: PathBuf) -> 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(source_root, build_repo_path, 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}"); } } + } + + 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; + }; - // content - // ^^^^^^^ - match p.file_name() { - Some(f) if f == "content" => { - p.pop(); + 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 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_paths(&build_repo_path.join(CONTENT_DIR), &affected_paths) + .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 dirty_serve_sync_loop( + source_root: PathBuf, + build_repo_path: PathBuf, + 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 Err(error) = watcher.watch(&source_root, RecursiveMode::Recursive) { + let _ = ready_tx.send(Err(format!( + "unable to watch `{}` for dirty serve changes: {error}", + source_root.to_string_lossy() + ))); + return; + } + + let mut previous_dirty_paths: BTreeSet<_> = match git::working_tree_paths(&source_root) { + Ok(paths) => paths.into_iter().collect(), + Err(error) => { + let _ = ready_tx.send(Err(format!( + "unable to capture initial dirty serve state: {}", + Report::from_error(error) + ))); + return; + } + }; + + info!( + "watching `{}` for dirty serve changes", + 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_relevant_event = match first_event { + Ok(event) => event_has_watched_source_path(&source_root, &event), + Err(error) => { + warn!("filesystem watcher error: {error}"); + false } - _ => return false, + }; + + loop { + match event_rx.recv_timeout(Duration::from_millis(75)) { + Ok(Ok(event)) => { + saw_relevant_event |= event_has_watched_source_path(&source_root, &event); + } + Ok(Err(error)) => warn!("filesystem watcher error: {error}"), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => return, + } + } + + if !saw_relevant_event { + continue; } - p == OsStr::new("") + if let Err(error) = + sync_dirty_serve_state(&source_root, &build_repo_path, &mut previous_dirty_paths) + { + warn!( + "unable to synchronize dirty serve changes: {}", + Report::from_error(error) + ); + } } +} - fn prepare(eipw: lint::CmdArgs, resolved: ResolvedExecution) -> Result { +#[derive(Debug)] +struct Prepared { + cache: cache::Cache, + repo_path: PathBuf, + output_path: PathBuf, + repository_use: git::RepositoryUse, + theme: ThemeSource, + source_root: PathBuf, + source_materialization: git::SourceMaterialization, +} + +impl Prepared { + fn prepare(resolved: ResolvedExecution) -> Result { zola::find_zola().whatever_context("unable to find suitable zola binary")?; let ResolvedExecution { @@ -904,7 +1277,7 @@ impl Prepared { let repo_path = build_path.join(REPO_DIR); let content_path = repo_path.join(CONTENT_DIR); - let output_path = build_path.join(OUTPUT_DIR); + let output_path = output_path(&build_path); let both = git::Fresh::new( &root_path, @@ -918,22 +1291,11 @@ impl Prepared { .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| Self::is_proposal_path(p.into())) - .map(|p| repo_path.join(p)) - .collect(); - both.merge() .whatever_context("unable to merge ERC/EIP repositories")?; let cache = cache::Cache::open().whatever_context("unable to open cache")?; - lint::eipw(&theme, &cache, &root_path, &repo_path, changed_files, eipw) - .whatever_context("linting failed")?; - markdown::preprocess(&content_path).whatever_context("unable to preprocess markdown")?; Ok(Prepared { @@ -942,6 +1304,8 @@ impl Prepared { cache, repo_path, output_path, + source_root: root_path, + source_materialization, }) } @@ -958,9 +1322,23 @@ impl Prepared { } fn serve(self) -> Result<(), Whatever> { - zola::serve(&self.theme, &self.cache, &self.repo_path, &self.output_path) - .whatever_context("zola serve failed")?; - Ok(()) + let dirty_watcher = if self.source_materialization == git::SourceMaterialization::Dirty { + Some( + DirtyServeWatcher::start(self.source_root.clone(), self.repo_path.clone()) + .whatever_context("unable to start dirty serve watcher")?, + ) + } else { + None + }; + + let result = zola::serve(&self.theme, &self.cache, &self.repo_path, &self.output_path) + .whatever_context("zola serve failed"); + + if let Some(dirty_watcher) = dirty_watcher { + dirty_watcher.stop(); + } + + result } fn check(self) -> Result<(), Whatever> { @@ -970,6 +1348,36 @@ impl Prepared { } } +fn run_editorial_lint( + resolved: &ResolvedExecution, + selectors: &EditorialSelectorArgs, + eipw: lint::CmdArgs, +) -> Result { + let targets = editorial_targets(selectors, resolved)?; + if targets.is_empty() { + info!("editorial selector resolved no proposal files; skipping editorial lint"); + return Ok(false); + } + + let cache = cache::Cache::open().whatever_context("unable to open cache")?; + + lint::eipw(&resolved.theme, &cache, &resolved.root_path, targets, eipw) + .whatever_context("editorial lint failed")?; + + Ok(true) +} + +fn editorial_runtime_execution( + resolved: &ResolvedExecution, + selectors: &EditorialSelectorArgs, +) -> ResolvedExecution { + let mut runtime = resolved.clone(); + if selectors.working_tree { + runtime.source_materialization = git::SourceMaterialization::Dirty; + } + runtime +} + fn clone_missing_repo(url: &str, destination: &Path) -> Result<(), Whatever> { if destination.exists() { git2::Repository::open(destination) @@ -1065,6 +1473,13 @@ fn run() -> Result<(), Whatever> { } let resolved = resolve_execution(&args)?; + + if matches!(&args.operation, Operation::Preview) { + preview::serve(&output_path(&resolved.build_path)) + .whatever_context("preview server failed")?; + return Ok(()); + } + let build_path = make_build_dir(&resolved.build_path)?; let mut lock_file = lock(&build_path)?; @@ -1080,15 +1495,16 @@ fn run() -> Result<(), Whatever> { .whatever_context("unable to remove build directory")?; return Ok(()); } - Operation::Check { eipw } => { - Prepared::prepare(eipw, resolved)?.check()?; + Operation::Check => { + Prepared::prepare(resolved)?.check()?; } - Operation::Build { eipw } => { - Prepared::prepare(eipw, resolved)?.build()?; + Operation::Build => { + Prepared::prepare(resolved)?.build()?; } - Operation::Serve { eipw } => { - Prepared::prepare(eipw, resolved)?.serve()?; + Operation::Serve => { + Prepared::prepare(resolved)?.serve()?; } + Operation::Preview => unreachable!(), Operation::Changed { all, format } => { let repo_path = build_path.join(REPO_DIR); @@ -1108,12 +1524,21 @@ fn run() -> Result<(), Whatever> { .changed_files() .whatever_context("unable to list changed files")? .into_iter() - .filter(|p| all || Prepared::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); } + Operation::Editorial { command } => match command { + EditorialCommand::Lint { selectors, eipw } => { + run_editorial_lint(&resolved, &selectors, eipw)?; + } + EditorialCommand::Build { selectors, eipw } => { + run_editorial_lint(&resolved, &selectors, eipw)?; + Prepared::prepare(editorial_runtime_execution(&resolved, &selectors))?.check()?; + } + }, } lock_file diff --git a/src/markdown.rs b/src/markdown.rs index 06198e6..5f098ad 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -22,7 +22,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::ffi::OsStr; use std::fs::read_to_string; use std::io::Write; @@ -137,6 +137,19 @@ impl Default for FrontMatter { } } +fn filesystem_modified(p: &Path) -> Result { + let metadata = std::fs::metadata(p) + .with_whatever_context(|e| format!("unable to read metadata for `{}`: {e}", p.display()))?; + let modified = metadata.modified().with_whatever_context(|e| { + format!( + "unable to read filesystem modified time for `{}`: {e}", + p.display() + ) + })?; + let date_time: DateTime = modified.into(); + Ok(date_time.to_rfc3339().parse().unwrap()) +} + fn last_modified(p: &Path) -> Result { // TODO: Replace this with `git2` let mut command = std::process::Command::new("git"); @@ -158,7 +171,16 @@ fn last_modified(p: &Path) -> Result { } let date_str = std::str::from_utf8(&output.stdout) - .with_whatever_context(|e| format!("command {:?} output not UTF-8: {e}", command))?; + .with_whatever_context(|e| format!("command {:?} output not UTF-8: {e}", command))? + .trim(); + + if date_str.is_empty() { + debug!( + "falling back to filesystem modified time for `{}` because git has no timestamp for the current path", + p.to_string_lossy() + ); + return filesystem_modified(p); + } let unix: i64 = date_str.parse().with_whatever_context(|e| { let err_str = std::str::from_utf8(&output.stderr).unwrap_or(""); @@ -278,6 +300,59 @@ pub fn preprocess(root_path: &Path) -> Result<(), Whatever> { Ok(()) } +pub fn preprocess_paths( + root_path: &Path, + relative_paths: &BTreeSet, +) -> Result<(), Whatever> { + let mut eips = BTreeSet::new(); + let mut asset_dirs = BTreeSet::new(); + + for relative_path in relative_paths { + let Ok(content_relative_path) = relative_path.strip_prefix("content") else { + continue; + }; + + if content_relative_path.as_os_str().is_empty() { + continue; + } + + if content_relative_path.extension().and_then(OsStr::to_str) != Some("md") { + continue; + } + + let mut components = content_relative_path.components(); + let Some(first_component) = components.next() else { + continue; + }; + + if matches!( + components.next(), + Some(component) if component.as_os_str() == OsStr::new("assets") + ) { + let proposal_dir = root_path.join(first_component.as_os_str()); + if proposal_dir.join("assets").exists() { + asset_dirs.insert(proposal_dir); + } + continue; + } + + let path = root_path.join(content_relative_path); + if path.exists() { + eips.insert(path); + } + } + + for path in eips { + process_eip(root_path, &path)?; + } + + for path in asset_dirs { + process_assets(root_path, &path)?; + } + + Ok(()) +} + fn path_to_at(root: &Path, parent: &Path, input: &str) -> Result { let croot = std::fs::canonicalize(root).with_whatever_context(|_| { format!("could not canonicalize `{}`", root.to_string_lossy()) diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 0000000..b8109e3 --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,147 @@ +/* + * 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, + path::{Component, Path, PathBuf}, +}; + +use log::info; +use snafu::{ResultExt, Whatever}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; + +const PREVIEW_ADDR: &str = "127.0.0.1:1111"; +const INDEX_HTML: &str = "index.html"; + +pub fn serve(output_path: &Path) -> 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(PREVIEW_ADDR) { + Ok(server) => server, + Err(error) => { + snafu::whatever!("unable to bind preview server on {PREVIEW_ADDR}: {error}") + } + }; + + info!( + "serving static preview from `{}` at http://{PREVIEW_ADDR}/", + 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(path) = resolve_request_path(output_path, request.url()) else { + request + .respond(Response::empty(StatusCode(400))) + .whatever_context("unable to send preview bad request response")?; + return Ok(()); + }; + + if !path.is_file() { + request + .respond(Response::empty(StatusCode(404))) + .whatever_context("unable to send preview not found response")?; + return Ok(()); + } + + let file = File::open(&path).with_whatever_context(|e| { + format!( + "unable to open preview asset `{}`: {e}", + path.to_string_lossy() + ) + })?; + + 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 resolve_request_path(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(resolved.join(INDEX_HTML)); + } + + if resolved.is_dir() { + return Some(resolved.join(INDEX_HTML)); + } + + if resolved.extension().is_none() { + let candidate = resolved.join(INDEX_HTML); + if candidate.is_file() { + return Some(candidate); + } + } + + Some(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") +} diff --git a/src/zola.rs b/src/zola.rs index ac164be..317302c 100644 --- a/src/zola.rs +++ b/src/zola.rs @@ -128,9 +128,8 @@ pub fn serve( output_path: &Path, ) -> 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"] + let args = ["serve", "--drafts", "--fast", "--force", "-o"] .map(OsString::from) .into_iter() .chain(std::iter::once(output_path.into())); From 24d83bf4753334cd83103be22de8a0d90e01be6a Mon Sep 17 00:00:00 2001 From: Rito Rhymes Date: Tue, 14 Apr 2026 22:28:14 -0400 Subject: [PATCH 3/3] Document the local build workflow Document the local multi-repo workflow and command surface. Describe local workspace setup, dirty workflow, site commands, editorial commands, parity usage, and the separate serve and preview paths using stable public-facing terminology. --- README.md | 274 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 269 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8e6293e..2b42227 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ get your software: - git - libgit2 - openssl +- just[^2] - [zola](https://github.com/getzola/zola/tree/next)[^1] [^1]: Requires at least commit [`ead17d0a3`] for full functionality. +[^2]: Required for the generated local task surface. [`ead17d0a3`]: https://github.com/getzola/zola/commit/ead17d0a3a20bfb67043a076c061b35ae6b6ddea @@ -45,14 +47,276 @@ cargo install --git https://github.com/ethereum/build-eips.git 1. Clone either [`ethereum/EIPs`] or [`ethereum/ERCs`], and change directory into it. 1. Modify whatever proposal you'd like. -1. Commit your changes. 1. Build the project. You can use: - - `build-eips check` to quickly check for problems like missing sections, - broken internal links, etc. + - `build-eips check` to run the runtime site verification path. - `build-eips build` to create an on-disk bundle of HTML, ready to be deployed. - - `build-eips serve` to launch a web server to preview changes locally. - **NB: live reload is not yet implemented.** + - `build-eips serve` to launch the runtime dev server locally. + - `build-eips preview` to serve the last built output without rebuilding. +1. Run explicit editorial validation when you need proposal-targeted `eipw` + checks. + +## Local Overrides + +Local overrides add a local path layer and workspace config support without +changing the default CI-oriented path unless you opt into those settings. + +### Explicit local overrides + +Use these flags to point the build at local sibling repositories and an +out-of-tree build root: + +```bash +build-eips --staging \ + -C /work/EIPs-project/EIPs \ + --theme-path /work/EIPs-project/theme \ + --other-repo-path /work/EIPs-project/ERCs \ + --build-root /work/EIPs-project/.local-build/EIPs \ + check +``` + +Available overrides: + +- `--theme-path ` +- `--other-repo-path ` +- `--build-root ` +- `--config ` +- `--profile ` + +The local theme override also reuses that checkout's `config/eipw.toml`. + +### Workspace init + +If `build-eips` is already installed, you can bootstrap a local workspace from +inside `EIPs/` or `ERCs/`: + +```bash +build-eips workspace init /work/EIPs-project +``` + +By default this: + +- clones the missing sibling content repo +- clones `theme` +- creates `.local-build/` +- writes `.build-eips.toml` + +For platform development, you can additionally clone `preprocessor` and `eipw`: + +```bash +build-eips workspace init /work/EIPs-project --platform-dev +``` + +After init, daily commands can run from inside `EIPs/` or `ERCs/` without +repeating the local path flags: + +```bash +cd /work/EIPs-project/EIPs +build-eips check +build-eips build +build-eips serve +``` + +`--profile parity` is available for one-off profile selection from the local +workspace config. + +### Current Constraints + +- WG CI parity still means `--staging` +- clean worktrees are still required +- dirty working tree support requires the explicit dirty mode described below + +## Local Workspace Workflow + +The local workspace workflow adds the generated task surface and the +front-door bootstrap path while keeping `.build-eips.toml` user-owned. + +### Refresh generated helpers + +Once `workspace init` has created `.build-eips.toml`, refresh the generated +workspace helper files from the workspace root: + +```bash +cd /work/EIPs-project +build-eips workspace refresh +``` + +This generates or refreshes `/work/EIPs-project/justfile` without rewriting +`.build-eips.toml`. + +### Doctor checks + +Validate the local setup at any point with: + +```bash +build-eips workspace doctor +``` + +Doctor checks the workspace config, expected local repos, required tools, and +whether the generated helper files are current. + +### Daily `just` commands + +After `workspace refresh`, you can use the generated `justfile` from inside +`EIPs/` or `ERCs/`: + +```bash +cd /work/EIPs-project/EIPs +just check +just build +just serve +just parity-build +``` + +`just` will find the workspace-root `justfile` by walking up from the current +content repo. The generated recipes pass the invoking content repo back to +`build-eips` with `-C`, so run them from inside `EIPs/` or `ERCs/` rather than +from the workspace root. + +### Front-door `scripts/dev-setup` + +If you cloned `EIPs/` or `ERCs/` first, use that repo's checked-in +`./scripts/dev-setup` helper. It locates or installs `build-eips` and `just`, +runs `workspace init`, refreshes the generated helpers, and prints the next +useful commands. + +## Dirty Workflow + +Dirty workflow adds an explicit local-only mode for the active content repo. + +### Dirty profile + +Fresh workspaces now include a `dirty` profile in `.build-eips.toml`, so the +documented daily dirty loop works immediately: + +```bash +cd /work/EIPs-project/EIPs +build-eips --profile dirty check +build-eips --profile dirty build +build-eips --profile dirty serve +``` + +The generated `justfile` also exposes: + +```bash +just dirty-build +just dirty-serve +``` + +`build-eips --profile dirty serve` now performs the expensive runtime +preparation once at startup, then watches the real active content repo and +mirrors tracked changes into the materialized repo that Zola is serving from. +Zola runs in fast serve mode for in-session rebuilds, so tracked edits become a +real live local dev loop without restarting the command. + +### Ad hoc override + +You can also enable the same dirty materialization path without relying on a +profile: + +```bash +build-eips --allow-dirty check +build-eips --allow-dirty build +build-eips --allow-dirty serve +``` + +### Dirty-mode limits + +- dirty mode is opt-in and non-parity +- the clean/default path is unchanged +- only the active content repo is materialized dirty +- sibling repo and theme still follow the clean workspace/profile rules +- untracked files in the active content repo are currently ignored +- clean `build-eips serve` remains a clean runtime serve path and does not sync + working-tree edits during the session +- tracked deletions are mirrored into the materialized repo, but served route + invalidation under Zola fast serve remains best-effort + +## Site Commands and Editorial Commands + +The command surface separates site work from targeted editorial validation. + +### Site Commands + +These commands no longer invoke `eipw`: + +```bash +build-eips check +build-eips build +build-eips serve +``` + +The generated `justfile` keeps the runtime, parity, and dirty runtime recipes: + +```bash +just check +just build +just serve +just parity-build +just dirty-build +just dirty-serve +``` + +### Parity Profile + +Use the parity profile when you want to test the active content repo against +remote sibling and theme inputs instead of local checkouts: + +```bash +build-eips --profile parity check +build-eips --profile parity build +build-eips --profile parity preview +``` + +### Editorial Commands + +Use the explicit editorial surface when you want targeted `eipw` validation: + +```bash +build-eips editorial lint content/07949.md +build-eips editorial lint --working-tree +build-eips editorial lint --against-upstream --format github +build-eips editorial build --batch /work/EIPs-project/editor-batch.txt +``` + +Selector modes are mutually exclusive: + +- explicit repo-relative proposal paths +- `--batch ` with one repo-relative proposal path per line +- `--working-tree` for tracked dirty proposal files +- `--against-upstream` for PR-style merge-base selection + +`editorial build` runs targeted editorial validation first, then reuses the +runtime `check` path. + +## Serve and Preview + +Local serving keeps two distinct modes: + +- `build-eips serve` for the runtime dev loop +- `build-eips preview` for serving already-built static output + +### Dirty Serve + +`build-eips --profile dirty serve` is the live local editing loop. It performs +the expensive runtime preparation once at startup, then watches the real active +content repo and mirrors tracked edits into the materialized repo that Zola is +serving from. + +### Static Preview + +`build-eips preview` serves the resolved output directory for the active +profile without invoking Zola, preprocessing markdown, or rebuilding anything. +If the output directory does not exist yet, it fails and tells you to run +`build-eips build` first. + +The generated `justfile` also exposes: + +```bash +just preview +just parity-preview +just dirty-preview +``` [`ethereum/EIPs`]: https://github.com/ethereum/EIPs/ [`ethereum/ERCs`]: https://github.com/ethereum/ERCs/