1use std::path::Path;
2
3use crate::contract::Scope;
4use crate::contract::version::{normalize_version, read_all_config_versions};
5
6#[derive(Debug)]
12pub enum GitSourceError {
13 RepoOpen(String),
15 Git2(git2::Error),
17}
18
19impl std::fmt::Display for GitSourceError {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 match self {
22 Self::RepoOpen(p) => write!(f, "无法打开仓库: {}", p),
23 Self::Git2(e) => write!(f, "git2 错误: {}", e),
24 }
25 }
26}
27
28impl std::error::Error for GitSourceError {}
29
30impl From<git2::Error> for GitSourceError {
31 fn from(e: git2::Error) -> Self {
32 Self::Git2(e)
33 }
34}
35
36#[derive(Debug)]
42pub struct VersionStatus {
43 pub tag_version: Option<String>,
45 pub config_version: Option<String>,
47 pub consistent: bool,
49 pub config_files: Vec<(String, Option<String>)>,
51}
52
53pub fn latest_tag(repo_path: &Path, scope_name: &str) -> Result<Option<String>, GitSourceError> {
64 let tags = all_tags(repo_path)?;
65 let prefix = format!("{}/", scope_name);
66
67 let mut scoped: Vec<&str> = Vec::new();
68 let mut unscoped: Vec<&str> = Vec::new();
69 for tag in &tags {
70 if let Some(rest) = tag.strip_prefix(&prefix) {
71 if !rest.is_empty() {
72 scoped.push(tag);
73 }
74 } else if !tag.contains('/') {
75 unscoped.push(tag);
76 }
77 }
78
79 scoped.sort_by(|a, b| semver_desc(a, b));
80 unscoped.sort_by(|a, b| semver_desc(a, b));
81
82 match scoped.first() {
83 Some(t) => Ok(Some(normalize_version(t))),
84 None => Ok(unscoped.first().map(|t| normalize_version(t))),
85 }
86}
87
88pub fn tags_for_scope(repo_path: &Path, scope_name: &str) -> Result<Vec<String>, GitSourceError> {
90 let tags = all_tags(repo_path)?;
91 let prefix = format!("{}/", scope_name);
92 Ok(tags
93 .into_iter()
94 .filter(|t| t.starts_with(&prefix))
95 .collect())
96}
97
98fn all_tags(repo_path: &Path) -> Result<Vec<String>, GitSourceError> {
100 let repo = git2::Repository::open(repo_path)
101 .map_err(|_| GitSourceError::RepoOpen(repo_path.display().to_string()))?;
102 let tag_names = repo.tag_names(None)?;
103 Ok(tag_names.iter().flatten().map(String::from).collect())
104}
105
106pub fn version_status(repo_path: &Path, scope: &Scope) -> Result<VersionStatus, GitSourceError> {
108 let tag_version = latest_tag(repo_path, &scope.name)?;
109 let scope_dir = repo_path.join(&scope.dir);
110 let config_files = read_all_config_versions(&scope_dir);
111 let config_version = config_files
112 .iter()
113 .find(|(_, v)| v.is_some())
114 .and_then(|(_, v)| v.clone());
115
116 let consistent = match &tag_version {
117 Some(t) => config_files.iter().all(|(_, v)| match v {
118 Some(cv) => cv == t,
119 None => true,
120 }),
121 None => config_version.is_none(),
122 };
123
124 Ok(VersionStatus {
125 tag_version,
126 config_version,
127 consistent,
128 config_files,
129 })
130}
131
132fn parse_semver(tag: &str) -> (u64, u64, u64) {
137 let after_scope = tag.split('/').next_back().unwrap_or(tag);
138 let ver = after_scope.strip_prefix('v').unwrap_or(after_scope);
139 let parts: Vec<&str> = ver.split('.').collect();
140 if parts.len() < 3 {
141 return (0, 0, 0);
142 }
143 let major = parts[0].parse().unwrap_or(0);
144 let minor = parts[1].parse().unwrap_or(0);
145 let patch_str: String = parts[2]
146 .chars()
147 .take_while(|c| c.is_ascii_digit())
148 .collect();
149 let patch = patch_str.parse().unwrap_or(0);
150 (major, minor, patch)
151}
152
153fn semver_desc(a: &str, b: &str) -> std::cmp::Ordering {
154 let va = parse_semver(a);
155 let vb = parse_semver(b);
156 vb.cmp(&va) }
158
159#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn init_repo_with_tags(dir: &Path, tags: &[&str]) {
168 let repo = git2::Repository::init(dir).unwrap();
169 let sig = git2::Signature::now("test", "test@test.com").unwrap();
170 let tree = {
171 let mut index = repo.index().unwrap();
172 let oid = index.write_tree().unwrap();
173 repo.find_tree(oid).unwrap()
174 };
175 let commit = repo
176 .commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
177 .unwrap();
178 for tag in tags {
179 repo.tag_lightweight(tag, &repo.find_object(commit, None).unwrap(), false)
180 .unwrap();
181 }
182 }
183
184 #[test]
187 fn test_latest_tag_no_tags() {
188 let d = tempfile::tempdir().unwrap();
189 git2::Repository::init(d.path()).unwrap();
190 assert_eq!(latest_tag(d.path(), "cli").unwrap(), None);
191 }
192
193 #[test]
194 fn test_latest_tag_scoped() {
195 let d = tempfile::tempdir().unwrap();
196 init_repo_with_tags(d.path(), &["cli/v0.2.0", "cli/v0.1.0", "v1.0.0"]);
197 assert_eq!(latest_tag(d.path(), "cli").unwrap(), Some("0.2.0".into()));
198 }
199
200 #[test]
201 fn test_latest_tag_unscoped_fallback() {
202 let d = tempfile::tempdir().unwrap();
203 init_repo_with_tags(d.path(), &["v1.0.0"]);
204 assert_eq!(latest_tag(d.path(), "cli").unwrap(), Some("1.0.0".into()));
205 }
206
207 #[test]
208 fn test_latest_tag_semver_sort() {
209 let d = tempfile::tempdir().unwrap();
210 init_repo_with_tags(d.path(), &["cli/v9.0.0", "cli/v10.0.0"]);
211 assert_eq!(latest_tag(d.path(), "cli").unwrap(), Some("10.0.0".into()));
212 }
213
214 #[test]
215 fn test_latest_tag_multiple_scopes() {
216 let d = tempfile::tempdir().unwrap();
217 init_repo_with_tags(d.path(), &["cli/v0.2.0", "studio/v0.3.0", "cli/v0.1.0"]);
218 assert_eq!(latest_tag(d.path(), "cli").unwrap(), Some("0.2.0".into()));
219 assert_eq!(
220 latest_tag(d.path(), "studio").unwrap(),
221 Some("0.3.0".into())
222 );
223 }
224
225 #[test]
228 fn test_tags_for_scope() {
229 let d = tempfile::tempdir().unwrap();
230 init_repo_with_tags(d.path(), &["cli/v0.1.0", "cli/v0.2.0", "studio/v0.1.0"]);
231 let tags = tags_for_scope(d.path(), "cli").unwrap();
232 assert_eq!(tags.len(), 2);
233 assert!(tags.contains(&"cli/v0.1.0".to_string()));
234 assert!(tags.contains(&"cli/v0.2.0".to_string()));
235 }
236
237 #[test]
238 fn test_tags_for_scope_no_match() {
239 let d = tempfile::tempdir().unwrap();
240 init_repo_with_tags(d.path(), &["v1.0.0"]);
241 assert!(tags_for_scope(d.path(), "cli").unwrap().is_empty());
242 }
243
244 #[test]
247 fn test_parse_semver_standard() {
248 assert_eq!(parse_semver("v1.2.3"), (1, 2, 3));
249 }
250
251 #[test]
252 fn test_parse_semver_scoped() {
253 assert_eq!(parse_semver("cli/v0.5.0"), (0, 5, 0));
254 }
255
256 #[test]
257 fn test_parse_semver_prerelease() {
258 assert_eq!(parse_semver("v1.0.0-rc.1"), (1, 0, 0));
259 }
260
261 #[test]
262 fn test_parse_semver_no_v() {
263 assert_eq!(parse_semver("1.2.3"), (1, 2, 3));
264 }
265
266 #[test]
267 fn test_parse_semver_invalid() {
268 assert_eq!(parse_semver("not-a-version"), (0, 0, 0));
269 }
270}