|
|
@ -1,18 +1,26 @@ |
|
|
|
use crate::AppContext; |
|
|
|
use crate::AppContext; |
|
|
|
use crate::config::{ChannelName, Config, EntryName, VersionName}; |
|
|
|
use crate::config::{ChannelName, Config, EntryName, VersionName}; |
|
|
|
use anyhow::bail; |
|
|
|
use anyhow::{Context, bail}; |
|
|
|
use colored::Colorize; |
|
|
|
use colored::Colorize; |
|
|
|
use faccess::PathExt; |
|
|
|
use faccess::PathExt; |
|
|
|
|
|
|
|
use indexmap::IndexMap; |
|
|
|
use serde::{Deserialize, Serialize}; |
|
|
|
use serde::{Deserialize, Serialize}; |
|
|
|
use std::borrow::Cow; |
|
|
|
use std::borrow::Cow; |
|
|
|
use std::collections::HashMap; |
|
|
|
use std::fs::{File, OpenOptions, read_to_string}; |
|
|
|
use std::fs::{OpenOptions, read_to_string}; |
|
|
|
|
|
|
|
use std::io::{BufRead, BufReader, Write}; |
|
|
|
use std::io::{BufRead, BufReader, Write}; |
|
|
|
use std::path::{Path, PathBuf}; |
|
|
|
use std::path::{Path, PathBuf}; |
|
|
|
|
|
|
|
|
|
|
|
const DIR_ENTRIES: &str = "entries"; |
|
|
|
const DIR_ENTRIES: &str = "entries"; |
|
|
|
const DIR_CHANNELS: &str = "channels"; |
|
|
|
const DIR_CHANNELS: &str = "channels"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const SUPPORTED_FORMAT_VERSION: usize = 1; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Deserialize, Serialize)] |
|
|
|
|
|
|
|
struct Manifest { |
|
|
|
|
|
|
|
/// Versionm of the format
|
|
|
|
|
|
|
|
format_version: usize, |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// Changelog store struct
|
|
|
|
/// Changelog store struct
|
|
|
|
pub struct Store<'a> { |
|
|
|
pub struct Store<'a> { |
|
|
|
/// App context, including config
|
|
|
|
/// App context, including config
|
|
|
@ -20,7 +28,7 @@ pub struct Store<'a> { |
|
|
|
/// Path to the changelog directory
|
|
|
|
/// Path to the changelog directory
|
|
|
|
store_path: PathBuf, |
|
|
|
store_path: PathBuf, |
|
|
|
/// Loaded version history for all channels
|
|
|
|
/// Loaded version history for all channels
|
|
|
|
versions: HashMap<ChannelName, ChannelReleaseStore>, |
|
|
|
versions: IndexMap<ChannelName, ChannelReleaseStore>, |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
impl<'a> Store<'a> { |
|
|
|
impl<'a> Store<'a> { |
|
|
@ -30,8 +38,8 @@ impl<'a> Store<'a> { |
|
|
|
if !store_path.is_dir() { |
|
|
|
if !store_path.is_dir() { |
|
|
|
if init { |
|
|
|
if init { |
|
|
|
// Try to create it
|
|
|
|
// Try to create it
|
|
|
|
eprintln!("Creating changelog dir: {}", store_path.display()); |
|
|
|
std::fs::create_dir_all(&store_path) |
|
|
|
std::fs::create_dir_all(&store_path)?; |
|
|
|
.with_context(|| format!("Creating changelog dir: {}", store_path.display()))?; |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
bail!( |
|
|
|
bail!( |
|
|
|
"Changelog directory does not exist: {}. Use `{} init` to create it.", |
|
|
|
"Changelog directory does not exist: {}. Use `{} init` to create it.", |
|
|
@ -48,10 +56,47 @@ impl<'a> Store<'a> { |
|
|
|
); |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let manifest_path = store_path.join("manifest.json"); |
|
|
|
|
|
|
|
if manifest_path.is_file() { |
|
|
|
|
|
|
|
let manifest_file = OpenOptions::new() |
|
|
|
|
|
|
|
.read(true) |
|
|
|
|
|
|
|
.open(&manifest_path) |
|
|
|
|
|
|
|
.with_context(|| format!("Opening manifest file: {}", manifest_path.display()))?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let manifest: Manifest = serde_json::from_reader(manifest_file) |
|
|
|
|
|
|
|
.with_context(|| format!("Reading manifest file: {}", manifest_path.display()))?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if manifest.format_version != 1 { |
|
|
|
|
|
|
|
bail!( |
|
|
|
|
|
|
|
"clpack store is in format {}. This version of clpack requires format {}", |
|
|
|
|
|
|
|
manifest.format_version, |
|
|
|
|
|
|
|
SUPPORTED_FORMAT_VERSION |
|
|
|
|
|
|
|
); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
println!("Creating clpack manifest file: {}", manifest_path.display()); |
|
|
|
|
|
|
|
let manifest_file = OpenOptions::new() |
|
|
|
|
|
|
|
.create_new(true) |
|
|
|
|
|
|
|
.write(true) |
|
|
|
|
|
|
|
.open(&manifest_path) |
|
|
|
|
|
|
|
.with_context(|| { |
|
|
|
|
|
|
|
format!( |
|
|
|
|
|
|
|
"Opening manifest file for writing: {}", |
|
|
|
|
|
|
|
manifest_path.display() |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
})?; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let manifest = Manifest { |
|
|
|
|
|
|
|
format_version: SUPPORTED_FORMAT_VERSION, |
|
|
|
|
|
|
|
}; |
|
|
|
|
|
|
|
serde_json::to_writer_pretty(manifest_file, &manifest) |
|
|
|
|
|
|
|
.with_context(|| format!("Writing manifest file: {}", manifest_path.display()))?; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let mut store = Self { |
|
|
|
let mut store = Self { |
|
|
|
store_path, |
|
|
|
store_path, |
|
|
|
ctx, |
|
|
|
ctx, |
|
|
|
versions: HashMap::new(), |
|
|
|
versions: IndexMap::new(), |
|
|
|
}; |
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
store.ensure_internal_subdirs_exist()?; |
|
|
|
store.ensure_internal_subdirs_exist()?; |
|
|
@ -71,8 +116,7 @@ impl<'a> Store<'a> { |
|
|
|
/// Check if a changelog entry exists. Filename is passed without extension.
|
|
|
|
/// Check if a changelog entry exists. Filename is passed without extension.
|
|
|
|
/// This only checks within the current epoch as older files are no longer present.
|
|
|
|
/// This only checks within the current epoch as older files are no longer present.
|
|
|
|
pub fn entry_exists(&self, name: &str) -> bool { |
|
|
|
pub fn entry_exists(&self, name: &str) -> bool { |
|
|
|
let path = self.make_entry_path(name); |
|
|
|
self.make_entry_path(name).exists() |
|
|
|
path.exists() |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/// Load release lists for all channels
|
|
|
|
/// Load release lists for all channels
|
|
|
@ -112,8 +156,8 @@ impl<'a> Store<'a> { |
|
|
|
subdir.display() |
|
|
|
subdir.display() |
|
|
|
); |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
eprintln!("Creating changelog subdir: {}", subdir.display()); |
|
|
|
std::fs::create_dir_all(&subdir) |
|
|
|
std::fs::create_dir_all(&subdir)?; |
|
|
|
.with_context(|| format!("Creating subdir: {}", subdir.display()))?; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if !subdir.writable() { |
|
|
|
if !subdir.writable() { |
|
|
@ -121,7 +165,7 @@ impl<'a> Store<'a> { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if gitkeep { |
|
|
|
if gitkeep { |
|
|
|
std::fs::File::create(subdir.join(".gitkeep"))?; |
|
|
|
File::create(subdir.join(".gitkeep"))?; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
Ok(()) |
|
|
|
Ok(()) |
|
|
@ -132,9 +176,10 @@ impl<'a> Store<'a> { |
|
|
|
let path = self.make_entry_path(name.as_str()); |
|
|
|
let path = self.make_entry_path(name.as_str()); |
|
|
|
let mut file = OpenOptions::new().write(true).create(true).open(&path)?; |
|
|
|
let mut file = OpenOptions::new().write(true).create(true).open(&path)?; |
|
|
|
|
|
|
|
|
|
|
|
eprintln!("Writing changelog entry to file: {}", path.display()); |
|
|
|
println!("Writing changelog entry to file: {}", path.display()); |
|
|
|
|
|
|
|
|
|
|
|
file.write_all(content.as_bytes())?; |
|
|
|
file.write_all(content.as_bytes()) |
|
|
|
|
|
|
|
.with_context(|| format!("Writing file {}", path.display()))?; |
|
|
|
Ok(()) |
|
|
|
Ok(()) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
@ -241,9 +286,9 @@ pub struct Release { |
|
|
|
impl Release { |
|
|
|
impl Release { |
|
|
|
/// Render the entry into a Markdown fragment, using h2 (##) as the title, h3 (###) for sections
|
|
|
|
/// Render the entry into a Markdown fragment, using h2 (##) as the title, h3 (###) for sections
|
|
|
|
pub fn render(&self, entries_dir: impl AsRef<Path>, config: &Config) -> anyhow::Result<String> { |
|
|
|
pub fn render(&self, entries_dir: impl AsRef<Path>, config: &Config) -> anyhow::Result<String> { |
|
|
|
let mut entries_per_section = HashMap::<String, String>::new(); |
|
|
|
let mut entries_per_section = IndexMap::<String, String>::new(); |
|
|
|
let entries_dir = entries_dir.as_ref(); |
|
|
|
let entries_dir = entries_dir.as_ref(); |
|
|
|
let unnamed = "".to_string(); |
|
|
|
let unnamed_section = "".to_string(); |
|
|
|
|
|
|
|
|
|
|
|
for entry in &self.entries { |
|
|
|
for entry in &self.entries { |
|
|
|
let entry_file = entries_dir.join(&format!("{entry}.md")); |
|
|
|
let entry_file = entries_dir.join(&format!("{entry}.md")); |
|
|
@ -258,22 +303,25 @@ impl Release { |
|
|
|
let file = OpenOptions::new().read(true).open(&entry_file)?; |
|
|
|
let file = OpenOptions::new().read(true).open(&entry_file)?; |
|
|
|
let reader = BufReader::new(file); |
|
|
|
let reader = BufReader::new(file); |
|
|
|
|
|
|
|
|
|
|
|
let mut current_section = unnamed.clone(); |
|
|
|
let mut current_section = unnamed_section.clone(); |
|
|
|
for line in reader.lines() { |
|
|
|
for line in reader.lines() { |
|
|
|
let line = line?; |
|
|
|
let line = line?; |
|
|
|
if line.trim().is_empty() { |
|
|
|
let line = line.trim_end(); |
|
|
|
|
|
|
|
let line_trimmed = line.trim(); |
|
|
|
|
|
|
|
if line_trimmed.is_empty() { |
|
|
|
continue; |
|
|
|
continue; |
|
|
|
} |
|
|
|
} |
|
|
|
if line.trim().starts_with('#') { |
|
|
|
if line_trimmed.starts_with('#') { |
|
|
|
// It is a section name
|
|
|
|
// It is a section name
|
|
|
|
let section = line.trim_matches(|c| c == '#' || c == ' '); |
|
|
|
current_section = line |
|
|
|
current_section = section.to_string(); |
|
|
|
.trim_start_matches(|c| c == '#' || c == ' ') |
|
|
|
|
|
|
|
.to_string(); |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
if let Some(buffer) = entries_per_section.get_mut(¤t_section) { |
|
|
|
if let Some(buffer) = entries_per_section.get_mut(¤t_section) { |
|
|
|
buffer.push('\n'); |
|
|
|
buffer.push('\n'); |
|
|
|
buffer.push_str(&line); |
|
|
|
buffer.push_str(&line); |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
entries_per_section.insert(current_section.clone(), line); |
|
|
|
entries_per_section.insert(current_section.clone(), line.to_string()); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
@ -282,12 +330,12 @@ impl Release { |
|
|
|
let mut reordered_sections = Vec::<(String, String)>::new(); |
|
|
|
let mut reordered_sections = Vec::<(String, String)>::new(); |
|
|
|
|
|
|
|
|
|
|
|
// First the unlabelled section (this is probably junk, but it was entered by the user, so keep it)
|
|
|
|
// First the unlabelled section (this is probably junk, but it was entered by the user, so keep it)
|
|
|
|
if let Some(unlabelled) = entries_per_section.remove("") { |
|
|
|
if let Some(unlabelled) = entries_per_section.swap_remove("") { |
|
|
|
reordered_sections.push(("".to_string(), unlabelled)); |
|
|
|
reordered_sections.push(("".to_string(), unlabelled)); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
for section_name in [unnamed].iter().chain(config.sections.iter()) { |
|
|
|
for section_name in [unnamed_section].iter().chain(config.sections.iter()) { |
|
|
|
if let Some(content) = entries_per_section.remove(section_name) { |
|
|
|
if let Some(content) = entries_per_section.swap_remove(section_name) { |
|
|
|
reordered_sections.push((section_name.clone(), content)); |
|
|
|
reordered_sections.push((section_name.clone(), content)); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
@ -307,12 +355,13 @@ impl Release { |
|
|
|
|
|
|
|
|
|
|
|
for (section_name, content) in reordered_sections { |
|
|
|
for (section_name, content) in reordered_sections { |
|
|
|
if !section_name.is_empty() { |
|
|
|
if !section_name.is_empty() { |
|
|
|
buffer.push_str(&format!("\n### {}\n\n", section_name)); |
|
|
|
buffer.push_str(&format!("\n### {}\n", section_name)); |
|
|
|
} |
|
|
|
} |
|
|
|
buffer.push_str(content.trim_end()); |
|
|
|
buffer.push_str(content.trim_end()); |
|
|
|
buffer.push_str("\n\n"); |
|
|
|
buffer.push_str("\n"); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
buffer.push_str("\n"); |
|
|
|
Ok(buffer) |
|
|
|
Ok(buffer) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|