1use std::collections::BTreeMap;
2
3use camino::Utf8PathBuf;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{NewgitError, Result};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct BranchInstance {
12 pub id: String,
13 pub name: String,
14 pub slug: String,
15 pub source_ref: String,
17 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#[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 Pending,
46 Ready,
47 Failed,
48 Blocked,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct TrackerBinding {
55 pub definition_rev: String,
56 #[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
97pub 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
116pub 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}