diff --git a/Cargo.lock b/Cargo.lock index 0a282fe..1f1fee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "bake" -version = "0.18.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbed34a1e40a3a5893a12c47de45006de3ae53112db64c0919a6e49c1a38d7e0" +checksum = "2f96c84418680954a2d9c8fe9afffb35aafb0b7086cb3cc141d613a2f0a74b42" dependencies = [ "bake-macros", "linkme", @@ -39,7 +39,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "socketry-markdown", + "socketry-markdown 0.1.1", ] [[package]] @@ -53,7 +53,7 @@ dependencies = [ "bake-releases", "serde", "serde_json", - "socketry-markdown", + "socketry-markdown 0.1.1", "tempfile", "toml_edit", "ureq", @@ -66,21 +66,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4877b435fe237311d88e546e8fb2b4a9021aff3550b625b8286e27da0ec693fd" dependencies = [ "bake", - "socketry-markdown", + "socketry-markdown 0.1.1", "tempfile", ] [[package]] name = "bake-macros" -version = "0.18.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b801275922e3bbc727ecdbb809f1a82591744cad907c9e915bdfa9b28bf001f" +checksum = "696dc147efb72a305f77a058388c94e651cd3586222a0ccfe3fcf145c7143242" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "bake-markdown" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eba114380436efdf21b2872d88f5450808cbbdec7947a647f7041ef4efb08a9" +dependencies = [ + "bake", + "socketry-markdown 0.2.0", +] + [[package]] name = "bake-readme" version = "0.2.0" @@ -89,7 +99,7 @@ checksum = "9cbf2eb3710006c27705edd4186c53bc85bcca4fa80ea927a3718660e59833f2" dependencies = [ "bake", "serde_json", - "socketry-markdown", + "socketry-markdown 0.1.1", "tempfile", ] @@ -100,7 +110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8439a25fe3a3192a666570d3d907cfb5bff8fdb5df17c92e495bcd4132ce0283" dependencies = [ "bake", - "socketry-markdown", + "socketry-markdown 0.1.1", "tempfile", ] @@ -645,6 +655,16 @@ dependencies = [ "unicode-id", ] +[[package]] +name = "socketry-markdown" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3a646262edeca60e86e4c4dd493167e74beb03ea75a60b2d5c40e6d6d8c5b" +dependencies = [ + "regex", + "unicode-id", +] + [[package]] name = "socketry-project" version = "0.3.5" @@ -656,6 +676,7 @@ dependencies = [ "bake-readme", "bake-releases", "bake-test-rust", + "tempfile", ] [[package]] @@ -663,6 +684,7 @@ name = "socketry-project-bake" version = "0.0.0" dependencies = [ "bake", + "bake-markdown", "socketry-project", ] diff --git a/Cargo.toml b/Cargo.toml index 0e2b4b0..39cbbbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,10 +22,13 @@ reviewers = ["socketry/managers"] name = "socketry_project" [dependencies] -bake = ">=0.18.0" +bake = ">=0.19.0" bake-agent-context = { version = ">=0.2.0" } bake-cargo = { version = ">=0.4.0" } bake-license = { version = ">=0.1.1" } bake-readme = { version = ">=0.1.1" } bake-releases = { version = ">=0.2.1" } bake-test-rust = { version = ">=0.3.0" } + +[dev-dependencies] +tempfile = "3" diff --git a/bake/Cargo.toml b/bake/Cargo.toml index 76fa7ee..cfc4b71 100644 --- a/bake/Cargo.toml +++ b/bake/Cargo.toml @@ -5,5 +5,6 @@ edition = "2024" publish = false [dependencies] -bake = "0.18.0" +bake = "0.19.0" +bake-markdown = "0.2.0" socketry-project = { path = "..", version = "0.3.5" } diff --git a/bake/src/bake_generated_tasks/mod.rs b/bake/src/bake_generated_tasks/mod.rs index 1792099..860794b 100644 --- a/bake/src/bake_generated_tasks/mod.rs +++ b/bake/src/bake_generated_tasks/mod.rs @@ -1,5 +1,4 @@ -// Released under the MIT License. -// Copyright, 2026, by Samuel Williams. - // Generated by `cargo bake --regenerate`; do not edit. + +use bake_markdown as _; use socketry_project as _; diff --git a/bake/src/main.rs b/bake/src/main.rs index 3a3e1fd..d980ca5 100644 --- a/bake/src/main.rs +++ b/bake/src/main.rs @@ -2,6 +2,7 @@ // Copyright, 2026, by Samuel Williams. use bake::Registry; +use bake_markdown as _; use std::process::ExitCode; fn main() -> ExitCode { @@ -27,6 +28,7 @@ mod tests { assert!(names.contains(&"cargo:release")); assert!(names.contains(&"cargo:after_version_bump")); assert!(names.contains(&"license:update")); + assert!(names.contains(&"markdown:normalize")); assert!(names.contains(&"readme:update")); assert!(names.contains(&"releases:update")); } diff --git a/context/conventions.md b/context/conventions.md index d53d8a9..ee920e9 100644 --- a/context/conventions.md +++ b/context/conventions.md @@ -84,7 +84,9 @@ their APIs into one shape. `socketry-project` there so task tooling does not become a runtime dependency of the published library. - Use the `cargo:after_version_bump` hook registered by `socketry-project` to - update `license.md`, `releases.md`, and the generated sections of `readme.md`. + update `license.md`, `releases.md`, and the generated sections of `readme.md`, + then normalize those files and all Markdown under the public `context/` + directory. - Follow the `socketry-project-releasing` skill to prepare and publish a release. It links to Bake Cargo task documentation for workflow setup, trusted publishing, reviewers, and tag creation. diff --git a/context/releasing.md b/context/releasing.md index 3df9a9a..a8d787c 100644 --- a/context/releasing.md +++ b/context/releasing.md @@ -26,8 +26,9 @@ cargo bake cargo:version:bump --version X.Y.Z ``` Choose one version task. The `socketry-project` hook updates `license.md`, -`releases.md`, and generated sections of `readme.md`. Review all generated -changes and ensure the release notes describe the actual changes. +`releases.md`, and generated sections of `readme.md`, then normalizes those +files and all Markdown under the public `context/` directory. Review the +generated changes and ensure the release notes describe the actual changes. Run the project's required tests and coverage checks using the `socketry-project-testing` skill. Commit the version and release files, then run @@ -59,9 +60,10 @@ the version tag and creates or updates the matching GitHub Release from `releases.md` using `cargo:releases:github:release`. These operations are Bake tasks; the workflow contains no inline Python release -scripts. The task names in this workflow require `bake` 0.18.0 or newer and +scripts. The task names in this workflow require `bake` 0.19.0 and `bake-cargo` 0.4.0 or newer in the resolved task binary, including its -`Cargo.lock`. +`Cargo.lock`. The version-bump hook also requires `bake-markdown` 0.2.0 or +newer. After merging, check the workflow result, published package versions, version tag, and GitHub Release. If a publish workflow is still waiting for environment diff --git a/context/setup.md b/context/setup.md index 46033c1..d771e63 100644 --- a/context/setup.md +++ b/context/setup.md @@ -62,7 +62,9 @@ reviewers = ["socketry/managers"] The `socketry-project` dependency makes the shared tasks available to the private Bake binary. It also registers `cargo:after_version_bump`, which updates `license.md`, `releases.md`, and generated sections in `readme.md` after a -version change. +version change, then normalizes those files and every Markdown file under the +public `context/` directory. Locally installed `.agents/context/` files are not +included. Keep task tooling out of unrelated published libraries. Consumer projects should depend on `socketry-project` from their private `bake/` package. @@ -95,9 +97,9 @@ Use `cargo bake cargo:setup:workflow` to generate workflow runs `cargo:release:detect` and `cargo:release` for release checks, then `cargo:publish:pending` and `cargo:release:publish` after merge through the configured `crates-io` environment. The standard workflow is documented in -the shared Releasing skill; its task binary must resolve `bake` 0.18.0 and -`bake-cargo` 0.4.0 or newer. Follow the `bake-test-rust` context for test -workflow and task details. +the shared Releasing skill; its task binary must resolve `bake` 0.19.0, +`bake-cargo` 0.4.0, and `bake-markdown` 0.2.0 or newer. Follow the +`bake-test-rust` context for test workflow and task details. ## Work on the project diff --git a/releases.md b/releases.md index 3f6029d..80ce366 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + +- Normalize standard project Markdown files after version bumps. + ## v0.3.5 - Require `bake-test-rust` 0.3.0 or newer for LLVM region coverage and update diff --git a/src/lib.rs b/src/lib.rs index 37771e6..ed6dfd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,179 +13,18 @@ use bake_readme as _; use bake_releases as _; use bake_test_rust as _; +mod markdown; + /// Refresh the standard project files after a Cargo version bump. #[bake::task(name = "cargo:after_version_bump")] pub fn after_version_bump(context: &mut Context, version: String) -> Result<()> { context.call("license:update", &[])?; context.call("releases:update", &[&format!("v{version}")])?; context.call("readme:update", &[])?; + markdown::normalize(context)?; Ok(()) } #[cfg(test)] -mod tests { - use bake::{Arguments, Parameter, Registry, Result, Task, Value}; - - #[derive(Default)] - struct Calls { - values: Vec<(String, Option)>, - fail_at: Option, - } - - fn record(context: &mut bake::Context, name: &str, value: Option) -> Result { - let calls = context.get_mut::().expect("the test records calls"); - calls.values.push((name.to_owned(), value)); - - if calls.fail_at.as_deref() == Some(name) { - return Err(bake::Error::new("injected task failure")); - } - - Ok(Value::Null) - } - - fn update_license(context: &mut bake::Context, _: &Arguments) -> Result { - record(context, "license:update", None) - } - - fn update_releases(context: &mut bake::Context, arguments: &Arguments) -> Result { - record( - context, - "releases:update", - Some(arguments.required::("version")?), - ) - } - - fn update_readme(context: &mut bake::Context, _: &Arguments) -> Result { - record(context, "readme:update", None) - } - - fn registry() -> Registry { - let mut registry = Registry::discover().unwrap(); - registry - .replace( - "license:update", - Task::new("license:update", "", vec![], update_license), - ) - .unwrap(); - registry - .replace( - "releases:update", - Task::new( - "releases:update", - "", - vec![Parameter::new::("version")], - update_releases, - ), - ) - .unwrap(); - registry - .replace( - "readme:update", - Task::new("readme:update", "", vec![], update_readme), - ) - .unwrap(); - - registry - } - - #[test] - fn refreshes_project_files_in_order_for_the_requested_version() { - let registry = registry(); - let mut context = registry.context("."); - context.insert(Calls::default()); - - super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap(); - - assert_eq!( - context.get::().unwrap().values, - [ - ("license:update".to_owned(), None), - ("releases:update".to_owned(), Some("v0.3.0".to_owned())), - ("readme:update".to_owned(), None), - ] - ); - } - - #[test] - fn propagates_license_update_failure() { - let registry = registry(); - let mut context = registry.context("."); - context.insert(Calls { - fail_at: Some("license:update".to_owned()), - ..Calls::default() - }); - - let error = super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap_err(); - - assert_eq!(error.to_string(), "license:update: injected task failure"); - assert_eq!( - context.get::().unwrap().values, - [("license:update".to_owned(), None)] - ); - } - - #[test] - fn propagates_release_update_failure() { - let registry = registry(); - let mut context = registry.context("."); - context.insert(Calls { - fail_at: Some("releases:update".to_owned()), - ..Calls::default() - }); - - let error = super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap_err(); - - assert_eq!(error.to_string(), "releases:update: injected task failure"); - assert_eq!( - context.get::().unwrap().values, - [ - ("license:update".to_owned(), None), - ("releases:update".to_owned(), Some("v0.3.0".to_owned())), - ] - ); - } - - #[test] - fn propagates_readme_update_failure() { - let registry = registry(); - let mut context = registry.context("."); - context.insert(Calls { - fail_at: Some("readme:update".to_owned()), - ..Calls::default() - }); - - let error = super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap_err(); - - assert_eq!(error.to_string(), "readme:update: injected task failure"); - assert_eq!( - context.get::().unwrap().values, - [ - ("license:update".to_owned(), None), - ("releases:update".to_owned(), Some("v0.3.0".to_owned())), - ("readme:update".to_owned(), None), - ] - ); - } - - #[test] - fn rejects_a_missing_release_version_argument() { - let registry = registry(); - let mut context = registry.context("."); - context.insert(Calls::default()); - - let error = update_releases(&mut context, &Arguments::default()).unwrap_err(); - - assert_eq!(error.to_string(), "missing argument \"version\""); - assert!(context.get::().unwrap().values.is_empty()); - } - - #[test] - fn registers_the_project_version_bump_hook() { - let registry = Registry::discover().unwrap(); - assert!( - registry - .tasks() - .any(|task| task.name() == "cargo:after_version_bump") - ); - } -} +#[path = "tests.rs"] +mod tests; diff --git a/src/markdown.rs b/src/markdown.rs new file mode 100644 index 0000000..e83efed --- /dev/null +++ b/src/markdown.rs @@ -0,0 +1,149 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use bake::{Context, Error, Result}; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +const STANDARD_FILES: [&str; 3] = ["license.md", "readme.md", "releases.md"]; + +pub(super) fn normalize(context: &mut Context) -> Result<()> { + let paths = markdown_paths(context.root())?; + normalize_paths(context, paths) +} + +fn normalize_paths(context: &mut Context, paths: Vec) -> Result<()> { + let mut owned_arguments = Vec::with_capacity(paths.len() * 2); + + for path in paths { + owned_arguments.push("--path".to_owned()); + owned_arguments.push(path_argument(&path)?); + } + + let arguments: Vec<_> = owned_arguments.iter().map(String::as_str).collect(); + context.call("markdown:normalize", &arguments)?; + + Ok(()) +} + +fn path_argument(path: &Path) -> Result { + path.to_str() + .map(str::to_owned) + .ok_or_else(|| Error::new("Markdown paths must be valid UTF-8")) +} + +fn markdown_paths(root: &Path) -> Result> { + markdown_paths_with(&LocalFileSystem, root) +} + +fn markdown_paths_with( + file_system: &FileSystemType, + root: &Path, +) -> Result> { + let mut paths: Vec<_> = STANDARD_FILES.iter().map(PathBuf::from).collect(); + collect_context_markdown_paths(file_system, root, Path::new("context"), &mut paths, true)?; + paths.sort(); + + Ok(paths) +} + +fn collect_context_markdown_paths( + file_system: &FileSystemType, + root: &Path, + directory: &Path, + paths: &mut Vec, + is_context_root: bool, +) -> Result<()> { + let full_directory = root.join(directory); + let entries = match file_system.read_dir(&full_directory) { + Ok(entries) => entries, + Err(error) if is_context_root && error.kind() == io::ErrorKind::NotFound => { + return Ok(()); + } + Err(error) => { + return Err(Error::new(format!( + "failed to read {}: {error}", + full_directory.display() + ))); + } + }; + + for entry in entries { + let entry = entry.map_err(|error| { + Error::new(format!( + "failed to read an entry in {}: {error}", + full_directory.display() + )) + })?; + let path = directory.join(entry.name()); + let kind = entry.kind().map_err(|error| { + Error::new(format!("failed to inspect {}: {error}", path.display())) + })?; + + if kind == EntryKind::Directory { + collect_context_markdown_paths(file_system, root, &path, paths, false)?; + } else if kind == EntryKind::File && path.extension() == Some(OsStr::new("md")) { + paths.push(path); + } + } + + Ok(()) +} + +trait FileSystem { + type Entry: DirectoryEntry; + type Entries: Iterator>; + + fn read_dir(&self, path: &Path) -> io::Result; +} + +trait DirectoryEntry { + fn name(&self) -> OsString; + fn kind(&self) -> io::Result; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EntryKind { + Directory, + File, + Other, +} + +struct LocalFileSystem; + +impl FileSystem for LocalFileSystem { + type Entry = fs::DirEntry; + type Entries = fs::ReadDir; + + fn read_dir(&self, path: &Path) -> io::Result { + fs::read_dir(path) + } +} + +impl DirectoryEntry for fs::DirEntry { + fn name(&self) -> OsString { + self.file_name() + } + + fn kind(&self) -> io::Result { + entry_kind(self.file_type()) + } +} + +fn entry_kind(file_type: io::Result) -> io::Result { + let file_type = file_type?; + + if file_type.is_dir() { + Ok(EntryKind::Directory) + } else if file_type.is_file() { + Ok(EntryKind::File) + } else { + Ok(EntryKind::Other) + } +} + +#[cfg(test)] +#[path = "markdown_tests.rs"] +mod tests; diff --git a/src/markdown_tests.rs b/src/markdown_tests.rs new file mode 100644 index 0000000..82b8f53 --- /dev/null +++ b/src/markdown_tests.rs @@ -0,0 +1,257 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use super::*; +use std::cell::RefCell; +use std::collections::HashMap; +use std::fs; + +#[test] +fn selects_standard_markdown_and_recursive_public_context_files() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + fs::create_dir_all(root.join("context/nested")).unwrap(); + fs::create_dir_all(root.join(".agents/context")).unwrap(); + fs::write(root.join("context/guide.md"), "Guide.\n").unwrap(); + fs::write(root.join("context/nested/skill.md"), "Skill.\n").unwrap(); + fs::write(root.join("context/nested/notes.txt"), "Not Markdown.\n").unwrap(); + fs::write(root.join(".agents/context/local.md"), "Local only.\n").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink( + root.join("context/guide.md"), + root.join("context/nested/link.md"), + ) + .unwrap(); + + let paths = markdown_paths(root).unwrap(); + + assert_eq!( + paths, + [ + PathBuf::from("context/guide.md"), + PathBuf::from("context/nested/skill.md"), + PathBuf::from("license.md"), + PathBuf::from("readme.md"), + PathBuf::from("releases.md"), + ] + ); +} + +#[test] +fn skips_context_when_the_public_context_directory_is_absent() { + let directory = tempfile::tempdir().unwrap(); + + let paths = markdown_paths(directory.path()).unwrap(); + + assert_eq!( + paths, + [ + PathBuf::from("license.md"), + PathBuf::from("readme.md"), + PathBuf::from("releases.md"), + ] + ); +} + +#[test] +fn reports_when_context_is_not_a_directory() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("context"), "Not a directory.\n").unwrap(); + + let error = markdown_paths(directory.path()).unwrap_err(); + + assert!(error.to_string().contains("failed to read")); +} + +#[test] +fn propagates_context_walk_errors_from_the_normalizer() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("context"), "Not a directory.\n").unwrap(); + let mut context = bake::Registry::new().context(directory.path()); + + let error = normalize(&mut context).unwrap_err(); + + assert!(error.to_string().contains("failed to read")); +} + +#[test] +fn reports_when_a_directory_entry_cannot_be_read() { + let file_system = FakeFileSystem::new([( + PathBuf::from("project/context"), + FakeDirectory::Entries(vec![Err(io::Error::other("entry unavailable"))]), + )]); + let mut paths = Vec::new(); + + let error = collect_context_markdown_paths( + &file_system, + Path::new("project"), + Path::new("context"), + &mut paths, + true, + ) + .unwrap_err(); + + assert!(error.to_string().contains("entry unavailable")); +} + +#[test] +fn reports_when_entry_metadata_cannot_be_read() { + let file_system = FakeFileSystem::new([( + PathBuf::from("project/context"), + FakeDirectory::Entries(vec![Ok(FakeEntry { + name: OsString::from("guide.md"), + kind: Err(io::Error::other("metadata unavailable")), + })]), + )]); + let mut paths = Vec::new(); + + let error = collect_context_markdown_paths( + &file_system, + Path::new("project"), + Path::new("context"), + &mut paths, + true, + ) + .unwrap_err(); + + assert!(error.to_string().contains("metadata unavailable")); +} + +#[test] +fn reports_when_a_nested_directory_cannot_be_read() { + let file_system = FakeFileSystem::new([ + ( + PathBuf::from("project/context"), + FakeDirectory::Entries(vec![Ok(FakeEntry::new("nested", EntryKind::Directory))]), + ), + ( + PathBuf::from("project/context/nested"), + FakeDirectory::Error(io::ErrorKind::PermissionDenied), + ), + ]); + let mut paths = Vec::new(); + + let error = collect_context_markdown_paths( + &file_system, + Path::new("project"), + Path::new("context"), + &mut paths, + true, + ) + .unwrap_err(); + + assert!(error.to_string().contains("project/context/nested")); +} + +#[test] +fn ignores_other_filesystem_entries() { + let file_system = FakeFileSystem::new([( + PathBuf::from("project/context"), + FakeDirectory::Entries(vec![Ok(FakeEntry::new("link.md", EntryKind::Other))]), + )]); + let mut paths = Vec::new(); + + collect_context_markdown_paths( + &file_system, + Path::new("project"), + Path::new("context"), + &mut paths, + true, + ) + .unwrap(); + + assert!(paths.is_empty()); +} + +#[test] +fn reports_when_the_normalize_task_is_not_registered() { + let directory = tempfile::tempdir().unwrap(); + let mut context = bake::Registry::new().context(directory.path()); + + let error = normalize(&mut context).unwrap_err(); + + assert!(error.to_string().contains("markdown:normalize")); +} + +#[cfg(unix)] +#[test] +fn propagates_path_conversion_errors_from_the_normalizer() { + use std::os::unix::ffi::OsStrExt; + + let directory = tempfile::tempdir().unwrap(); + let mut context = bake::Registry::new().context(directory.path()); + let path = PathBuf::from(OsStr::from_bytes(&[0xff, b'.', b'm', b'd'])); + + let error = normalize_paths(&mut context, vec![path]).unwrap_err(); + + assert_eq!(error.to_string(), "Markdown paths must be valid UTF-8"); +} + +#[test] +fn reports_file_type_errors() { + let error = entry_kind(Err(io::Error::other("metadata unavailable"))).unwrap_err(); + + assert_eq!(error.to_string(), "metadata unavailable"); +} + +enum FakeDirectory { + Entries(Vec>), + Error(io::ErrorKind), +} + +struct FakeFileSystem { + directories: RefCell>, +} + +impl FakeFileSystem { + fn new(directories: impl IntoIterator) -> Self { + Self { + directories: RefCell::new(directories.into_iter().collect()), + } + } +} + +impl FileSystem for FakeFileSystem { + type Entry = FakeEntry; + type Entries = std::vec::IntoIter>; + + fn read_dir(&self, path: &Path) -> io::Result { + let response = self + .directories + .borrow_mut() + .remove(path) + .unwrap_or(FakeDirectory::Entries(Vec::new())); + + match response { + FakeDirectory::Entries(entries) => Ok(entries.into_iter()), + FakeDirectory::Error(kind) => Err(io::Error::from(kind)), + } + } +} + +struct FakeEntry { + name: OsString, + kind: io::Result, +} + +impl FakeEntry { + fn new(name: &str, kind: EntryKind) -> Self { + Self { + name: OsString::from(name), + kind: Ok(kind), + } + } +} + +impl DirectoryEntry for FakeEntry { + fn name(&self) -> OsString { + self.name.clone() + } + + fn kind(&self) -> io::Result { + match &self.kind { + Ok(kind) => Ok(*kind), + Err(error) => Err(io::Error::new(error.kind(), error.to_string())), + } + } +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..e1ccaf9 --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,235 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +use bake::{Arguments, Parameter, Registry, Result, Task, Value}; + +#[derive(Default)] +struct Calls { + values: Vec<(String, Option)>, + markdown_paths: Vec, + fail_at: Option, +} + +fn record(context: &mut bake::Context, name: &str, value: Option) -> Result { + let calls = context.get_mut::().expect("the test records calls"); + calls.values.push((name.to_owned(), value)); + + if calls.fail_at.as_deref() == Some(name) { + return Err(bake::Error::new("injected task failure")); + } + + Ok(Value::Null) +} + +fn update_license(context: &mut bake::Context, _: &Arguments) -> Result { + record(context, "license:update", None) +} + +fn update_releases(context: &mut bake::Context, arguments: &Arguments) -> Result { + record( + context, + "releases:update", + Some(arguments.required::("version")?), + ) +} + +fn update_readme(context: &mut bake::Context, _: &Arguments) -> Result { + record(context, "readme:update", None) +} + +fn normalize_markdown(context: &mut bake::Context, arguments: &Arguments) -> Result { + let paths = arguments.repeated::("path")?; + record(context, "markdown:normalize", None)?; + context.get_mut::().unwrap().markdown_paths = paths; + Ok(Value::Null) +} + +fn registry() -> Registry { + let mut registry = Registry::discover().unwrap(); + registry + .replace( + "license:update", + Task::new("license:update", "", vec![], update_license), + ) + .unwrap(); + registry + .replace( + "releases:update", + Task::new( + "releases:update", + "", + vec![Parameter::new::("version")], + update_releases, + ), + ) + .unwrap(); + registry + .replace( + "readme:update", + Task::new("readme:update", "", vec![], update_readme), + ) + .unwrap(); + registry + .register(Task::new( + "markdown:normalize", + "", + vec![Parameter::new::("path").repeated()], + normalize_markdown, + )) + .unwrap(); + + registry +} + +#[test] +fn refreshes_project_files_in_order_for_the_requested_version() { + let registry = registry(); + let mut context = registry.context("."); + context.insert(Calls::default()); + + super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap(); + + assert_eq!( + context.get::().unwrap().values, + [ + ("license:update".to_owned(), None), + ("releases:update".to_owned(), Some("v0.3.0".to_owned())), + ("readme:update".to_owned(), None), + ("markdown:normalize".to_owned(), None), + ] + ); +} + +#[test] +fn propagates_license_update_failure() { + let registry = registry(); + let mut context = registry.context("."); + context.insert(Calls { + fail_at: Some("license:update".to_owned()), + ..Calls::default() + }); + + let error = super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap_err(); + + assert_eq!(error.to_string(), "license:update: injected task failure"); + assert_eq!( + context.get::().unwrap().values, + [("license:update".to_owned(), None)] + ); +} + +#[test] +fn propagates_release_update_failure() { + let registry = registry(); + let mut context = registry.context("."); + context.insert(Calls { + fail_at: Some("releases:update".to_owned()), + ..Calls::default() + }); + + let error = super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap_err(); + + assert_eq!(error.to_string(), "releases:update: injected task failure"); + assert_eq!( + context.get::().unwrap().values, + [ + ("license:update".to_owned(), None), + ("releases:update".to_owned(), Some("v0.3.0".to_owned())), + ] + ); +} + +#[test] +fn propagates_readme_update_failure() { + let registry = registry(); + let mut context = registry.context("."); + context.insert(Calls { + fail_at: Some("readme:update".to_owned()), + ..Calls::default() + }); + + let error = super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap_err(); + + assert_eq!(error.to_string(), "readme:update: injected task failure"); + assert_eq!( + context.get::().unwrap().values, + [ + ("license:update".to_owned(), None), + ("releases:update".to_owned(), Some("v0.3.0".to_owned())), + ("readme:update".to_owned(), None), + ] + ); +} + +#[test] +fn propagates_markdown_normalization_failure() { + let registry = registry(); + let mut context = registry.context("."); + context.insert(Calls { + fail_at: Some("markdown:normalize".to_owned()), + ..Calls::default() + }); + + let error = super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap_err(); + + assert_eq!( + error.to_string(), + "markdown:normalize: injected task failure" + ); + assert_eq!( + context.get::().unwrap().values, + [ + ("license:update".to_owned(), None), + ("releases:update".to_owned(), Some("v0.3.0".to_owned())), + ("readme:update".to_owned(), None), + ("markdown:normalize".to_owned(), None), + ] + ); +} + +#[test] +fn passes_standard_and_recursive_context_paths_to_the_normalizer() { + let directory = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(directory.path().join("context/nested")).unwrap(); + std::fs::write(directory.path().join("context/guide.md"), "Guide.\n").unwrap(); + std::fs::write(directory.path().join("context/nested/skill.md"), "Skill.\n").unwrap(); + + let registry = registry(); + let mut context = registry.context(directory.path()); + context.insert(Calls::default()); + + super::after_version_bump(&mut context, "0.3.0".to_owned()).unwrap(); + + assert_eq!( + context.get::().unwrap().markdown_paths, + [ + std::path::PathBuf::from("context/guide.md"), + std::path::PathBuf::from("context/nested/skill.md"), + std::path::PathBuf::from("license.md"), + std::path::PathBuf::from("readme.md"), + std::path::PathBuf::from("releases.md"), + ] + ); +} + +#[test] +fn rejects_a_missing_release_version_argument() { + let registry = registry(); + let mut context = registry.context("."); + context.insert(Calls::default()); + + let error = update_releases(&mut context, &Arguments::default()).unwrap_err(); + + assert_eq!(error.to_string(), "missing argument \"version\""); + assert!(context.get::().unwrap().values.is_empty()); +} + +#[test] +fn registers_the_project_version_bump_hook() { + let registry = Registry::discover().unwrap(); + assert!( + registry + .tasks() + .any(|task| task.name() == "cargo:after_version_bump") + ); +}