Skip to main content

git_harvest/
lib.rs

1/*********************** GNU General Public License 3.0 ***********************\
2|                                                                              |
3|  Copyright (C) 2026 Kevin Matthes                                            |
4|                                                                              |
5|  This program is free software: you can redistribute it and/or modify        |
6|  it under the terms of the GNU General Public License as published by        |
7|  the Free Software Foundation, either version 3 of the License, or           |
8|  (at your option) any later version.                                         |
9|                                                                              |
10|  This program is distributed in the hope that it will be useful,             |
11|  but WITHOUT ANY WARRANTY; without even the implied warranty of              |
12|  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the               |
13|  GNU General Public License for more details.                                |
14|                                                                              |
15|  You should have received a copy of the GNU General Public License           |
16|  along with this program.  If not, see <https://www.gnu.org/licenses/>.      |
17|                                                                              |
18\******************************************************************************/
19
20//! Harvest a CHANGELOG from a repository's Git history.
21//!
22//! Two passes:  `git-harvest scan` harvests a branch's structured commits
23//! into a RON fragment, and `git-harvest assemble` merges the fragments into
24//! a new section of the RON CHANGELOG.  `git-harvest init` writes a fresh
25//! CHANGELOG to start from, and `git-harvest render` exports it as Markdown.
26
27mod changelog;
28mod cli;
29mod git;
30
31pub use crate::{
32    changelog::{
33        Changelog, Configuration, Entry, Fragment, Grammar, Renderer, Section,
34    },
35    cli::{
36        AssembleArguments, Cli, Command, InitArguments, RenderArguments,
37        ScanArguments,
38    },
39};
40
41/// Run `git-harvest` with its arguments already parsed.
42///
43/// # Errors
44///
45/// Returns the [`sysexits::ExitCode`] to terminate with; the human-readable
46/// reason is printed to standard error at the point of failure.
47pub fn run(cli: Cli) -> sysexits::Result<()> {
48    match cli.command {
49        Command::Assemble(arguments) => assemble(&arguments),
50        Command::Init(arguments) => init(&arguments),
51        Command::Render(arguments) => render(&arguments),
52        Command::Scan(arguments) => scan(&arguments),
53    }
54}
55
56/// Write a fresh CHANGELOG holding [`Changelog::default`].
57fn init(arguments: &InitArguments) -> sysexits::Result<()> {
58    if arguments.output.exists() && !arguments.force {
59        eprintln!(
60            "git-harvest:  {} exists already; pass --force to overwrite it",
61            arguments.output.display()
62        );
63        return Err(sysexits::ExitCode::CantCreat);
64    }
65
66    let document = Changelog::default().to_ron()?;
67
68    std::fs::write(&arguments.output, document).map_err(|reason| {
69        eprintln!(
70            "git-harvest:  cannot write {}:  {reason}",
71            arguments.output.display()
72        );
73        sysexits::ExitCode::IoErr
74    })
75}
76
77/// The harvest configuration to scan with:  the CHANGELOG's, or the default.
78fn configuration(
79    changelog: &std::path::Path,
80) -> sysexits::Result<Configuration> {
81    let Ok(source) = std::fs::read_to_string(changelog) else {
82        eprintln!(
83            "git-harvest:  {} not found; scanning with the default \
84             configuration",
85            changelog.display()
86        );
87        return Ok(Configuration::default());
88    };
89
90    match ron::from_str::<Changelog>(&source) {
91        Ok(document) => Ok(document.configuration),
92        Err(reason) => {
93            eprintln!(
94                "git-harvest:  cannot parse {}:  {reason}",
95                changelog.display()
96            );
97            Err(sysexits::ExitCode::DataErr)
98        }
99    }
100}
101
102/// Harvest this branch's structured commits into a `changelog.d/` fragment.
103fn scan(arguments: &ScanArguments) -> sysexits::Result<()> {
104    let configuration = configuration(&arguments.changelog)?;
105    let repository = git::open()?;
106    let commits = git::commits_since(&repository, &arguments.base)?;
107
108    let mut fragment = Fragment::default();
109
110    for commit in &commits {
111        if let Some((bucket, text)) = configuration.parse(&commit.subject) {
112            fragment
113                .record(&bucket, Entry::harvested(&text, &commit.short_hash));
114        }
115    }
116
117    if fragment.is_empty() {
118        eprintln!(
119            "git-harvest:  no structured commits since {}; wrote nothing",
120            arguments.base
121        );
122        return Ok(());
123    }
124
125    let stamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%SZ");
126    let leaf = git::branch_leaf(&repository);
127    let path = arguments.output.join(format!("{stamp}_{leaf}.ron"));
128
129    std::fs::create_dir_all(&arguments.output).map_err(|reason| {
130        eprintln!(
131            "git-harvest:  cannot create {}:  {reason}",
132            arguments.output.display()
133        );
134        sysexits::ExitCode::CantCreat
135    })?;
136
137    if path.exists() && !arguments.force {
138        eprintln!(
139            "git-harvest:  {} exists already; pass --force to overwrite it",
140            path.display()
141        );
142        return Err(sysexits::ExitCode::CantCreat);
143    }
144
145    let count: usize = fragment.changes.values().map(Vec::len).sum();
146    let noun = if count == 1 { "entry" } else { "entries" };
147    let document = fragment.to_ron()?;
148
149    std::fs::write(&path, document).map_err(|reason| {
150        eprintln!("git-harvest:  cannot write {}:  {reason}", path.display());
151        sysexits::ExitCode::IoErr
152    })?;
153
154    eprintln!("git-harvest:  {count} {noun} -> {}", path.display());
155    Ok(())
156}
157
158/// Render the CHANGELOG as a *Keep a Changelog* Markdown file.
159fn render(arguments: &RenderArguments) -> sysexits::Result<()> {
160    let changelog = read_changelog(&arguments.changelog)?;
161    let released = changelog
162        .sections
163        .iter()
164        .filter(|section| section.released.is_some())
165        .count();
166
167    std::fs::write(&arguments.output, changelog.to_markdown()).map_err(
168        |reason| {
169            eprintln!(
170                "git-harvest:  cannot write {}:  {reason}",
171                arguments.output.display()
172            );
173            sysexits::ExitCode::IoErr
174        },
175    )?;
176
177    let noun = if released == 1 { "section" } else { "sections" };
178    eprintln!(
179        "git-harvest:  {released} {noun} -> {}",
180        arguments.output.display()
181    );
182    Ok(())
183}
184
185/// Read and parse a whole CHANGELOG document.
186fn read_changelog(path: &std::path::Path) -> sysexits::Result<Changelog> {
187    let source = std::fs::read_to_string(path).map_err(|reason| {
188        eprintln!(
189            "git-harvest:  cannot read {}:  {reason}; run `git-harvest init` \
190             first",
191            path.display()
192        );
193        sysexits::ExitCode::NoInput
194    })?;
195
196    ron::from_str(&source).map_err(|reason| {
197        eprintln!("git-harvest:  cannot parse {}:  {reason}", path.display());
198        sysexits::ExitCode::DataErr
199    })
200}
201
202/// Every `*.ron` fragment in `directory`, sorted by name.
203fn fragment_paths(directory: &std::path::Path) -> Vec<std::path::PathBuf> {
204    let Ok(entries) = std::fs::read_dir(directory) else {
205        return Vec::new();
206    };
207
208    let mut paths: Vec<_> = entries
209        .filter_map(Result::ok)
210        .map(|entry| entry.path())
211        .filter(|path| path.extension().is_some_and(|end| end == "ron"))
212        .collect();
213
214    paths.sort();
215    paths
216}
217
218/// Sort and deduplicate every bucket's entries.
219fn tidy(changes: &mut std::collections::BTreeMap<String, Vec<Entry>>) {
220    for entries in changes.values_mut() {
221        entries.sort();
222        entries.dedup();
223    }
224}
225
226/// Read the `paths` fragments into one section for `version` at `released`.
227fn harvested_section(
228    version: semver::Version,
229    released: chrono::DateTime<chrono::Utc>,
230    paths: &[std::path::PathBuf],
231) -> sysexits::Result<Section> {
232    let mut section = Section {
233        version,
234        released: Some(released),
235        introduction: None,
236        references: std::collections::BTreeMap::new(),
237        changes: std::collections::BTreeMap::new(),
238    };
239
240    for path in paths {
241        let source = std::fs::read_to_string(path).map_err(|reason| {
242            eprintln!(
243                "git-harvest:  cannot read {}:  {reason}",
244                path.display()
245            );
246            sysexits::ExitCode::NoInput
247        })?;
248        let fragment: Fragment = ron::from_str(&source).map_err(|reason| {
249            eprintln!(
250                "git-harvest:  cannot parse {}:  {reason}",
251                path.display()
252            );
253            sysexits::ExitCode::DataErr
254        })?;
255
256        section.references.extend(fragment.references);
257        for (bucket, entries) in fragment.changes {
258            section.changes.entry(bucket).or_default().extend(entries);
259        }
260    }
261
262    tidy(&mut section.changes);
263    Ok(section)
264}
265
266/// Merge `section` into the CHANGELOG, joining a same-version section or
267/// inserting a new one in descending version order.
268fn splice(changelog: &mut Changelog, section: Section) {
269    if let Some(existing) = changelog
270        .sections
271        .iter_mut()
272        .find(|existing| existing.version == section.version)
273    {
274        existing.references.extend(section.references);
275        for (bucket, entries) in section.changes {
276            existing.changes.entry(bucket).or_default().extend(entries);
277        }
278        existing.released = existing.released.max(section.released);
279        tidy(&mut existing.changes);
280    } else {
281        let at = changelog
282            .sections
283            .iter()
284            .position(|existing| existing.version < section.version)
285            .unwrap_or(changelog.sections.len());
286        changelog.sections.insert(at, section);
287    }
288}
289
290/// Merge the harvested fragments into a new CHANGELOG section.
291fn assemble(arguments: &AssembleArguments) -> sysexits::Result<()> {
292    let version =
293        arguments
294            .version
295            .parse::<semver::Version>()
296            .map_err(|reason| {
297                eprintln!(
298                    "git-harvest:  {:?} is not a version:  {reason}",
299                    arguments.version
300                );
301                sysexits::ExitCode::Usage
302            })?;
303
304    let released = match &arguments.released {
305        None => chrono::Utc::now(),
306        Some(text) => {
307            text.parse::<chrono::DateTime<chrono::Utc>>()
308                .map_err(|reason| {
309                    eprintln!(
310                        "git-harvest:  {text:?} is not a moment:  {reason}"
311                    );
312                    sysexits::ExitCode::Usage
313                })?
314        }
315    };
316
317    let mut changelog = read_changelog(&arguments.changelog)?;
318    let fragments = fragment_paths(&arguments.input);
319
320    if fragments.is_empty() {
321        eprintln!(
322            "git-harvest:  no fragments in {}; nothing to assemble",
323            arguments.input.display()
324        );
325        return Ok(());
326    }
327
328    let section = harvested_section(version.clone(), released, &fragments)?;
329    splice(&mut changelog, section);
330
331    std::fs::write(&arguments.changelog, changelog.to_ron()?).map_err(
332        |reason| {
333            eprintln!(
334                "git-harvest:  cannot write {}:  {reason}",
335                arguments.changelog.display()
336            );
337            sysexits::ExitCode::IoErr
338        },
339    )?;
340
341    for path in &fragments {
342        std::fs::remove_file(path).map_err(|reason| {
343            eprintln!(
344                "git-harvest:  cannot delete {}:  {reason}",
345                path.display()
346            );
347            sysexits::ExitCode::IoErr
348        })?;
349    }
350
351    eprintln!(
352        "git-harvest:  {} fragments -> section {version} of {}",
353        fragments.len(),
354        arguments.changelog.display()
355    );
356    Ok(())
357}
358
359/******************************************************************************/