Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/packs/checker.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Module declarations
mod dependency;
pub(crate) mod layer;
mod todo_format;

mod common_test;
mod folder_privacy;
Expand Down Expand Up @@ -282,6 +283,7 @@ fn validate(configuration: &Configuration) -> Vec<String> {
[&CheckerType::Layer]
.clone(),
}),
Box::new(todo_format::Checker),
];

let mut validation_errors: Vec<String> = validators
Expand Down
130 changes: 130 additions & 0 deletions src/packs/checker/todo_format.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! Package todo format validation checker.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see now. We've introduced a new package_todo module when there was already an existing one in the checker namespace. That's why you're having namespace collisions.

//!
//! This module provides validation to ensure that package_todo.yml files maintain
//! the correct serialization format. This prevents issues where manual edits
//! (such as mass search-replace operations when renaming packs) result in
//! incorrectly formatted files that create noise when running `pks update`.

use std::fs;
use std::path::Path;

use crate::packs::bin_locater;
use crate::packs::{Configuration, PackageTodo};
use anyhow::{anyhow, Result};
use rayon::prelude::*;

use super::ValidatorInterface;

/// Validator that checks package_todo.yml files for correct serialization format.
///
/// This validator ensures that existing package_todo.yml files match their expected
/// serialized format by:
/// 1. Reading the current file content
/// 2. Deserializing it to a PackageTodo struct
/// 3. Re-serializing it using the standard serialization logic
/// 4. Comparing the result with the original file content
///
/// If differences are found, it reports validation errors with context-aware
/// suggestions for the correct command to run based on packs_first_mode.
pub struct Checker;

