Skip to main content

newgit_core/
branch.rs

1use std::collections::BTreeMap;
2
3use camino::Utf8PathBuf;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{NewgitError, Result};
8
9/// The binding record. The workspace directory is disposable; this is not.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct BranchInstance {
12    pub id: String,
13    pub name: String,
14    pub slug: String,
15    /// Git branch in the store repo (also the branch checked out in the clone).
16    pub source_ref: String,
17    /// Revision the workspace was materialized at.
18    pub source_rev: String,
19    pub workspace_path: Utf8PathBuf,
20    pub status: InstanceStatus,
21    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
22    pub trackers: BTreeMap<String, TrackerBinding>,
23    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
24    pub resources: BTreeMap<String, ResourceBinding>,
25    pub created_at: DateTime<Utc>,
26    pub updated_at: DateTime<Utc>,
27}
28
29/// Which concrete instance of a resource this branch instance is bound to:
30/// its allocated ports and rendered exports.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct ResourceBinding {
33    pub definition_rev: String,
34    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
35    pub resolved_ports: BTreeMap<String, u16>,
36    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
37    pub resolved_exports: BTreeMap<String, String>,
38    pub status: ResourceStatus,
39}
40
41#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "kebab-case")]
43pub enum ResourceStatus {
44    /// Bound; prepare has not succeeded yet.
45    Pending,
46    Ready,
47    Failed,
48    /// Not attempted because a resource dependency is failed or blocked.
49    Blocked,
50}
51
52/// Which content revision of a tracker this instance is bound to.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct TrackerBinding {
55    pub definition_rev: String,
56    /// None when the tracker is bound but no content has been captured yet.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub content_rev: Option<String>,
59}
60
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "kebab-case")]
63pub enum InstanceStatus {
64    Active,
65}
66
67impl BranchInstance {
68    pub fn new(
69        name: &str,
70        source_ref: impl Into<String>,
71        source_rev: impl Into<String>,
72        workspace_path: Utf8PathBuf,
73    ) -> Result<Self> {
74        validate_name(name)?;
75        let slug = branch_slug(name);
76        let now = Utc::now();
77        Ok(Self {
78            id: format!("br_{slug}_{}", now.timestamp_millis()),
79            name: name.to_owned(),
80            slug,
81            source_ref: source_ref.into(),
82            source_rev: source_rev.into(),
83            workspace_path,
84            status: InstanceStatus::Active,
85            trackers: BTreeMap::new(),
86            resources: BTreeMap::new(),
87            created_at: now,
88            updated_at: now,
89        })
90    }
91
92    pub fn short_rev(&self) -> &str {
93        self.source_rev.get(..8).unwrap_or(&self.source_rev)
94    }
95}
96
97/// Filesystem-safe identifier derived from the instance name; used for the
98/// workspace directory and the record filename.
99pub fn branch_slug(name: &str) -> String {
100    let mut slug = String::with_capacity(name.len());
101    let mut previous_dash = false;
102
103    for ch in name.chars().flat_map(char::to_lowercase) {
104        if ch.is_ascii_alphanumeric() {
105            slug.push(ch);
106            previous_dash = false;
107        } else if !previous_dash {
108            slug.push('-');
109            previous_dash = true;
110        }
111    }
112
113    slug.trim_matches('-').to_owned()
114}
115
116/// Names double as Git branch names, so stay well inside ref-name rules.
117pub fn validate_name(name: &str) -> Result<()> {
118    let starts_ok = name
119        .chars()
120        .next()
121        .is_some_and(|ch| ch.is_ascii_alphanumeric());
122    let chars_ok = name
123        .chars()
124        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/'));
125    let refname_ok = !name.contains("..") && !name.ends_with('/') && !name.ends_with(".lock");
126
127    if starts_ok && chars_ok && refname_ok {
128        Ok(())
129    } else {
130        Err(NewgitError::InvalidName(name.to_owned()))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::{branch_slug, validate_name};
137
138    #[test]
139    fn slug_flattens_and_lowercases() {
140        assert_eq!(branch_slug("Feature/A_b"), "feature-a-b");
141        assert_eq!(branch_slug("auth-refactor"), "auth-refactor");
142    }
143
144    #[test]
145    fn names_stay_inside_git_ref_rules() {
146        assert!(validate_name("feature/login").is_ok());
147        assert!(validate_name("-flag").is_err());
148        assert!(validate_name("a..b").is_err());
149        assert!(validate_name("a.lock").is_err());
150        assert!(validate_name("").is_err());
151    }
152}