added the changelog command

This commit is contained in:
2026-02-26 19:06:41 +01:00
parent 2973430c00
commit 5190291b30
10 changed files with 560 additions and 5 deletions

191
src/changelog.rs Normal file
View File

@@ -0,0 +1,191 @@
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::fs;
use crate::cli::ChangelogCommands;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ChangelogEntry {
pub date: String,
pub message: String,
pub author: String,
pub hash: String,
#[serde(default)]
pub files: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ChangelogConfig {
pub entries: Vec<ChangelogEntry>,
}
pub async fn handle(cmd: ChangelogCommands, root_dir: PathBuf) -> Result<()> {
let changelog_path = root_dir.join("_changelog.toml");
match cmd {
ChangelogCommands::View => {
if !changelog_path.exists() {
println!("No changelog found at {:?}", changelog_path);
return Ok(());
}
let content = fs::read_to_string(&changelog_path).await?;
let config: ChangelogConfig = toml::from_str(&content)?;
println!("Changelog ({} entries):", config.entries.len());
for entry in config.entries {
println!("- [{}] {} ({})", entry.date, entry.message, entry.author);
if !entry.files.is_empty() {
println!(" Files: {:?}\n", entry.files);
}
}
}
ChangelogCommands::GenFromGit { git_path } => {
let git_dir = git_path.unwrap_or_else(|| root_dir.clone());
update_from_git(&changelog_path, &git_dir, &root_dir).await?;
}
}
Ok(())
}
async fn update_from_git(changelog_path: &Path, git_dir: &Path, wiki_root: &Path) -> Result<()> {
let output_root = Command::new("git")
.arg("-C")
.arg(git_dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to find git root")?;
let repo_root_str = String::from_utf8(output_root.stdout)?.trim().to_string();
let repo_root = PathBuf::from(&repo_root_str);
let prefix_path = wiki_root.strip_prefix(&repo_root).unwrap_or(Path::new(""));
let mut prefix = prefix_path.to_string_lossy().to_string().replace('\\', "/");
if !prefix.is_empty() && !prefix.ends_with('/') {
prefix.push('/');
}
// Load existing
let mut config = if changelog_path.exists() {
let content = fs::read_to_string(changelog_path).await?;
toml::from_str(&content).unwrap_or_default()
} else {
ChangelogConfig::default()
};
let existing_hashes: HashSet<String> = config.entries.iter().map(|e| e.hash.clone()).collect();
let output = Command::new("git")
.arg("-C")
.arg(git_dir)
.args([
"log",
"--name-only",
"--pretty=format:---ENTRY---|%H|%ad|%an|%s",
"--date=format:%Y-%m-%d %H:%M:%S",
"--",
".",
])
.output()
.context("Failed to execute git command")?;
if !output.status.success() {
return Err(anyhow!(
"Git command failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let stdout = String::from_utf8(output.stdout)?;
let mut new_entries = Vec::new();
let mut current_entry: Option<ChangelogEntry> = None;
for line in stdout.lines() {
if line.starts_with("---ENTRY---|") {
// Push previous
if let Some(entry) = current_entry.take() {
if !entry.files.is_empty() && !existing_hashes.contains(&entry.hash) {
new_entries.push(entry);
}
}
// Parse new
let parts: Vec<&str> = line.split('|').collect();
if parts.len() >= 5 {
let hash = parts[1].to_string();
let date = parts[2].to_string();
let author = parts[3].to_string();
let message = parts[4..].join("|");
current_entry = Some(ChangelogEntry {
hash,
date,
author,
message,
files: Vec::new(),
});
}
} else if !line.trim().is_empty() {
let raw_path = line.trim();
// Check if file belongs to the wiki directory
if prefix.is_empty() || raw_path.starts_with(&prefix) {
if let Some(entry) = current_entry.as_mut() {
// Strip the prefix to get the relative path inside the wiki
let clean_path = if prefix.is_empty() {
raw_path.to_string()
} else {
raw_path
.strip_prefix(&prefix)
.unwrap_or(raw_path)
.to_string()
};
if clean_path != "changelog.toml" {
entry.files.push(clean_path);
}
}
}
}
}
// Push last
if let Some(entry) = current_entry {
if !entry.files.is_empty() && !existing_hashes.contains(&entry.hash) {
new_entries.push(entry);
}
}
if new_entries.is_empty() {
println!("No new commits touching the wiki directory found.");
return Ok(());
}
println!("Found {} new commits.", new_entries.len());
config.entries.extend(new_entries);
config.entries.sort_by(|a, b| b.date.cmp(&a.date));
let mut toml_str =
String::from("# This file was generated by `wiki-maker changelog gen-from-git`\n\n");
toml_str.push_str(&toml::to_string_pretty(&config)?);
fs::write(changelog_path, toml_str).await?;
println!("Updated {:?}", changelog_path);
Ok(())
}
pub async fn load_changelog(root_dir: &Path) -> Vec<ChangelogEntry> {
let path = root_dir.join("_changelog.toml");
if path.exists() {
if let Ok(content) = fs::read_to_string(path).await {
if let Ok(config) = toml::from_str::<ChangelogConfig>(&content) {
return config.entries;
}
}
}
Vec::new()
}

View File

@@ -45,6 +45,11 @@ pub enum Commands {
#[command(subcommand)]
cmd: EntryCommands,
},
/// Manage the changelog
Changelog {
#[command(subcommand)]
cmd: ChangelogCommands,
},
}
#[derive(Subcommand)]
@@ -67,3 +72,16 @@ pub enum EntryCommands {
name: String,
},
}
#[derive(Subcommand)]
pub enum ChangelogCommands {
/// View the current changelog
View,
/// Generate or update the changelog from git history
GenFromGit {
/// Path to the directory containing `.git/` (defaults to current dir or wiki dir).
/// Note that git will look in parent directories until it finds a `.git/`
#[arg(long)]
git_path: Option<PathBuf>,
},
}

View File

@@ -12,13 +12,16 @@ use syntect::{highlighting::ThemeSet, parsing::SyntaxSet};
use tera::{Context, Tera};
mod analysis;
mod changelog;
mod cli;
mod entry;
mod rendering;
use crate::{
cli::{Cli, Commands},
rendering::{render_page_handler, render_summary_handler, render_wiki_page},
rendering::{
render_changelog_handler, render_page_handler, render_summary_handler, render_wiki_page,
},
};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
@@ -31,6 +34,10 @@ lazy_static! {
("home.html", include_str!("../templates/home.html")),
("page.html", include_str!("../templates/page.html")),
("style.css", include_str!("../templates/style.css")),
(
"changelog.html",
include_str!("../templates/changelog.html"),
),
])
.unwrap();
tera
@@ -98,6 +105,7 @@ async fn main() -> anyhow::Result<()> {
.route("/", get(render_summary_handler))
.route("/{page}", get(render_page_handler))
.route("/style.css", get(serve_css))
.route("/changelog", get(render_changelog_handler))
.nest_service(
"/images",
tower_http::services::ServeDir::new(&shared_state.docs_dir.join("images")),
@@ -139,6 +147,9 @@ async fn main() -> anyhow::Result<()> {
Commands::Entry { cmd } => {
entry::handle(cmd, abs_path).await?;
}
Commands::Changelog { cmd } => {
changelog::handle(cmd, abs_path).await?;
}
}
Ok(())
}
@@ -148,7 +159,9 @@ async fn get_summary_data(docs_dir: &PathBuf, is_static: bool) -> Vec<Page> {
if let Ok(mut entries) = tokio::fs::read_dir(docs_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("toml") {
if path.extension().and_then(|s| s.to_str()) != Some("toml")
|| path.file_name().and_then(|s| s.to_str()) == Some("_changelog.toml")
{
continue;
}
@@ -184,6 +197,8 @@ async fn get_summary_data(docs_dir: &PathBuf, is_static: bool) -> Vec<Page> {
async fn run_build(docs_dir: PathBuf, out_dir: PathBuf, no_navigation: bool) -> anyhow::Result<()> {
tracing::info!("Building static site to: {}", out_dir.display());
let changelog_entries = changelog::load_changelog(&docs_dir).await;
if !no_navigation {
let pages = get_summary_data(&docs_dir, true).await;
let static_pages: Vec<Page> = pages
@@ -194,22 +209,34 @@ async fn run_build(docs_dir: PathBuf, out_dir: PathBuf, no_navigation: bool) ->
})
.collect();
// Render home page
let mut context = Context::new();
context.insert("title", "Wiki Index");
context.insert("files", &static_pages);
context.insert("changelog", &changelog_entries);
context.insert("is_static", &true);
let rendered = TEMPLATES.render("home.html", &context)?;
tokio::fs::write(out_dir.join("index.html"), rendered).await?;
}
// Render changelog page
let mut context = Context::new();
context.insert("title", "Changelog");
context.insert("changelog", &changelog_entries);
context.insert("is_static", &true);
context.insert("no_navigation", &false);
let rendered = TEMPLATES.render("changelog.html", &context)?;
tokio::fs::write(out_dir.join("changelog.html"), &rendered).await?;
let css = TEMPLATES.render("style.css", &Context::new())?;
tokio::fs::write(out_dir.join("style.css"), css).await?;
let mut entries = tokio::fs::read_dir(&docs_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("toml") {
if path.extension().and_then(|s| s.to_str()) == Some("toml")
&& path.file_name().and_then(|s| s.to_str()) != Some("_changelog.toml")
{
let filename = if let Some(file_name) = entry.file_name().to_str() {
file_name.to_string()
} else {

View File

@@ -13,7 +13,8 @@ use syntect::html::highlighted_html_for_string;
use tera::Context;
use crate::{
AppState, SYNTAX_SET, TEMPLATES, THEME_SET, WikiConfig, get_nav_links, get_summary_data,
AppState, SYNTAX_SET, TEMPLATES, THEME_SET, WikiConfig, changelog, get_nav_links,
get_summary_data,
};
pub struct Renderer<'a> {
@@ -202,10 +203,14 @@ pub async fn render_summary_handler(State(state): State<Arc<AppState>>) -> impl
if state.no_navigation {
return (StatusCode::NOT_FOUND, "Disabled").into_response();
}
let pages = get_summary_data(&state.docs_dir, false).await;
let changelog_entries = changelog::load_changelog(&state.docs_dir).await;
let mut context = Context::new();
context.insert("title", "Wiki Index");
context.insert("files", &pages);
context.insert("changelog", &changelog_entries);
context.insert("is_static", &false);
match TEMPLATES.render("home.html", &context) {
@@ -229,3 +234,22 @@ pub async fn render_page_handler(
Err(e) => (StatusCode::NOT_FOUND, format!("<h1>404</h1><p>{}</p>", e)).into_response(),
}
}
pub async fn render_changelog_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
if state.no_navigation {
return (StatusCode::NOT_FOUND, "Disabled").into_response();
}
let changelog_entries = changelog::load_changelog(&state.docs_dir).await;
let mut context = Context::new();
context.insert("title", "Changelog");
context.insert("changelog", &changelog_entries);
context.insert("is_static", &false);
context.insert("no_navigation", &false);
match TEMPLATES.render("changelog.html", &context) {
Ok(rendered) => Html(rendered).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}