Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
6 changes: 4 additions & 2 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 package_todo;

mod common_test;
mod folder_privacy;
Expand All @@ -14,7 +15,7 @@ use crate::packs::checker_configuration::CheckerType;
// Internal imports
use crate::packs::pack::write_pack_to_disk;
use crate::packs::pack::Pack;
use crate::packs::package_todo;
use crate::packs::package_todo as package_todo_module;

Choose a reason for hiding this comment

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

I would try to differentiate the names of these two modules more, if not at a higher level, then at least for this alias. It's not clear what the difference is.
If one is meant to be public and the other internal, it may be worth restructuring the code to create an create::internal:: namespace.

use crate::packs::Configuration;

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

let mut validation_errors: Vec<String> = validators
Expand Down Expand Up @@ -346,7 +348,7 @@ pub(crate) fn update(configuration: &Configuration) -> anyhow::Result<()> {
&strict_violations.len()
);
}
package_todo::write_violations_to_disk(configuration, violations);
package_todo_module::write_violations_to_disk(configuration, violations);
println!("Successfully updated package_todo.yml files!");

Ok(())
Expand Down
113 changes: 113 additions & 0 deletions src/packs/checker/package_todo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! Package todo format validation checker.
//!
//! 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::{Configuration, PackageTodo};
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().unwrap().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()
})
.collect();

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

/// 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<(), String> {

Choose a reason for hiding this comment

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

This crate uses the anyhow to simplify the Result handling. I would be good to use it here for consistency. Example

Suggested change
) -> Result<(), String> {
) -> anyhow::Result<()> {

The return type can also be written as Result, if use anyhow::Result is included above.

// Read the current file content
let current_content = fs::read_to_string(package_todo_path)
.map_err(|e| format!("Failed to read {}: {}", package_todo_path.display(), e))?;

// Deserialize to ensure the file is valid and can be parsed
let package_todo: PackageTodo = serde_yaml::from_str(&current_content)
.map_err(|e| format!("Failed to parse {}: {}", package_todo_path.display(), e))?;

// 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 {
return Err(format!(

Choose a reason for hiding this comment

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

If using anyhow, this would need to be written as

Suggested change
return Err(format!(
return Err(anyhow!(

with a use anyhow::anyhow in the imports above.

"Package todo file {} is not in the expected format. Please run `{}` to fix it.",
package_todo_path.display(),
if packs_first_mode { "pks update" } else { "bin/packwerk update-todo" }
Copy link
Contributor

Choose a reason for hiding this comment

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

It can definitely be improved, but we have https://github.com/rubyatscale/pks/blob/main/src/packs/bin_locater.rs for identifying the executable.

));
}

Ok(())
}
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
53 changes: 53 additions & 0 deletions tests/package_todo_format_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//! 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(())
}

Loading