1use std::collections::BTreeMap;
2
3use camino::Utf8PathBuf;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{NewgitError, Result};
8use crate::render::RenderRecord;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct BranchInstance {
13 pub id: String,
14 pub name: String,
15 pub slug: String,
16 pub source_ref: String,
18 pub source_rev: String,
20 pub workspace_path: Utf8PathBuf,
21 pub status: InstanceStatus,
22 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
23 pub trackers: BTreeMap<String, TrackerBinding>,
24 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
25 pub resources: BTreeMap<String, ResourceBinding>,
26 pub created_at: DateTime<Utc>,
27 pub updated_at: DateTime<Utc>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct ResourceBinding {
34 pub definition_rev: String,
35 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
36 pub resolved_ports: BTreeMap<String, u16>,
37 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
38 pub resolved_exports: BTreeMap<String, String>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub rendered: Vec<RenderRecord>,
46 pub status: ResourceStatus,
47}
48
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "kebab-case")]
51pub enum ResourceStatus {
52 Pending,
54 Ready,
55 Failed,
56 Blocked,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct TrackerBinding {
63 pub definition_rev: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub content_rev: Option<String>,
67}
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
70#[serde(rename_all = "kebab-case")]
71pub enum InstanceStatus {
72 Active,
73}
74
75impl BranchInstance {
76 pub fn new(
77 name: &str,
78 source_ref: impl Into<String>,
79 source_rev: impl Into<String>,
80 workspace_path: Utf8PathBuf,
81 ) -> Result<Self> {
82 validate_name(name)?;
83 let slug = branch_slug(name);
84 let now = Utc::now();
85 Ok(Self {
86 id: format!("br_{slug}_{}", now.timestamp_millis()),
87 name: name.to_owned(),
88 slug,
89 source_ref: source_ref.into(),
90 source_rev: source_rev.into(),
91 workspace_path,
92 status: InstanceStatus::Active,
93 trackers: BTreeMap::new(),
94 resources: BTreeMap::new(),
95 created_at: now,
96 updated_at: now,
97 })
98 }
99
100 pub fn short_rev(&self) -> &str {
101 self.source_rev.get(..8).unwrap_or(&self.source_rev)
102 }
103}
104
105pub fn branch_slug(name: &str) -> String {
108 let mut slug = String::with_capacity(name.len());
109 let mut previous_dash = false;
110
111 for ch in name.chars().flat_map(char::to_lowercase) {
112 if ch.is_ascii_alphanumeric() {
113 slug.push(ch);
114 previous_dash = false;
115 } else if !previous_dash {
116 slug.push('-');
117 previous_dash = true;
118 }
119 }
120
121 slug.trim_matches('-').to_owned()
122}
123
124pub fn validate_name(name: &str) -> Result<()> {
126 let starts_ok = name
127 .chars()
128 .next()
129 .is_some_and(|ch| ch.is_ascii_alphanumeric());
130 let chars_ok = name
131 .chars()
132 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/'));
133 let refname_ok = !name.contains("..") && !name.ends_with('/') && !name.ends_with(".lock");
134
135 if starts_ok && chars_ok && refname_ok {
136 Ok(())
137 } else {
138 Err(NewgitError::InvalidName(name.to_owned()))
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::{branch_slug, validate_name};
145
146 #[test]
147 fn slug_flattens_and_lowercases() {
148 assert_eq!(branch_slug("Feature/A_b"), "feature-a-b");
149 assert_eq!(branch_slug("auth-refactor"), "auth-refactor");
150 }
151
152 #[test]
153 fn names_stay_inside_git_ref_rules() {
154 assert!(validate_name("feature/login").is_ok());
155 assert!(validate_name("-flag").is_err());
156 assert!(validate_name("a..b").is_err());
157 assert!(validate_name("a.lock").is_err());
158 assert!(validate_name("").is_err());
159 }
160}