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
46 changes: 37 additions & 9 deletions crates/mdbook-goals/src/mdbook_preprocessor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ use regex::Regex;
use rust_project_goals::config::{Configuration, GoalsConfig};
use rust_project_goals::format_champions::format_champions;
use rust_project_goals::format_team_ask::format_team_asks;
use rust_project_goals::format_team_support::format_team_support;
use rust_project_goals::markdown_processor::{MarkdownProcessor, MarkdownProcessorState};
use rust_project_goals::util;
use rust_project_goals_cli::Order;

use rust_project_goals::spanned::Spanned;
use rust_project_goals::{
goal::{self, GoalDocument, TeamAsk},
goal::{self, GoalDocument, TeamAsk, TeamInvolvement},
re,
team::TeamName,
};
Expand Down Expand Up @@ -303,14 +304,41 @@ impl<'c> GoalPreprocessorWithContext<'c> {
};

let goals = self.goal_documents(path)?;
let asks_of_any_team: Vec<&TeamAsk> = goals
.iter()
.filter(|g| g.metadata.status.is_not_not_accepted())
.flat_map(|g| &g.team_asks)
.collect();
let format_team_asks =
format_team_asks(&asks_of_any_team).map_err(|e| anyhow::anyhow!("{e}"))?;
chapter.content.replace_range(range, &format_team_asks);

// Separate goals by format
let mut old_format_asks: Vec<&TeamAsk> = vec![];
let mut new_format_goals: Vec<&GoalDocument> = vec![];

for goal in goals.iter().filter(|g| g.metadata.status.is_not_not_accepted()) {
match &goal.team_involvement {
TeamInvolvement::Asks(asks) => {
old_format_asks.extend(asks.iter());
}
TeamInvolvement::Support(_) => {
new_format_goals.push(goal);
}
}
}

// Format both old and new format goals
let mut formatted = String::new();

if !old_format_asks.is_empty() {
formatted.push_str(
&format_team_asks(&old_format_asks).map_err(|e| anyhow::anyhow!("{e}"))?,
);
}

if !new_format_goals.is_empty() {
if !formatted.is_empty() {
formatted.push_str("\n\n");
}
formatted.push_str(
&format_team_support(&new_format_goals).map_err(|e| anyhow::anyhow!("{e}"))?,
);
}

chapter.content.replace_range(range, &formatted);

