fix: make the workspace pass the CI lint/test gate
The new Gitea Actions build gate runs `cargo fmt --check`, `clippy -D warnings`, and `cargo test` — stricter than the old deploy.sh, which only `cargo build`d. That surfaced pre-existing drift that never compiled under the test/clippy profile: - apply rustfmt across the workspace (formatting only, no logic changes) - moments-data: add the missing `prune_events` to the test-only `NoopWriter` stub (the EventWriter trait gained it with the blog-prune feature; a plain `cargo build` never compiles the `#[cfg(test)]` stub, so it went stale) - moments-api: `.max().min()` -> `.clamp()`, and build `usvg::Options` with struct-update syntax instead of post-Default field assignment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7zF7Kf4JqDwa6M8Qgge9M
This commit is contained in:
@@ -11,7 +11,10 @@ use chrono::{DateTime, Datelike, NaiveDate, Utc};
|
||||
use clap::Parser;
|
||||
use moments_core::{EventReader, reshape};
|
||||
use moments_data::PgStore;
|
||||
use moments_entities::{BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount, ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem};
|
||||
use moments_entities::{
|
||||
BlogPost, BlogPostSummary, DailyCount, Event, EventQuery, HourlyAvg, LanguageDailyCount,
|
||||
ProjectSummary, RepoLanguage, Source, SourceSummary, TimelineItem,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use tracing::info;
|
||||
@@ -104,11 +107,7 @@ async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<EventsQueryParams>,
|
||||
) -> Result<Json<Vec<TimelineItem>>, ApiError> {
|
||||
let sources = params
|
||||
.source
|
||||
.as_deref()
|
||||
.map(parse_sources)
|
||||
.transpose()?;
|
||||
let sources = params.source.as_deref().map(parse_sources).transpose()?;
|
||||
|
||||
let limit = params.limit.unwrap_or(100).clamp(1, 1000);
|
||||
|
||||
@@ -128,9 +127,7 @@ async fn list_events(
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
async fn list_sources(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<SourceSummary>>, ApiError> {
|
||||
async fn list_sources(State(state): State<AppState>) -> Result<Json<Vec<SourceSummary>>, ApiError> {
|
||||
let summaries = state
|
||||
.store
|
||||
.source_summaries(/* include_private */ true)
|
||||
@@ -161,7 +158,11 @@ async fn blog_events(state: &AppState) -> Result<Vec<Event>, ApiError> {
|
||||
}
|
||||
|
||||
fn payload_str<'a>(event: &'a Event, key: &str) -> &'a str {
|
||||
event.payload.get(key).and_then(|v| v.as_str()).unwrap_or("")
|
||||
event
|
||||
.payload
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
async fn list_blog_posts(
|
||||
@@ -212,8 +213,14 @@ async fn daily_counts(
|
||||
Query(params): Query<DailyCountsParams>,
|
||||
) -> Result<Json<Vec<DailyCount>>, ApiError> {
|
||||
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
|
||||
let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365));
|
||||
let counts = state.store.daily_counts(from, to, /* include_private */ true).await.map_err(internal)?;
|
||||
let from = params
|
||||
.from
|
||||
.unwrap_or_else(|| to - chrono::Duration::days(365));
|
||||
let counts = state
|
||||
.store
|
||||
.daily_counts(from, to, /* include_private */ true)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
Ok(Json(counts))
|
||||
}
|
||||
|
||||
@@ -222,8 +229,14 @@ async fn language_daily_counts(
|
||||
Query(params): Query<DailyCountsParams>,
|
||||
) -> Result<Json<Vec<LanguageDailyCount>>, ApiError> {
|
||||
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
|
||||
let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365));
|
||||
let counts = state.store.language_daily_counts(from, to, /* include_private */ true).await.map_err(internal)?;
|
||||
let from = params
|
||||
.from
|
||||
.unwrap_or_else(|| to - chrono::Duration::days(365));
|
||||
let counts = state
|
||||
.store
|
||||
.language_daily_counts(from, to, /* include_private */ true)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
Ok(Json(counts))
|
||||
}
|
||||
|
||||
@@ -242,20 +255,30 @@ async fn hourly_avgs(
|
||||
Query(params): Query<HourlyAvgsParams>,
|
||||
) -> Result<Json<Vec<HourlyAvg>>, ApiError> {
|
||||
let to = params.to.unwrap_or_else(|| Utc::now().date_naive());
|
||||
let from = params.from.unwrap_or_else(|| to - chrono::Duration::days(365));
|
||||
let from = params
|
||||
.from
|
||||
.unwrap_or_else(|| to - chrono::Duration::days(365));
|
||||
let tz = params.tz.as_deref().unwrap_or("UTC");
|
||||
// Validate the tz string before handing it to postgres — a bad name
|
||||
// here would surface as an opaque 500 from the DB. chrono-tz would do
|
||||
// it for free but we don't depend on it; instead reject obvious shell
|
||||
// injection vectors (the value is bound, not interpolated, so this is
|
||||
// belt-and-braces).
|
||||
if tz.len() > 64 || tz.chars().any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-'))) {
|
||||
if tz.len() > 64
|
||||
|| tz
|
||||
.chars()
|
||||
.any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '/' | '_' | '+' | '-')))
|
||||
{
|
||||
return Err(ApiError {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: "invalid tz".into(),
|
||||
});
|
||||
}
|
||||
let avgs = state.store.hourly_avgs(from, to, tz, /* include_private */ true).await.map_err(internal)?;
|
||||
let avgs = state
|
||||
.store
|
||||
.hourly_avgs(from, to, tz, /* include_private */ true)
|
||||
.await
|
||||
.map_err(internal)?;
|
||||
Ok(Json(avgs))
|
||||
}
|
||||
|
||||
@@ -266,9 +289,7 @@ async fn repo_languages(
|
||||
Ok(Json(langs))
|
||||
}
|
||||
|
||||
async fn og_contributions(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
async fn og_contributions(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
// Get date range from source summaries
|
||||
let summaries = state
|
||||
.store
|
||||
@@ -292,10 +313,11 @@ async fn og_contributions(
|
||||
let projects = state.store.list_projects().await.map_err(internal)?;
|
||||
let repo_count = projects.len();
|
||||
|
||||
let png = render_contributions_png(&counts, earliest, today, repo_count).map_err(|e| ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: e,
|
||||
})?;
|
||||
let png =
|
||||
render_contributions_png(&counts, earliest, today, repo_count).map_err(|e| ApiError {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: e,
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
@@ -332,7 +354,13 @@ fn render_contributions_png(
|
||||
let cell = step - gap;
|
||||
let radius = cell / 2.0;
|
||||
|
||||
let colors = ["rgba(255,255,255,0.05)", "#0e4429", "#006d32", "#26a641", "#39d353"];
|
||||
let colors = [
|
||||
"rgba(255,255,255,0.05)",
|
||||
"#0e4429",
|
||||
"#006d32",
|
||||
"#26a641",
|
||||
"#39d353",
|
||||
];
|
||||
|
||||
// Build weekly data per year
|
||||
struct YearRow {
|
||||
@@ -377,16 +405,24 @@ fn render_contributions_png(
|
||||
let thresholds = if non_zero.is_empty() {
|
||||
[1i64, 2, 3]
|
||||
} else {
|
||||
let p = |pct: f64| non_zero[(pct * non_zero.len() as f64).min(non_zero.len() as f64 - 1.0) as usize];
|
||||
let p = |pct: f64| {
|
||||
non_zero[(pct * non_zero.len() as f64).min(non_zero.len() as f64 - 1.0) as usize]
|
||||
};
|
||||
[p(0.25), p(0.5), p(0.75)]
|
||||
};
|
||||
|
||||
let color_for = |count: i64| -> &str {
|
||||
if count == 0 { colors[0] }
|
||||
else if count <= thresholds[0] { colors[1] }
|
||||
else if count <= thresholds[1] { colors[2] }
|
||||
else if count <= thresholds[2] { colors[3] }
|
||||
else { colors[4] }
|
||||
if count == 0 {
|
||||
colors[0]
|
||||
} else if count <= thresholds[0] {
|
||||
colors[1]
|
||||
} else if count <= thresholds[1] {
|
||||
colors[2]
|
||||
} else if count <= thresholds[2] {
|
||||
colors[3]
|
||||
} else {
|
||||
colors[4]
|
||||
}
|
||||
};
|
||||
|
||||
let n_rows = rows.len();
|
||||
@@ -425,7 +461,7 @@ fn render_contributions_png(
|
||||
y = subtitle_y,
|
||||
));
|
||||
|
||||
let label_font_size = (step * 0.7).round().max(8.0).min(14.0);
|
||||
let label_font_size = (step * 0.7).round().clamp(8.0, 14.0);
|
||||
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let y_base = graph_y + (row_idx as f64) * step;
|
||||
@@ -452,20 +488,23 @@ fn render_contributions_png(
|
||||
// Rasterize at 1200x630
|
||||
let mut fontdb = fontdb::Database::new();
|
||||
fontdb.load_system_fonts();
|
||||
let mut opts = resvg::usvg::Options::default();
|
||||
opts.fontdb = std::sync::Arc::new(fontdb);
|
||||
opts.font_family = "Noto Sans".to_owned();
|
||||
let tree = resvg::usvg::Tree::from_str(&svg, &opts)
|
||||
.map_err(|e| format!("svg parse: {e}"))?;
|
||||
let opts = resvg::usvg::Options {
|
||||
fontdb: std::sync::Arc::new(fontdb),
|
||||
font_family: "Noto Sans".to_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
let tree = resvg::usvg::Tree::from_str(&svg, &opts).map_err(|e| format!("svg parse: {e}"))?;
|
||||
|
||||
let mut pixmap =
|
||||
resvg::tiny_skia::Pixmap::new(og_w as u32, og_h as u32).ok_or_else(|| "pixmap alloc failed".to_string())?;
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(og_w as u32, og_h as u32)
|
||||
.ok_or_else(|| "pixmap alloc failed".to_string())?;
|
||||
|
||||
resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
||||
resvg::render(
|
||||
&tree,
|
||||
resvg::tiny_skia::Transform::default(),
|
||||
&mut pixmap.as_mut(),
|
||||
);
|
||||
|
||||
pixmap
|
||||
.encode_png()
|
||||
.map_err(|e| format!("png encode: {e}"))
|
||||
pixmap.encode_png().map_err(|e| format!("png encode: {e}"))
|
||||
}
|
||||
|
||||
/// Allowlisted forge hosts that the proxy may contact.
|
||||
@@ -492,7 +531,11 @@ async fn forge_proxy(
|
||||
}
|
||||
(format!("https://{host}"), "/api/v1")
|
||||
}
|
||||
_ => return Err(ApiError::bad_request(format!("unsupported source: {source}"))),
|
||||
_ => {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"unsupported source: {source}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!("{base}{api_prefix}/{rest}");
|
||||
@@ -528,7 +571,10 @@ fn parse_sources(raw: &str) -> Result<Vec<Source>, ApiError> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.parse::<Source>().map_err(|e| ApiError::bad_request(e.to_string())))
|
||||
.map(|s| {
|
||||
s.parse::<Source>()
|
||||
.map_err(|e| ApiError::bad_request(e.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -557,6 +603,10 @@ fn internal<E: std::fmt::Display>(e: E) -> ApiError {
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(self.status, Json(serde_json::json!({ "error": self.message }))).into_response()
|
||||
(
|
||||
self.status,
|
||||
Json(serde_json::json!({ "error": self.message })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user