impl ValidatorInterface for Checker {
/// Validates that all existing package_todo.yml files are correctly formatted.
///
/// Iterates through all packs and checks their package_todo.yml files (if they exist)
/// to ensure they match the expected serialization format.
///
/// # Returns
/// - `None` if all files are correctly formatted
/// - `Some(Vec<String>)` containing error messages for incorrectly formatted files
fn validate(&self, configuration: &Configuration) -> Option<Vec<String>> {
let validation_errors: Vec<String> = configuration
.pack_set
.packs
.par_iter()
.filter_map(|pack| {
let package_todo_path = pack
.yml
.parent()
.expect("Pack yml file should have a parent directory")
.join("package_todo.yml");

// Skip packs that don't have package_todo.yml files
if !package_todo_path.exists() {
return None;
}

validate_package_todo_format(
&package_todo_path,
&pack.name,
configuration.packs_first_mode,
)
.err()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If using anyhow you'll need to convert these into String

Suggested change
.err()
.map_err(|e| e.to_string()).err

.map(|e| e.to_string())
})
.collect();

if validation_errors.is_empty() {
None
} else {
Some(validation_errors)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is totally legit and there's nothing wrong with it, but I would prefer pattern matching in general.
It's also just good to know how to pattern match a slice.

match &validation_errors[..] {
    [] => None,
    _ => Some(validation_errors)
}

I whipped up a more complete example of matching over slices for you.
https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=7932bced185feb6f53dc7b23194c3466

To be 100% clear, I don't actually have a problem with the if/else here.
You were just asking about idiomatic rust so it seemed to be a learning opportunity to bring this up.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! This is exactly the kind of feedback I'm looking for. Thank you!

}
}

/// Validates the format of a single package_todo.yml file.
///
/// This function implements the core validation logic:
/// 1. Reads the current file content
/// 2. Deserializes it to ensure it's valid YAML and matches PackageTodo structure
/// 3. Re-serializes it using the standard serialization logic
/// 4. Compares the result with the original content
///
/// # Arguments
/// * `package_todo_path` - Path to the package_todo.yml file to validate
/// * `pack_name` - Name of the pack (used for generating the correct header)
/// * `packs_first_mode` - Whether the project uses packs.yml (affects command suggestions)
///
/// # Returns
/// * `Ok(())` if the file is correctly formatted
/// * `Err(String)` with a descriptive error message if validation fails
///
/// # Common causes of validation failures
/// - Missing `---` separator after header comments
/// - Incorrect ordering of violations or files (should be alphabetically sorted)
/// - Manual edits that break the standard serialization format
/// - Wrong header comment (should match packs_first_mode setting)
fn validate_package_todo_format(
package_todo_path: &Path,
pack_name: &str,
packs_first_mode: bool,
) -> Result<()> {
// Read the current file content
let current_content = fs::read_to_string(package_todo_path)?;

// Deserialize to ensure the file is valid and can be parsed
let package_todo: PackageTodo = serde_yaml::from_str(&current_content)?;

// Re-serialize using the standard serialization logic to get the expected format
let expected_content = crate::packs::package_todo::serialize_package_todo(
pack_name,
&package_todo,
packs_first_mode,
);

// Compare the current content with the expected serialized format
if current_content != expected_content {
let command = if packs_first_mode {
format!("{} update", bin_locater::packs_bin_name())
} else {
"bin/packwerk update-todo".to_string()
};
return Err(anyhow!(
"Package todo file {} is not in the expected format. Please run `{}` to fix it.",
package_todo_path.display(),
command
));
}

Ok(())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a place I would definitely prefer pattern matching over an if return.
The if return structure leaves room to wedge code in between this and returning the Ok, which can encourage complexity in the future as more early returns get added in here.

match current_content {
    expected_content => Ok(()),
     _ => {
        Err(/*...*/)     
     }
 }

}
33 changes: 30 additions & 3 deletions src/packs/package_todo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,35 @@ pub fn write_violations_to_disk(
debug!("Finished writing violations to disk");
}

fn serialize_package_todo(
responsible_pack_name: &String,
/// Serializes a PackageTodo struct into the standard package_todo.yml format.
///
/// This function generates the canonical serialized representation of a package_todo.yml file,
/// including the standard header comments and properly formatted YAML content. The output
/// format is deterministic and consistent, which is critical for format validation.
///
/// # Arguments
/// * `responsible_pack_name` - The name of the pack that owns this package_todo.yml file
/// * `package_todo` - The PackageTodo struct containing violation data to serialize
/// * `packs_first_mode` - Whether the project uses packs.yml (affects header command)
///
/// # Returns
/// A String containing the complete package_todo.yml file content, including:
/// - Standard header comments explaining the file's purpose
/// - Appropriate regeneration command based on packs_first_mode
/// - YAML separator (`---`)
/// - Sorted violation data in the standard format
///
/// # Format Details
/// The serialized format ensures:
/// - Violations are sorted alphabetically by defining pack, then constant name
/// - Files within each violation are sorted alphabetically
/// - Constant names are properly quoted (using a hack to work around serde_yaml limitations)
/// - The header matches the project's configuration (packs_first_mode)
///
/// This function is used both for writing new package_todo.yml files and for
/// format validation to ensure existing files match the expected format.
pub(crate) fn serialize_package_todo(
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super familiar with Rust's modules. Is this a code smell? I didn't want to dive into the "how should we organize and expose these types/methods" rabbit hole just yet.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems reasonable, if you don't want the method to be part of the public API. If you really want to limit the usage you can declare pub(super) so only this and the parent module can use it, but that's likely overkill for this project.

responsible_pack_name: &str,
package_todo: &PackageTodo,
packs_first_mode: bool,
) -> String {
Expand Down Expand Up @@ -223,7 +250,7 @@ fn delete_package_todo_from_disk(responsible_pack: &Pack) {
}
}

fn header(responsible_pack_name: &String, packs_first_mode: bool) -> String {
fn header(responsible_pack_name: &str, packs_first_mode: bool) -> String {
let command = if packs_first_mode {
"pks update"
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# You can regenerate this file using the following command:
#
# bin/packwerk update-todo
---
packs/bar:
"::Bar":
violations:
Expand Down
2 changes: 2 additions & 0 deletions tests/fixtures/incorrectly_formatted_package_todo/package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
enforce_privacy: false
enforce_dependencies: false
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
enforce_privacy: true
enforce_dependencies: true
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# This file is incorrectly formatted (not ordered properly)
---
packs/bar:
"::Baz":
violations:
- privacy
- dependency
files:
- packs/foo/app/services/foo.rb
"::Bar":
violations:
- dependency
files:
- packs/foo/app/services/foo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
include:
- "**/*.rb"
- "**/*.rake"
exclude:
- "{bin,node_modules,script,tmp,vendor}/**/*"
packs_first_mode: false
95 changes: 95 additions & 0 deletions tests/todo_format_validation_test.rs
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be nice to have a test that ensures packs without a package todo file are correctly skipped. I wouldn't consider this a blocker though!

Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! Tests for package_todo.yml format validation.
//!
//! These tests verify that the `pks validate` command correctly identifies
//! when package_todo.yml files are not in the expected serialization format
//! and provides appropriate error messages and suggestions.

use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::{error::Error, process::Command};

mod common;

/// Tests that validation fails for incorrectly formatted package_todo.yml files.
///
/// This test uses a fixture with a package_todo.yml file that has violations in
/// the wrong order (::Baz should come after ::Bar when sorted alphabetically).
/// The validation should fail and suggest running the appropriate update command.
#[test]
fn test_validate_incorrectly_formatted_package_todo(
) -> Result<(), Box<dyn Error>> {
Command::cargo_bin("pks")
.unwrap()
.arg("--project-root")
.arg("tests/fixtures/incorrectly_formatted_package_todo")
.arg("validate")
.assert()
.failure()
.stdout(predicate::str::contains("1 validation error(s) detected:"))
.stdout(predicate::str::contains("is not in the expected format"))
.stdout(predicate::str::contains("bin/packwerk update-todo"));

common::teardown();
Ok(())
}

/// Tests that validation passes for correctly formatted package_todo.yml files.
///
/// This test uses an existing fixture that has a properly formatted package_todo.yml
/// file (with correct ordering, headers, and format). The validation should succeed.
#[test]
fn test_validate_correctly_formatted_package_todo() -> Result<(), Box<dyn Error>>
{
Command::cargo_bin("pks")
.unwrap()
.arg("--project-root")
.arg("tests/fixtures/contains_package_todo")
.arg("validate")
.assert()
.success()
.stdout(predicate::str::contains("Packwerk validate succeeded!"));

common::teardown();
Ok(())
}

/// Tests that validation passes when violations exist but no package_todo.yml files.
///
/// This test uses a fixture with actual privacy violations but no package_todo.yml files
/// and verifies that the todo format validator still succeeds (it only validates existing
/// todo files, not whether violations should have todo files).
#[test]
fn test_validate_passes_when_violations_exist_but_no_todo_file(
) -> Result<(), Box<dyn Error>> {
Command::cargo_bin("pks")
.unwrap()
.arg("--project-root")
.arg("tests/fixtures/privacy_violation_overrides")
.arg("validate")
.assert()
.success()
.stdout(predicate::str::contains("Packwerk validate succeeded!"));

common::teardown();
Ok(())
}

/// Tests that validation succeeds when there are no violations and no package_todo.yml files.
///
/// This test uses a clean fixture with no violations and no package_todo.yml files
/// to verify the validator succeeds in the simplest case.
#[test]
fn test_validate_succeeds_when_no_violations_and_no_todo_files(
) -> Result<(), Box<dyn Error>> {
Command::cargo_bin("pks")
.unwrap()
.arg("--project-root")
.arg("tests/fixtures/simple_app")
.arg("validate")
.assert()
.success()
.stdout(predicate::str::contains("Packwerk validate succeeded!"));

common::teardown();
Ok(())
}