1mod 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
41pub 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
56fn 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
77fn 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
102fn 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
158fn 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
185fn 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
202fn 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
218fn 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
226fn 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
266fn 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
290fn 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