Ok(())
}
Expand Down
135 changes: 104 additions & 31 deletions crates/rust-project-goals-cli/src/cfp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use std::path::{Path, PathBuf};
pub mod text_processing {
use regex::Regex;

use crate::cfp::normalize_timeframe;

/// Process template content by replacing placeholders and removing notes
pub fn process_template_content(
content: &str,
Expand Down Expand Up @@ -71,7 +73,12 @@ pub mod text_processing {
let mut new_content = content.to_string();

// Create the new section content with capitalized H
let capitalized_timeframe = format!("{}H{}", &timeframe[0..4], &timeframe[5..]);
let capitalized_timeframe = if timeframe.len() > 4 {
format!("{}H{}", &timeframe[0..4], &timeframe[5..])
} else {
timeframe.to_string()
};

let new_section_content = format!(
"# ⏳ {} goal process\n\n\
- [Overview](./{}/README.md)\n\
Expand Down Expand Up @@ -134,9 +141,9 @@ pub mod text_processing {
let new_section = format!("\n{}", new_section_content);

// Find a good place to insert the new section
// Look for the last timeframe section or insert at the beginning
// Match both lowercase and uppercase H
let re = Regex::new(r"# ⏳ \d{4}[hH][12] goal process").unwrap();
// Look for the last timeframe section or insert after # Summary
// Match both year-only (2027) and half-year (2026H1) formats
let re = Regex::new(r"# ⏳ \d{4}([hH][12])? goal process").unwrap();

if let Some(last_match) = re.find_iter(&content).last() {
// Find the end of this section (next section or end of file)
Expand All @@ -148,8 +155,20 @@ pub mod text_processing {
new_content.push_str(&new_section);
}
} else {
// No existing timeframe sections, insert at the beginning
new_content = new_section + &content;
// No existing ⏳ timeframe sections, find the first dated section (⏳ or ⚙️) and insert before it
let dated_section_re = Regex::new(r"\n# (⏳|⚙️) \d{4}").unwrap();
if let Some(first_dated) = dated_section_re.find(&content) {
new_content.insert_str(first_dated.start(), &new_section);
} else {
// No dated sections at all, insert after "# Summary\n\n"
if let Some(summary_pos) = content.find("# Summary") {
let insert_pos = summary_pos + "# Summary\n\n".len();
new_content.insert_str(insert_pos, &new_section);
} else {
// No # Summary found, prepend
new_content = new_section + &content;
}
}
}

new_content
Expand All @@ -163,28 +182,38 @@ pub mod text_processing {
) -> String {
let mut new_content = content.to_string();

// Extract year and half from timeframe
let _year = &timeframe[0..4];
let half = &timeframe[4..].to_lowercase();

// Determine the months based on the half
let (start_month, end_month) = if half == "h1" {
("January", "June")
// Create the new section to add with capitalized H
let capitalized_timeframe = normalize_timeframe(timeframe);

// Check if this is a year-only timeframe (no H1/H2)
let new_section = if timeframe.len() == 4 {
// Year-only format - no date range
format!(
"\n## Next goal period ({})\n\n\
The next goal period will be {}. \
We are currently in the process of assembling goals. \
[Click here](./{}/goals.md) to see the current list. \
If you'd like to propose a goal, [instructions can be found here](./how_to/propose_a_goal.md).\n",
capitalized_timeframe, capitalized_timeframe, lowercase_timeframe
)
} else {
("July", "December")
// Half-year format - include date range
let half = &timeframe[4..].to_lowercase();
let (start_month, end_month) = if half == "h1" {
("January", "June")
} else {
("July", "December")
};
format!(
"\n## Next goal period ({})\n\n\
The next goal period will be {}, running from the start of {} to the end of {}. \
We are currently in the process of assembling goals. \
[Click here](./{}/goals.md) to see the current list. \
If you'd like to propose a goal, [instructions can be found here](./how_to/propose_a_goal.md).\n",
capitalized_timeframe, capitalized_timeframe, start_month, end_month, lowercase_timeframe
)
};

// Create the new section to add with capitalized H
let capitalized_timeframe = format!("{}H{}", &timeframe[0..4], &timeframe[5..]);
let new_section = format!(
"\n## Next goal period ({})\n\n\
The next goal period will be {}, running from the start of {} to the end of {}. \
We are currently in the process of assembling goals. \
[Click here](./{}/goals.md) to see the current list. \
If you'd like to propose a goal, [instructions can be found here](./how_to/propose_a_goal.md).\n",
capitalized_timeframe, capitalized_timeframe, start_month, end_month, lowercase_timeframe
);

// First check for an existing entry for this specific timeframe
let this_period_pattern = Regex::new(&format!(
r"## Next goal period(?:\s*\({}\))?\s*\n",
Expand Down Expand Up @@ -340,15 +369,23 @@ pub fn create_cfp(timeframe: &str, force: bool, dry_run: bool) -> Result<()> {
Ok(())
}

/// Validates that the timeframe is in the correct format (e.g., "2025h1" or "2025H1")
/// Validates that the timeframe is in the correct format (e.g., "2025h1" or "2025H1" or '2026')
fn validate_timeframe(timeframe: &str) -> Result<()> {
let re = Regex::new(r"^\d{4}[hH][12]$").unwrap();
let re = Regex::new(r"^\d{4}([hH][12])?$").unwrap();
if !re.is_match(timeframe) {
return Err(Error::str("Invalid timeframe format. Expected format: YYYYhN or YYYYHN (e.g., 2025h1, 2025H1, 2025h2, or 2025H2"));
}
Ok(())
}

fn normalize_timeframe(timeframe: &str) -> String {
if timeframe.len() == 4 {
timeframe.to_string()
} else {
format!("{}H{}", &timeframe[0..4], &timeframe[5..])
}
}

/// Copies a template file to the destination and replaces placeholders
fn copy_and_process_template(
template_path: &str,
Expand Down Expand Up @@ -444,7 +481,7 @@ fn update_main_readme(timeframe: &str, lowercase_timeframe: &str, dry_run: bool)
}

// Determine what kind of update was made for better logging
let capitalized_timeframe = format!("{}H{}", &timeframe[0..4], &timeframe[5..]);
let capitalized_timeframe = normalize_timeframe(timeframe);
let specific_timeframe_pattern = format!(
r"## Next goal period(?:\s*\({}\))",
regex::escape(&capitalized_timeframe)
Expand Down Expand Up @@ -522,15 +559,39 @@ mod tests {
}

#[test]
fn test_process_summary_content_no_existing_section() {
// Test adding a new section when no timeframe sections exist
let content = "# Summary\n\n[Introduction](./README.md)\n";
fn test_process_summary_content_new_section() {
// Test adding a new section before an existing dated section
let content = "# Summary\n\n[👋 Introduction](./README.md)\n\n# ⚙️ 2025H2 goal process\n\n- [Overview](./2025h2/README.md)\n";
let result = process_summary_content(content, "2026h1", "2026h1");

assert!(result.contains("# ⏳ 2026H1 goal process"));
assert!(result.contains("- [Overview](./2026h1/README.md)"));
assert!(result.contains("- [Proposed goals](./2026h1/goals.md)"));
assert!(result.contains("- [Goals not accepted](./2026h1/not_accepted.md)"));
// Verify the section is inserted after Introduction, before the existing dated section
let intro_pos = result.find("[👋 Introduction]").unwrap();
let goal_process_pos = result.find("# ⏳ 2026H1 goal process").unwrap();
let existing_section_pos = result.find("# ⚙️ 2025H2").unwrap();
assert!(intro_pos < goal_process_pos, "Goal process section should come after Introduction");
assert!(goal_process_pos < existing_section_pos, "New section should come before existing dated section");
}

#[test]
fn test_process_summary_content_year_only_timeframe() {
// Test adding a new section with year-only timeframe (no H1/H2)
let content = "# Summary\n\n[👋 Introduction](./README.md)\n\n# ⚙️ 2025H2 goal process\n\n- [Overview](./2025h2/README.md)\n";
let result = process_summary_content(content, "2027", "2027");

assert!(result.contains("# ⏳ 2027 goal process"));
assert!(result.contains("- [Overview](./2027/README.md)"));
assert!(result.contains("- [Proposed goals](./2027/goals.md)"));
assert!(result.contains("- [Goals not accepted](./2027/not_accepted.md)"));
// Verify the section is inserted after Introduction, before the existing dated section
let intro_pos = result.find("[👋 Introduction]").unwrap();
let goal_process_pos = result.find("# ⏳ 2027 goal process").unwrap();
let existing_section_pos = result.find("# ⚙️ 2025H2").unwrap();
assert!(intro_pos < goal_process_pos, "Goal process section should come after Introduction");
assert!(goal_process_pos < existing_section_pos, "New section should come before existing dated section");
}

#[test]
Expand Down Expand Up @@ -630,4 +691,16 @@ mod tests {
assert!(result.contains("## Next goal period (2026H1)"));
assert!(result.contains("running from the start of January to the end of June"));
}

#[test]
fn test_process_readme_content_year_only() {
// Test that year-only timeframes don't include date ranges
let content = "# Project goals\n\n## Current goal period (2026)\n\nThe 2026 goal period.";
let result = process_readme_content(content, "2027", "2027");

assert!(result.contains("## Next goal period (2027)"));
assert!(result.contains("The next goal period will be 2027."));
assert!(!result.contains("running from"));
assert!(result.contains("[Click here](./2027/goals.md)"));
}
}
4 changes: 1 addition & 3 deletions crates/rust-project-goals-cli/src/rfc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,9 +583,7 @@ fn task_items(goal_plan: &GoalPlan) -> Result<Vec<String>> {
fn teams_with_asks(goal_documents: &[GoalDocument]) -> BTreeSet<&'static TeamName> {
goal_documents
.iter()
.flat_map(|g| &g.team_asks)
.flat_map(|ask| &ask.teams)
.copied()
.flat_map(|g| g.team_involvement.teams())
.collect()
}

Expand Down
7 changes: 1 addition & 6 deletions crates/rust-project-goals-cli/src/updates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,7 @@ pub fn render_updates(
.iter()
.filter_map(|doc| {
doc.metadata.tracking_issue.as_ref().map(|issue| {
let teams: std::collections::BTreeSet<&rust_project_goals::team::TeamName> = doc
.team_asks
.iter()
.flat_map(|ask| &ask.teams)
.copied()
.collect();
let teams = doc.team_involvement.teams();

let team_champions: Vec<String> = teams
.into_iter()
Expand Down
Loading