1use crate::api::{Branches, branch_exists, get_branches};
7
8#[derive(Debug, Clone, PartialEq)]
10pub enum UpdateStrategy {
11 SemverTags,
13 NixpkgsChannel,
15 HomeManagerChannel,
17 NixDarwinChannel,
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub enum ChannelType {
24 NixosStable { year: u32, month: u32 },
26 NixpkgsStable { year: u32, month: u32 },
28 Unstable,
30 HomeManagerRelease { year: u32, month: u32 },
32 NixDarwinStable { year: u32, month: u32 },
34 BareVersion { year: u32, month: u32 },
36 Unknown,
38}
39
40impl ChannelType {
41 pub fn is_unstable(&self) -> bool {
43 matches!(self, ChannelType::Unstable)
44 }
45
46 pub fn version(&self) -> Option<(u32, u32)> {
48 match self {
49 ChannelType::NixosStable { year, month }
50 | ChannelType::NixpkgsStable { year, month }
51 | ChannelType::HomeManagerRelease { year, month }
52 | ChannelType::NixDarwinStable { year, month }
53 | ChannelType::BareVersion { year, month } => Some((*year, *month)),
54 _ => None,
55 }
56 }
57
58 pub fn prefix(&self) -> Option<&'static str> {
60 match self {
61 ChannelType::NixosStable { .. } => Some("nixos-"),
62 ChannelType::NixpkgsStable { .. } => Some("nixpkgs-"),
63 ChannelType::HomeManagerRelease { .. } => Some("release-"),
64 ChannelType::NixDarwinStable { .. } => Some("nix-darwin-"),
65 ChannelType::BareVersion { .. } => Some(""),
66 _ => None,
67 }
68 }
69}
70
71pub fn detect_strategy(owner: &str, repo: &str) -> UpdateStrategy {
73 match (owner.to_lowercase().as_str(), repo.to_lowercase().as_str()) {
74 ("nixos", "nixpkgs") => UpdateStrategy::NixpkgsChannel,
75 ("nix-community", "home-manager") => UpdateStrategy::HomeManagerChannel,
76 ("lnl7", "nix-darwin") | ("nix-community", "nix-darwin") => {
77 UpdateStrategy::NixDarwinChannel
78 }
79 _ => UpdateStrategy::SemverTags,
80 }
81}
82
83pub fn parse_channel_ref(ref_str: &str) -> ChannelType {
85 let ref_str = ref_str.strip_prefix("refs/heads/").unwrap_or(ref_str);
86
87 if ref_str == "nixos-unstable" || ref_str == "nixpkgs-unstable" {
88 return ChannelType::Unstable;
89 }
90
91 if ref_str == "master" || ref_str == "main" {
93 return ChannelType::Unstable;
94 }
95
96 if let Some(version) = ref_str.strip_prefix("nixos-")
97 && let Some((year, month)) = parse_version(version)
98 {
99 return ChannelType::NixosStable { year, month };
100 }
101
102 if let Some(version) = ref_str.strip_prefix("nixpkgs-")
103 && let Some((year, month)) = parse_version(version)
104 {
105 return ChannelType::NixpkgsStable { year, month };
106 }
107
108 if let Some(version) = ref_str.strip_prefix("release-")
109 && let Some((year, month)) = parse_version(version)
110 {
111 return ChannelType::HomeManagerRelease { year, month };
112 }
113
114 if let Some(version) = ref_str.strip_prefix("nix-darwin-")
115 && let Some((year, month)) = parse_version(version)
116 {
117 return ChannelType::NixDarwinStable { year, month };
118 }
119
120 if let Some((year, month)) = parse_version(ref_str) {
122 return ChannelType::BareVersion { year, month };
123 }
124
125 ChannelType::Unknown
126}
127
128fn parse_version(version: &str) -> Option<(u32, u32)> {
130 let parts: Vec<&str> = version.split('.').collect();
131 if parts.len() == 2 {
132 let year = parts[0].parse::<u32>().ok()?;
133 let month = parts[1].parse::<u32>().ok()?;
134 if (20..=99).contains(&year) && (month == 5 || month == 11) {
136 return Some((year, month));
137 }
138 }
139 None
140}
141
142pub fn find_latest_channel(
149 current_ref: &str,
150 owner: &str,
151 repo: &str,
152 domain: Option<&str>,
153) -> Option<String> {
154 let current_channel = parse_channel_ref(current_ref);
155
156 if current_channel.is_unstable() {
158 tracing::debug!("Skipping update for unstable channel: {}", current_ref);
159 return None;
160 }
161
162 let prefix = current_channel.prefix()?;
164 let current_version = current_channel.version()?;
165
166 if let Some(latest) = find_latest_channel_targeted(prefix, current_version, owner, repo, domain)
168 {
169 if latest != current_ref {
170 return Some(latest);
171 } else {
172 tracing::debug!("{} is already on the latest channel", current_ref);
173 return None;
174 }
175 }
176
177 tracing::debug!("Targeted lookup failed, falling back to listing all branches");
179 let branches = match get_branches(repo, owner, domain) {
180 Ok(b) => b,
181 Err(e) => {
182 tracing::error!("Failed to fetch branches: {}", e);
183 return None;
184 }
185 };
186
187 let latest = find_latest_matching_branch(&branches, prefix, current_version);
189
190 if let Some(ref latest_branch) = latest
191 && latest_branch == current_ref
192 {
193 tracing::debug!("{} is already on the latest channel", current_ref);
194 return None;
195 }
196
197 latest
198}
199
200fn generate_candidate_channels(prefix: &str, current_version: (u32, u32)) -> Vec<String> {
203 let (current_year, current_month) = current_version;
204 let mut candidates = Vec::new();
205
206 let mut year = current_year;
209 let mut month = current_month;
210
211 for _ in 0..10 {
212 if month == 5 {
214 month = 11;
215 } else {
216 month = 5;
217 year += 1;
218 }
219
220 candidates.push(format!("{}{}.{:02}", prefix, year, month));
221 }
222
223 candidates.reverse();
225 candidates
226}
227
228fn find_latest_channel_targeted(
231 prefix: &str,
232 current_version: (u32, u32),
233 owner: &str,
234 repo: &str,
235 domain: Option<&str>,
236) -> Option<String> {
237 let candidates = generate_candidate_channels(prefix, current_version);
238
239 tracing::debug!(
240 "Checking candidate channels (newest first): {:?}",
241 candidates
242 );
243
244 for candidate in &candidates {
246 tracing::debug!("Checking if branch exists: {}", candidate);
247 if branch_exists(repo, owner, candidate, domain) {
248 tracing::debug!("Found existing channel: {}", candidate);
249 return Some(candidate.clone());
250 }
251 }
252
253 let current_branch = format!("{}{}.{:02}", prefix, current_version.0, current_version.1);
255 tracing::debug!("No newer channel, checking current: {}", current_branch);
256 if branch_exists(repo, owner, ¤t_branch, domain) {
257 return Some(current_branch);
258 }
259
260 None
261}
262
263fn find_latest_matching_branch(
265 branches: &Branches,
266 prefix: &str,
267 current_version: (u32, u32),
268) -> Option<String> {
269 let mut best: Option<(u32, u32, String)> = None;
270
271 for branch_name in &branches.names {
272 if let Some(version_str) = branch_name.strip_prefix(prefix) {
274 if version_str == "unstable" {
276 continue;
277 }
278
279 if let Some((year, month)) = parse_version(version_str) {
280 if (year, month) >= current_version {
282 match &best {
283 None => {
284 best = Some((year, month, branch_name.clone()));
285 }
286 Some((best_year, best_month, _)) => {
287 if (year, month) > (*best_year, *best_month) {
288 best = Some((year, month, branch_name.clone()));
289 }
290 }
291 }
292 }
293 }
294 }
295 }
296
297 best.map(|(_, _, name)| name)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_detect_strategy() {
306 assert_eq!(
307 detect_strategy("nixos", "nixpkgs"),
308 UpdateStrategy::NixpkgsChannel
309 );
310 assert_eq!(
311 detect_strategy("NixOS", "nixpkgs"),
312 UpdateStrategy::NixpkgsChannel
313 );
314 assert_eq!(
315 detect_strategy("nix-community", "home-manager"),
316 UpdateStrategy::HomeManagerChannel
317 );
318 assert_eq!(
319 detect_strategy("LnL7", "nix-darwin"),
320 UpdateStrategy::NixDarwinChannel
321 );
322 assert_eq!(
323 detect_strategy("nix-community", "nix-darwin"),
324 UpdateStrategy::NixDarwinChannel
325 );
326 assert_eq!(
327 detect_strategy("some-user", "some-repo"),
328 UpdateStrategy::SemverTags
329 );
330 }
331
332 #[test]
333 fn test_parse_channel_ref_nixos() {
334 assert_eq!(
335 parse_channel_ref("nixos-24.11"),
336 ChannelType::NixosStable {
337 year: 24,
338 month: 11
339 }
340 );
341 assert_eq!(
342 parse_channel_ref("nixos-25.05"),
343 ChannelType::NixosStable { year: 25, month: 5 }
344 );
345 assert_eq!(parse_channel_ref("nixos-unstable"), ChannelType::Unstable);
346 }
347
348 #[test]
349 fn test_parse_channel_ref_nixpkgs() {
350 assert_eq!(
351 parse_channel_ref("nixpkgs-24.11"),
352 ChannelType::NixpkgsStable {
353 year: 24,
354 month: 11
355 }
356 );
357 assert_eq!(parse_channel_ref("nixpkgs-unstable"), ChannelType::Unstable);
358 }
359
360 #[test]
361 fn test_parse_channel_ref_home_manager() {
362 assert_eq!(
363 parse_channel_ref("release-24.11"),
364 ChannelType::HomeManagerRelease {
365 year: 24,
366 month: 11
367 }
368 );
369 assert_eq!(parse_channel_ref("master"), ChannelType::Unstable);
370 }
371
372 #[test]
373 fn test_parse_channel_ref_nix_darwin() {
374 assert_eq!(
375 parse_channel_ref("nix-darwin-24.11"),
376 ChannelType::NixDarwinStable {
377 year: 24,
378 month: 11
379 }
380 );
381 assert_eq!(parse_channel_ref("main"), ChannelType::Unstable);
382 }
383
384 #[test]
385 fn test_parse_channel_ref_bare_version() {
386 assert_eq!(
387 parse_channel_ref("24.11"),
388 ChannelType::BareVersion {
389 year: 24,
390 month: 11
391 }
392 );
393 assert_eq!(
394 parse_channel_ref("25.05"),
395 ChannelType::BareVersion { year: 25, month: 5 }
396 );
397 }
398
399 #[test]
400 fn test_parse_channel_ref_with_refs_heads_prefix() {
401 assert_eq!(
402 parse_channel_ref("refs/heads/nixos-24.11"),
403 ChannelType::NixosStable {
404 year: 24,
405 month: 11
406 }
407 );
408 }
409
410 #[test]
411 fn test_parse_channel_ref_unknown() {
412 assert_eq!(parse_channel_ref("v1.0.0"), ChannelType::Unknown);
413 assert_eq!(parse_channel_ref("nixos-invalid"), ChannelType::Unknown);
414 assert_eq!(parse_channel_ref("feature-branch"), ChannelType::Unknown);
415 }
416
417 #[test]
418 fn test_channel_type_is_unstable() {
419 assert!(ChannelType::Unstable.is_unstable());
420 assert!(parse_channel_ref("master").is_unstable());
421 assert!(parse_channel_ref("main").is_unstable());
422 assert!(parse_channel_ref("nixos-unstable").is_unstable());
423 assert!(
424 !ChannelType::NixosStable {
425 year: 24,
426 month: 11
427 }
428 .is_unstable()
429 );
430 }
431
432 #[test]
433 fn test_find_latest_matching_branch() {
434 let branches = Branches {
435 names: vec![
436 "nixos-23.11".to_string(),
437 "nixos-24.05".to_string(),
438 "nixos-24.11".to_string(),
439 "nixos-unstable".to_string(),
440 "master".to_string(),
441 ],
442 };
443
444 let result = find_latest_matching_branch(&branches, "nixos-", (24, 5));
446 assert_eq!(result, Some("nixos-24.11".to_string()));
447
448 let result = find_latest_matching_branch(&branches, "nixos-", (24, 11));
450 assert_eq!(result, Some("nixos-24.11".to_string()));
451
452 let result = find_latest_matching_branch(&branches, "nixos-", (25, 5));
454 assert_eq!(result, None);
455 }
456
457 #[test]
458 fn test_generate_candidate_channels() {
459 let candidates = generate_candidate_channels("nixos-", (24, 5));
462 assert_eq!(candidates.len(), 10);
463 assert_eq!(candidates[0], "nixos-29.05");
465 assert_eq!(candidates[9], "nixos-24.11");
467
468 let candidates = generate_candidate_channels("nixpkgs-", (24, 11));
471 assert_eq!(candidates[0], "nixpkgs-29.11"); assert_eq!(candidates[9], "nixpkgs-25.05"); }
474}