1use crate::error::{Error, Result};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use uuid::Uuid;
26
27pub const CHANGESETS_DIR: &str = ".cuenv/changesets";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum BumpType {
34 None,
36 Patch,
38 Minor,
40 Major,
42}
43
44impl BumpType {
45 pub fn parse(s: &str) -> Result<Self> {
51 match s.trim().to_lowercase().as_str() {
52 "major" => Ok(Self::Major),
53 "minor" => Ok(Self::Minor),
54 "patch" => Ok(Self::Patch),
55 "none" => Ok(Self::None),
56 _ => Err(Error::changeset_parse(
57 format!("Invalid bump type: {s}. Expected major, minor, patch, or none"),
58 None,
59 )),
60 }
61 }
62
63 #[must_use]
65 pub fn max(self, other: Self) -> Self {
66 if self > other { self } else { other }
67 }
68}
69
70impl std::fmt::Display for BumpType {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::None => write!(f, "none"),
74 Self::Patch => write!(f, "patch"),
75 Self::Minor => write!(f, "minor"),
76 Self::Major => write!(f, "major"),
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct PackageChange {
84 pub name: String,
86 pub bump: BumpType,
88}
89
90impl PackageChange {
91 #[must_use]
93 pub fn new(name: impl Into<String>, bump: BumpType) -> Self {
94 Self {
95 name: name.into(),
96 bump,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Changeset {
104 pub id: String,
106 pub summary: String,
108 pub packages: Vec<PackageChange>,
110 pub description: Option<String>,
112}
113
114impl Changeset {
115 #[must_use]
117 pub fn new(
118 summary: impl Into<String>,
119 packages: Vec<PackageChange>,
120 description: Option<String>,
121 ) -> Self {
122 Self {
123 id: Self::generate_id(),
124 summary: summary.into(),
125 packages,
126 description,
127 }
128 }
129
130 #[must_use]
132 pub fn with_id(
133 id: impl Into<String>,
134 summary: impl Into<String>,
135 packages: Vec<PackageChange>,
136 description: Option<String>,
137 ) -> Self {
138 Self {
139 id: id.into(),
140 summary: summary.into(),
141 packages,
142 description,
143 }
144 }
145
146 #[must_use]
148 fn generate_id() -> String {
149 Uuid::new_v4()
152 .to_string()
153 .replace('-', "")
154 .chars()
155 .take(12)
156 .collect()
157 }
158
159 pub fn parse(content: &str, id: &str) -> Result<Self> {
165 let content = content.trim();
166
167 if !content.starts_with("---") {
169 return Err(Error::changeset_parse(
170 "Changeset must start with '---' frontmatter delimiter",
171 None,
172 ));
173 }
174
175 let after_first = &content[3..];
177 let Some(end_idx) = after_first.find("---") else {
178 return Err(Error::changeset_parse(
179 "Missing closing '---' frontmatter delimiter",
180 None,
181 ));
182 };
183
184 let frontmatter = after_first[..end_idx].trim();
185 let body = after_first[end_idx + 3..].trim();
186
187 let packages = Self::parse_frontmatter(frontmatter)?;
189
190 let (summary, description) = Self::parse_body(body)?;
192
193 Ok(Self {
194 id: id.to_string(),
195 summary,
196 packages,
197 description,
198 })
199 }
200
201 fn parse_frontmatter(frontmatter: &str) -> Result<Vec<PackageChange>> {
203 let mut packages = Vec::new();
204
205 for line in frontmatter.lines() {
206 let line = line.trim();
207 if line.is_empty() {
208 continue;
209 }
210
211 let Some((name_part, bump_part)) = line.split_once(':') else {
213 return Err(Error::changeset_parse(
214 format!("Invalid frontmatter line: {line}. Expected 'package: bump_type'"),
215 None,
216 ));
217 };
218
219 let name = name_part.trim().trim_matches('"').trim_matches('\'');
221 let bump = BumpType::parse(bump_part)?;
222
223 packages.push(PackageChange::new(name, bump));
224 }
225
226 Ok(packages)
227 }
228
229 fn parse_body(body: &str) -> Result<(String, Option<String>)> {
231 if body.is_empty() {
232 return Err(Error::changeset_parse(
233 "Changeset body cannot be empty",
234 None,
235 ));
236 }
237
238 let mut lines = body.lines();
240 let summary = lines
241 .find(|l| !l.trim().is_empty())
242 .ok_or_else(|| Error::changeset_parse("Missing changeset summary", None))?
243 .trim()
244 .to_string();
245
246 let remaining_lines: Vec<&str> = lines.skip_while(|l| l.trim().is_empty()).collect();
248
249 let description = if remaining_lines.is_empty() {
250 None
251 } else {
252 let desc = remaining_lines.join("\n").trim().to_string();
253 if desc.is_empty() { None } else { Some(desc) }
254 };
255
256 Ok((summary, description))
257 }
258
259 #[must_use]
261 pub fn to_markdown(&self) -> String {
262 use std::fmt::Write;
263 let mut output = String::from("---\n");
264
265 for pkg in &self.packages {
267 let _ = writeln!(output, "\"{}\": {}", pkg.name, pkg.bump);
268 }
269
270 output.push_str("---\n\n");
271 output.push_str(&self.summary);
272 output.push('\n');
273
274 if let Some(desc) = &self.description {
275 output.push('\n');
276 output.push_str(desc);
277 output.push('\n');
278 }
279
280 output
281 }
282
283 #[must_use]
285 pub fn filename(&self) -> String {
286 format!("{}.md", self.id)
287 }
288}
289
290pub struct ChangesetManager {
292 root: PathBuf,
294}
295
296impl ChangesetManager {
297 #[must_use]
299 pub fn new(root: &Path) -> Self {
300 Self {
301 root: root.to_path_buf(),
302 }
303 }
304
305 #[must_use]
307 pub fn changesets_dir(&self) -> PathBuf {
308 self.root.join(CHANGESETS_DIR)
309 }
310
311 pub fn ensure_dir(&self) -> Result<()> {
317 let dir = self.changesets_dir();
318 if !dir.exists() {
319 fs::create_dir_all(&dir).map_err(|e| {
320 Error::changeset_io_with_source(
321 "Failed to create changesets directory",
322 Some(dir),
323 e,
324 )
325 })?;
326 }
327 Ok(())
328 }
329
330 pub fn add(&self, changeset: &Changeset) -> Result<PathBuf> {
336 self.ensure_dir()?;
337
338 let path = self.changesets_dir().join(changeset.filename());
339 let content = changeset.to_markdown();
340
341 fs::write(&path, content).map_err(|e| {
342 Error::changeset_io_with_source("Failed to write changeset", Some(path.clone()), e)
343 })?;
344
345 Ok(path)
346 }
347
348 pub fn list(&self) -> Result<Vec<Changeset>> {
354 let dir = self.changesets_dir();
355 if !dir.exists() {
356 return Ok(Vec::new());
357 }
358
359 let mut changesets = Vec::new();
360
361 for entry in fs::read_dir(&dir).map_err(|e| {
362 Error::changeset_io_with_source(
363 "Failed to read changesets directory",
364 Some(dir.clone()),
365 e,
366 )
367 })? {
368 let entry = entry.map_err(|e| {
369 Error::changeset_io_with_source(
370 "Failed to read directory entry",
371 Some(dir.clone()),
372 e,
373 )
374 })?;
375
376 let path = entry.path();
377 if path.extension().is_some_and(|ext| ext == "md")
378 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
379 {
380 let content = fs::read_to_string(&path).map_err(|e| {
381 Error::changeset_io_with_source(
382 "Failed to read changeset file",
383 Some(path.clone()),
384 e,
385 )
386 })?;
387 let changeset = Changeset::parse(&content, stem)?;
388 changesets.push(changeset);
389 }
390 }
391
392 Ok(changesets)
393 }
394
395 pub fn get_package_bumps(&self) -> Result<HashMap<String, BumpType>> {
401 let changesets = self.list()?;
402 let mut bumps: HashMap<String, BumpType> = HashMap::new();
403
404 for changeset in changesets {
405 for pkg_change in changeset.packages {
406 let current = bumps
407 .get(&pkg_change.name)
408 .copied()
409 .unwrap_or(BumpType::None);
410 bumps.insert(pkg_change.name, current.max(pkg_change.bump));
411 }
412 }
413
414 Ok(bumps)
415 }
416
417 pub fn remove(&self, id: &str) -> Result<()> {
423 let path = self.changesets_dir().join(format!("{id}.md"));
424 if path.exists() {
425 fs::remove_file(&path).map_err(|e| {
426 Error::changeset_io_with_source("Failed to remove changeset", Some(path), e)
427 })?;
428 }
429 Ok(())
430 }
431
432 pub fn clear(&self) -> Result<()> {
438 let dir = self.changesets_dir();
439 if dir.exists() {
440 for entry in fs::read_dir(&dir).map_err(|e| {
441 Error::changeset_io_with_source(
442 "Failed to read changesets directory",
443 Some(dir.clone()),
444 e,
445 )
446 })? {
447 let entry = entry.map_err(|e| {
448 Error::changeset_io_with_source(
449 "Failed to read directory entry",
450 Some(dir.clone()),
451 e,
452 )
453 })?;
454 let path = entry.path();
455 if path.extension().is_some_and(|ext| ext == "md") {
456 fs::remove_file(&path).map_err(|e| {
457 Error::changeset_io_with_source("Failed to remove changeset", Some(path), e)
458 })?;
459 }
460 }
461 }
462 Ok(())
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use tempfile::TempDir;
470
471 #[test]
472 fn test_bump_type_parse() {
473 assert_eq!(BumpType::parse("major").unwrap(), BumpType::Major);
474 assert_eq!(BumpType::parse("Minor").unwrap(), BumpType::Minor);
475 assert_eq!(BumpType::parse("PATCH").unwrap(), BumpType::Patch);
476 assert_eq!(BumpType::parse("none").unwrap(), BumpType::None);
477 assert!(BumpType::parse("invalid").is_err());
478 }
479
480 #[test]
481 fn test_bump_type_max() {
482 assert_eq!(BumpType::None.max(BumpType::Patch), BumpType::Patch);
483 assert_eq!(BumpType::Patch.max(BumpType::Minor), BumpType::Minor);
484 assert_eq!(BumpType::Minor.max(BumpType::Major), BumpType::Major);
485 assert_eq!(BumpType::Major.max(BumpType::None), BumpType::Major);
486 }
487
488 #[test]
489 fn test_bump_type_display() {
490 assert_eq!(BumpType::Major.to_string(), "major");
491 assert_eq!(BumpType::Minor.to_string(), "minor");
492 assert_eq!(BumpType::Patch.to_string(), "patch");
493 assert_eq!(BumpType::None.to_string(), "none");
494 }
495
496 #[test]
497 fn test_changeset_new() {
498 let changeset = Changeset::new(
499 "Add feature",
500 vec![PackageChange::new("my-pkg", BumpType::Minor)],
501 Some("Details".to_string()),
502 );
503
504 assert_eq!(changeset.summary, "Add feature");
505 assert_eq!(changeset.packages.len(), 1);
506 assert!(changeset.description.is_some());
507 assert_eq!(changeset.id.len(), 12);
509 }
510
511 #[test]
512 fn test_changeset_parse() {
513 let content = r#"---
514"my-package": minor
515"other-pkg": patch
516---
517
518Add a new feature
519
520This is a longer description
521with multiple lines.
522"#;
523
524 let changeset = Changeset::parse(content, "test-id").unwrap();
525 assert_eq!(changeset.id, "test-id");
526 assert_eq!(changeset.summary, "Add a new feature");
527 assert_eq!(changeset.packages.len(), 2);
528 assert_eq!(changeset.packages[0].name, "my-package");
529 assert_eq!(changeset.packages[0].bump, BumpType::Minor);
530 assert_eq!(changeset.packages[1].name, "other-pkg");
531 assert_eq!(changeset.packages[1].bump, BumpType::Patch);
532 assert!(changeset.description.is_some());
533 assert!(changeset.description.unwrap().contains("multiple lines"));
534 }
535
536 #[test]
537 fn test_changeset_parse_no_description() {
538 let content = r#"---
539"pkg": major
540---
541
542Breaking change summary
543"#;
544
545 let changeset = Changeset::parse(content, "id").unwrap();
546 assert_eq!(changeset.summary, "Breaking change summary");
547 assert!(changeset.description.is_none());
548 }
549
550 #[test]
551 fn test_changeset_to_markdown() {
552 let changeset = Changeset::with_id(
553 "abc123",
554 "Fix bug",
555 vec![PackageChange::new("my-pkg", BumpType::Patch)],
556 None,
557 );
558
559 let md = changeset.to_markdown();
560 assert!(md.contains("---"));
561 assert!(md.contains("\"my-pkg\": patch"));
562 assert!(md.contains("Fix bug"));
563 }
564
565 #[test]
566 fn test_changeset_roundtrip() {
567 let original = Changeset::with_id(
568 "roundtrip",
569 "Test summary",
570 vec![
571 PackageChange::new("pkg-a", BumpType::Minor),
572 PackageChange::new("pkg-b", BumpType::Patch),
573 ],
574 Some("Extended description".to_string()),
575 );
576
577 let markdown = original.to_markdown();
578 let parsed = Changeset::parse(&markdown, "roundtrip").unwrap();
579
580 assert_eq!(parsed.id, original.id);
581 assert_eq!(parsed.summary, original.summary);
582 assert_eq!(parsed.packages.len(), original.packages.len());
583 assert_eq!(parsed.description, original.description);
584 }
585
586 #[test]
587 fn test_changeset_manager_add_list() {
588 let temp = TempDir::new().unwrap();
589 let manager = ChangesetManager::new(temp.path());
590
591 let changeset = Changeset::with_id(
592 "test-cs",
593 "Test change",
594 vec![PackageChange::new("pkg", BumpType::Minor)],
595 None,
596 );
597
598 manager.add(&changeset).unwrap();
599
600 let list = manager.list().unwrap();
601 assert_eq!(list.len(), 1);
602 assert_eq!(list[0].id, "test-cs");
603 }
604
605 #[test]
606 fn test_changeset_manager_get_package_bumps() {
607 let temp = TempDir::new().unwrap();
608 let manager = ChangesetManager::new(temp.path());
609
610 let cs1 = Changeset::with_id(
612 "cs1",
613 "Small fix",
614 vec![PackageChange::new("pkg", BumpType::Patch)],
615 None,
616 );
617 let cs2 = Changeset::with_id(
618 "cs2",
619 "New feature",
620 vec![PackageChange::new("pkg", BumpType::Minor)],
621 None,
622 );
623
624 manager.add(&cs1).unwrap();
625 manager.add(&cs2).unwrap();
626
627 let bumps = manager.get_package_bumps().unwrap();
628 assert_eq!(bumps.get("pkg"), Some(&BumpType::Minor));
630 }
631
632 #[test]
633 fn test_changeset_manager_remove() {
634 let temp = TempDir::new().unwrap();
635 let manager = ChangesetManager::new(temp.path());
636
637 let changeset = Changeset::with_id(
638 "to-remove",
639 "Will be removed",
640 vec![PackageChange::new("pkg", BumpType::Patch)],
641 None,
642 );
643
644 manager.add(&changeset).unwrap();
645 assert_eq!(manager.list().unwrap().len(), 1);
646
647 manager.remove("to-remove").unwrap();
648 assert_eq!(manager.list().unwrap().len(), 0);
649 }
650
651 #[test]
652 fn test_changeset_manager_clear() {
653 let temp = TempDir::new().unwrap();
654 let manager = ChangesetManager::new(temp.path());
655
656 for i in 0..3 {
657 let changeset = Changeset::with_id(
658 format!("cs-{i}"),
659 format!("Change {i}"),
660 vec![PackageChange::new("pkg", BumpType::Patch)],
661 None,
662 );
663 manager.add(&changeset).unwrap();
664 }
665
666 assert_eq!(manager.list().unwrap().len(), 3);
667 manager.clear().unwrap();
668 assert_eq!(manager.list().unwrap().len(), 0);
669 }
670}