added the changelog command
This commit is contained in:
191
src/changelog.rs
Normal file
191
src/changelog.rs
Normal 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()
|
||||
}
|
||||
18
src/cli.rs
18
src/cli.rs
@@ -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>,
|
||||
},
|
||||
}
|
||||
|
||||
35
src/main.rs
35
src/main.rs
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user