cuenv_release/
changeset.rs

1//! Changeset creation, storage, and parsing.
2//!
3//! Changesets are Markdown files stored in `.cuenv/changesets/` that describe
4//! pending changes for a release. They follow a format similar to Changesets
5//! but with cuenv-specific extensions.
6//!
7//! # Changeset Format
8//!
9//! ```markdown
10//! ---
11//! "package-name": minor
12//! "another-package": patch
13//! ---
14//!
15//! Summary of the change (first line is the title)
16//!
17//! Optional longer description with more details.
18//! ```
19
20use crate::error::{Error, Result};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25use uuid::Uuid;
26
27/// The directory name for storing changesets.
28pub const CHANGESETS_DIR: &str = ".cuenv/changesets";
29
30/// Type of version bump for a package.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum BumpType {
34    /// No version change.
35    None,
36    /// Patch version bump (0.0.X).
37    Patch,
38    /// Minor version bump (0.X.0).
39    Minor,
40    /// Major version bump (X.0.0).
41    Major,
42}
43
44impl BumpType {
45    /// Parse a bump type from a string.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if the string is not a valid bump type.
50    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    /// Get the higher of two bump types.
64    #[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/// A change to a specific package.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct PackageChange {
84    /// The package name or path.
85    pub name: String,
86    /// The type of version bump.
87    pub bump: BumpType,
88}
89
90impl PackageChange {
91    /// Create a new package change.
92    #[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/// A changeset describing pending changes for a release.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Changeset {
104    /// Unique identifier for this changeset.
105    pub id: String,
106    /// Summary of the change (first line, used as title).
107    pub summary: String,
108    /// Packages affected by this change.
109    pub packages: Vec<PackageChange>,
110    /// Optional longer description.
111    pub description: Option<String>,
112}
113
114impl Changeset {
115    /// Create a new changeset with a generated ID.
116    #[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    /// Create a changeset with a specific ID.
131    #[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    /// Generate a unique changeset ID.
147    #[must_use]
148    fn generate_id() -> String {
149        // Use UUID v4 for unique IDs, take first 12 hex chars (excluding hyphens) for reasonable brevity
150        // while maintaining sufficient entropy to avoid collisions
151        Uuid::new_v4()
152            .to_string()
153            .replace('-', "")
154            .chars()
155            .take(12)
156            .collect()
157    }
158
159    /// Parse a changeset from its Markdown content.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if the content is not valid changeset format.
164    pub fn parse(content: &str, id: &str) -> Result<Self> {
165        let content = content.trim();
166
167        // Must start and end with frontmatter delimiters
168        if !content.starts_with("---") {
169            return Err(Error::changeset_parse(
170                "Changeset must start with '---' frontmatter delimiter",
171                None,
172            ));
173        }
174
175        // Find the end of frontmatter
176        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        // Parse package bumps from frontmatter
188        let packages = Self::parse_frontmatter(frontmatter)?;
189
190        // Parse summary and description from body
191        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    /// Parse the frontmatter section to extract package bumps.
202    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            // Parse "package-name": bump_type
212            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            // Remove quotes from package name
220            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    /// Parse the body section to extract summary and description.
230    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        // First non-empty line is the summary
239        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        // Rest is the description (if any non-empty content)
247        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    /// Convert the changeset to Markdown format.
260    #[must_use]
261    pub fn to_markdown(&self) -> String {
262        use std::fmt::Write;
263        let mut output = String::from("---\n");
264
265        // Write package bumps in frontmatter
266        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    /// Get the filename for this changeset.
284    #[must_use]
285    pub fn filename(&self) -> String {
286        format!("{}.md", self.id)
287    }
288}
289
290/// Manager for changeset operations.
291pub struct ChangesetManager {
292    /// Root directory of the project.
293    root: PathBuf,
294}
295
296impl ChangesetManager {
297    /// Create a new changeset manager for the given project root.
298    #[must_use]
299    pub fn new(root: &Path) -> Self {
300        Self {
301            root: root.to_path_buf(),
302        }
303    }
304
305    /// Get the changesets directory path.
306    #[must_use]
307    pub fn changesets_dir(&self) -> PathBuf {
308        self.root.join(CHANGESETS_DIR)
309    }
310
311    /// Ensure the changesets directory exists.
312    ///
313    /// # Errors
314    ///
315    /// Returns an error if the directory cannot be created.
316    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    /// Add a new changeset.
331    ///
332    /// # Errors
333    ///
334    /// Returns an error if the changeset cannot be written.
335    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    /// List all pending changesets.
349    ///
350    /// # Errors
351    ///
352    /// Returns an error if the changesets cannot be read.
353    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    /// Get the aggregate bump type for each package from all changesets.
396    ///
397    /// # Errors
398    ///
399    /// Returns an error if the changesets cannot be read.
400    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    /// Remove a changeset by ID.
418    ///
419    /// # Errors
420    ///
421    /// Returns an error if the changeset cannot be removed.
422    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    /// Remove all changesets.
433    ///
434    /// # Errors
435    ///
436    /// Returns an error if the changesets cannot be removed.
437    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        // ID is 12 hex chars (from UUID with hyphens removed, taking first 12)
508        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        // Add two changesets affecting the same package
611        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        // Should be Minor since it's the highest
629        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}