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/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..e8b8cfe 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,27 +139,387 @@ 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 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)) +} + +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", })?; - 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() + + for path in relative_paths { + sync_dirty_path(source_root, working_root, &mut index, path)?; + } + + index.write().context(GitSnafu { + what: "write build repository index", + })?; + + Ok(()) +} + +fn remove_existing_path(path: &Path) -> Result<(), std::io::Error> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() => { + std::fs::remove_dir_all(path) + } + Ok(_) => std::fs::remove_file(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn 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> { let original = match master_tree.get_path(path) { Err(_) => return Ok(()), @@ -170,19 +552,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 +582,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 +624,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 +654,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 +782,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 +885,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 +897,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 +922,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..cf05ee6 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; @@ -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, @@ -257,21 +249,18 @@ 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, - changed_paths: Vec, + sources: Vec, opts: CmdArgs, ) -> Result<(), Error> { - if opts.no_lint { - return Ok(()); - } - let mut stdout = std::io::stdout(); - let mut config_path = cache.repo(theme_repo, theme_rev)?; + 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"); @@ -301,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 6244467..a719247 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,26 +11,46 @@ 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 snafu::{Report, ResultExt, Whatever}; +use log::{debug, info, warn}; +use notify::{Event, RecursiveMode, Watcher}; +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 +64,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, } @@ -57,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 { @@ -85,6 +123,77 @@ enum Operation { #[clap(long, value_enum, default_value_t)] format: ChangedFormat, }, + + /// Run targeted editorial validation with eipw + Editorial { + #[command(subcommand)] + command: EditorialCommand, + }, + + /// 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, 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)] @@ -95,6 +204,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 +273,47 @@ 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()); + } +} + +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 = @@ -145,229 +330,1215 @@ 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 + +preview: + build-eips -C "{{ invocation_directory() }}" preview + +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 + +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 +"# +} + +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()) } -#[derive(Debug)] -struct Prepared { - cache: cache::Cache, - root_path: PathBuf, - repo_path: PathBuf, - output_path: PathBuf, - config: Config, +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(()) } -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(); +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 output_path(build_path: &Path) -> PathBuf { + build_path.join(OUTPUT_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)) } - Some(_) if p.extension().map(|x| x == "md").unwrap_or(false) => { - p.set_extension(""); + _ => 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, + }) +} + +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("") +} + +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; + } + + 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)); + } - // 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(); + 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; + }; + + 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(())); - // content - // ^^^^^^^ - match p.file_name() { - Some(f) if f == "content" => { - p.pop(); + 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 + } + }; + + 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, } - _ => return false, } - p == OsStr::new("") + if !saw_relevant_event { + continue; + } + + 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, - config: Config, - root_path: PathBuf, - build_path: PathBuf, - ) -> 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 { + 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 output_path = output_path(&build_path); - 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(); + 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")?; both.merge() .whatever_context("unable to merge ERC/EIP repositories")?; 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")?; - markdown::preprocess(&content_path).whatever_context("unable to preprocess markdown")?; Ok(Prepared { - config, - root_path, + repository_use, + theme, cache, repo_path, output_path, + source_root: root_path, + source_materialization, }) } 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")?; - 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> { - 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_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) + .whatever_context("expected existing workspace repo path to be a git repository")?; + info!( + "using existing workspace repo `{}`", + destination.to_string_lossy() + ); + return Ok(()); + } + + 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 { + 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(); - if let Operation::Print { print } = args.operation { - print::print(print); + + if let Operation::Print { print } = &args.operation { + print::print(print.clone()); return Ok(()); } - let config = if args.staging { - Config::staging() - } else { - Config::production() - }; + 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 root_path = root(&args)?; - let build_path = make_build_dir(&root_path)?; + 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)?; 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()?; + Operation::Check => { + Prepared::prepare(resolved)?.check()?; } - Operation::Build { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.build()?; + Operation::Build => { + Prepared::prepare(resolved)?.build()?; } - Operation::Serve { eipw } => { - Prepared::prepare(eipw, config, root_path, build_path)?.serve()?; + Operation::Serve => { + Prepared::prepare(resolved)?.serve()?; } + Operation::Preview => unreachable!(), 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() .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 fc65638..317302c 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,20 +122,18 @@ pub fn build( } pub fn serve( - theme_repo: &str, - theme_rev: &str, + theme: &ThemeSource, cache: &Cache, project_path: &Path, 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())); - spawn_log(theme_repo, theme_rev, cache, project_path, args)?; + spawn_log(theme, cache, project_path, args)?; Ok(()) } @@ -155,8 +147,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 +164,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) {