Skip to content

Commit d475d51

Browse files
nikomatsakisclaude
andcommitted
refactor: clean configuration loading and enhance markdown processing
Refactors the configuration architecture for cleaner API design and extracts shared markdown processing logic. Renames structs for better clarity (GoalsConfig as main public interface) and adds comprehensive documentation. This prepares the foundation for dynamic content generation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent cb7d714 commit d475d51

File tree

4 files changed

+412
-1
lines changed

4 files changed

+412
-1
lines changed

crates/rust-project-goals/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ rust-project-goals-json = { version = "0.1.0", path = "../rust-project-goals-jso
1717
toml = "0.8.19"
1818
indexmap = "2.7.1"
1919
spanned = "0.6.1"
20+
21+
[dev-dependencies]
22+
tempfile = "3.8.1"

crates/rust-project-goals/src/config.rs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
use std::collections::HashMap;
12
use std::path::PathBuf;
23

4+
use anyhow::Context;
35
use indexmap::IndexMap;
6+
use regex::Regex;
47
use serde::Deserialize;
58
use spanned::{Context as _, Result};
69

@@ -40,3 +43,246 @@ impl Configuration {
4043
Ok(toml::from_str(&toml_string)?)
4144
}
4245
}
46+
47+
// Goals-specific configuration for markdown processing
48+
// This is separate from the main Configuration above
49+
50+
#[derive(Deserialize, Debug, Default)]
51+
struct TomlBookConfig {
52+
#[serde(default)]
53+
preprocessor: TomlPreprocessorConfig,
54+
}
55+
56+
#[derive(Deserialize, Debug, Default)]
57+
struct TomlPreprocessorConfig {
58+
#[serde(default)]
59+
goals: TomlGoalsConfig,
60+
}
61+
62+
#[derive(Deserialize, Debug, Default, Clone)]
63+
struct TomlGoalsConfig {
64+
/// Static link definitions (name -> URL)
65+
/// Maps from link names like "Help wanted" to URLs like "https://img.shields.io/badge/Help%20wanted-yellow"
66+
#[serde(default)]
67+
pub links: HashMap<String, String>,
68+
69+
/// Linkifier patterns (regex pattern -> URL template)
70+
/// Maps from patterns like "RFC #([0-9]+)" to URL templates like "https://github.com/rust-lang/rfcs/pull/$1"
71+
#[serde(default)]
72+
pub linkifiers: HashMap<String, String>,
73+
74+
/// User display name overrides (username -> display name)
75+
/// Maps from usernames like "@nikomatsakis" to display names like "Niko Matsakis"
76+
#[serde(default)]
77+
pub users: HashMap<String, String>,
78+
79+
/// Usernames to ignore during auto-linking
80+
/// List of usernames like ["@bot", "@automated"] that should not be auto-linked
81+
#[serde(default)]
82+
pub ignore_users: Vec<String>,
83+
}
84+
85+
/// Parsed and processed goals configuration ready for use
86+
/// This is the public interface that components should use
87+
#[derive(Debug, Clone)]
88+
pub struct GoalsConfig {
89+
/// Static link definitions (name -> URL)
90+
pub links: HashMap<String, String>,
91+
/// Compiled linkifier patterns (regex -> URL template)
92+
pub linkifiers: Vec<(Regex, String)>,
93+
/// User display name overrides (username -> display name)
94+
pub users: HashMap<String, String>,
95+
/// Usernames to ignore during auto-linking
96+
pub ignore_users: Vec<String>,
97+
}
98+
99+
impl GoalsConfig {
100+
/// Load and parse goals configuration from book.toml
101+
pub fn from_book_toml(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
102+
let content = std::fs::read_to_string(path.as_ref()).context(format!(
103+
"Failed to read book.toml at {}",
104+
path.as_ref().display()
105+
))?;
106+
let book_config: TomlBookConfig = toml::from_str(&content).context(format!(
107+
"Failed to parse book.toml at {}",
108+
path.as_ref().display()
109+
))?;
110+
Self::from_toml_goals_config(book_config.preprocessor.goals)
111+
}
112+
113+
/// Convert from raw TomlGoalsConfig to processed GoalsConfig
114+
fn from_toml_goals_config(config: TomlGoalsConfig) -> anyhow::Result<Self> {
115+
// Compile linkifier regex patterns
116+
let linkifiers: Vec<(Regex, String)> = config
117+
.linkifiers
118+
.into_iter()
119+
.map(|(pattern, url_template)| {
120+
let regex = Regex::new(&format!(r"\[{}\]", pattern))
121+
.context(format!("Invalid linkifier pattern: {}", pattern))?;
122+
Ok((regex, url_template))
123+
})
124+
.collect::<anyhow::Result<Vec<_>>>()?;
125+
126+
Ok(GoalsConfig {
127+
links: config.links,
128+
linkifiers,
129+
users: config.users,
130+
ignore_users: config.ignore_users,
131+
})
132+
}
133+
134+
/// Create empty/default configuration
135+
pub fn default() -> Self {
136+
Self {
137+
links: HashMap::new(),
138+
linkifiers: Vec::new(),
139+
users: HashMap::new(),
140+
ignore_users: Vec::new(),
141+
}
142+
}
143+
}
144+
145+
#[cfg(test)]
146+
mod tests {
147+
use super::*;
148+
use std::io::Write;
149+
use tempfile::NamedTempFile;
150+
151+
#[test]
152+
fn test_goals_config_empty_toml() {
153+
let mut file = NamedTempFile::new().unwrap();
154+
writeln!(file, "# empty book.toml").unwrap();
155+
156+
let config = GoalsConfig::from_book_toml(file.path()).unwrap();
157+
assert!(config.links.is_empty());
158+
assert!(config.linkifiers.is_empty());
159+
assert!(config.users.is_empty());
160+
assert!(config.ignore_users.is_empty());
161+
}
162+
163+
#[test]
164+
fn test_goals_config_complete_toml() {
165+
let mut file = NamedTempFile::new().unwrap();
166+
writeln!(
167+
file,
168+
r#"
169+
[preprocessor.goals.links]
170+
"Help wanted" = "https://img.shields.io/badge/Help%20wanted-yellow"
171+
"Complete" = "https://img.shields.io/badge/Complete-green"
172+
173+
[preprocessor.goals.linkifiers]
174+
"RFC #([0-9]+)" = "https://github.com/rust-lang/rfcs/pull/$1"
175+
"([a-zA-Z0-9-]+)/([a-zA-Z0-9-]+)#([0-9]+)" = "https://github.com/$1/$2/issues/$3"
176+
177+
[preprocessor.goals.users]
178+
"@nikomatsakis" = "Niko Matsakis"
179+
"@Nadrieril" = "@Nadrieril"
180+
181+
[preprocessor.goals]
182+
ignore_users = ["@bot", "@automated"]
183+
"#
184+
)
185+
.unwrap();
186+
187+
let config = GoalsConfig::from_book_toml(file.path()).unwrap();
188+
189+
// Check links
190+
assert_eq!(config.links.len(), 2);
191+
assert_eq!(
192+
config.links.get("Help wanted"),
193+
Some(&"https://img.shields.io/badge/Help%20wanted-yellow".to_string())
194+
);
195+
assert_eq!(
196+
config.links.get("Complete"),
197+
Some(&"https://img.shields.io/badge/Complete-green".to_string())
198+
);
199+
200+
// Check linkifiers (now compiled regex patterns)
201+
assert_eq!(config.linkifiers.len(), 2);
202+
// Check that the patterns are compiled correctly by finding one with the expected URL template
203+
let rfc_linkifier = config
204+
.linkifiers
205+
.iter()
206+
.find(|(_, template)| template == "https://github.com/rust-lang/rfcs/pull/$1");
207+
assert!(rfc_linkifier.is_some());
208+
209+
// Check users
210+
assert_eq!(config.users.len(), 2);
211+
assert_eq!(
212+
config.users.get("@nikomatsakis"),
213+
Some(&"Niko Matsakis".to_string())
214+
);
215+
assert_eq!(
216+
config.users.get("@Nadrieril"),
217+
Some(&"@Nadrieril".to_string())
218+
);
219+
220+
// Check ignore_users
221+
assert_eq!(config.ignore_users.len(), 2);
222+
assert!(config.ignore_users.contains(&"@bot".to_string()));
223+
assert!(config.ignore_users.contains(&"@automated".to_string()));
224+
}
225+
226+
#[test]
227+
fn test_goals_config_partial_toml() {
228+
let mut file = NamedTempFile::new().unwrap();
229+
writeln!(
230+
file,
231+
r#"
232+
[book]
233+
title = "Some Book"
234+
235+
[preprocessor.goals.users]
236+
"@alice" = "Alice"
237+
238+
[preprocessor.other]
239+
some_other_config = "value"
240+
"#
241+
)
242+
.unwrap();
243+
244+
let config = GoalsConfig::from_book_toml(file.path()).unwrap();
245+
246+
// Only users should be populated, others should be empty
247+
assert!(config.links.is_empty());
248+
assert!(config.linkifiers.is_empty());
249+
assert_eq!(config.users.len(), 1);
250+
assert_eq!(config.users.get("@alice"), Some(&"Alice".to_string()));
251+
assert!(config.ignore_users.is_empty());
252+
}
253+
254+
#[test]
255+
fn test_goals_config_invalid_toml() {
256+
let mut file = NamedTempFile::new().unwrap();
257+
writeln!(file, "invalid toml [").unwrap();
258+
259+
let result = GoalsConfig::from_book_toml(file.path());
260+
assert!(result.is_err());
261+
}
262+
263+
#[test]
264+
fn test_goals_config_missing_file() {
265+
let result = GoalsConfig::from_book_toml("/nonexistent/file.toml");
266+
assert!(result.is_err());
267+
}
268+
269+
#[test]
270+
fn test_goals_config_default() {
271+
let config = GoalsConfig::default();
272+
assert!(config.links.is_empty());
273+
assert!(config.linkifiers.is_empty());
274+
assert!(config.users.is_empty());
275+
assert!(config.ignore_users.is_empty());
276+
}
277+
278+
#[test]
279+
fn test_goals_config_clone() {
280+
let mut config = GoalsConfig::default();
281+
config
282+
.users
283+
.insert("@test".to_string(), "Test User".to_string());
284+
285+
let cloned = config.clone();
286+
assert_eq!(config.users, cloned.users);
287+
}
288+
}

crates/rust-project-goals/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
pub mod config;
2-
pub mod format_team_ask;
32
pub mod format_champions;
3+
pub mod format_team_ask;
44
pub mod gh;
55
pub mod goal;
6+
pub mod markdown_processor;
67
pub mod markwaydown;
78
pub mod re;
89
pub mod team;

0 commit comments

Comments
 (0)